diff --git a/lib/irc/connection.ex b/lib/irc/connection.ex index 86d8279..4472466 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{} Each `IRC.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 IRC.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 = IRC.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 = IRC.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 = IRC.Account.lookup(sender) message = %IRC.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 IRC.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 = IRC.Account.lookup(sender) message = %IRC.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 IRC.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 = %IRC.Account{}, message}, state) do if net == state.conn.network do user = IRC.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. IRC.UserTrack.joined(channel, who, priv, false) account = IRC.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 IRC.UserTrack.quitted(sender, reason) {:noreply, state} end def handle_info({:joined, channel, sender}, state) do IRC.UserTrack.joined(channel, sender, []) account = IRC.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 IRC.UserTrack.parted(state.network, channel, nick) {:noreply, state} end def handle_info({:parted, channel, %ExIRC.SenderInfo{nick: nick}}, state) do IRC.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 IRC.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 dispatch(["messages"] ++ keys, {:irc, :text, m}) end def publish(m = %IRC.Message{trigger: t = %IRC.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 \\ IRC.PubSub) + 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} 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, 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 IRC.UserTrack.change_privileges(network, channel, nick, {[:operator], []}) :ok end defp track_mode(network, channel, nick, "-o") do IRC.UserTrack.change_privileges(network, channel, nick, {[], [:operator]}) :ok end defp track_mode(network, channel, nick, "+v") do IRC.UserTrack.change_privileges(network, channel, nick, {[:voice], []}) :ok end defp track_mode(network, channel, nick, "-v") do IRC.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 71d6d93..7cd9dc8 100644 --- a/lib/irc/irc.ex +++ b/lib/irc/irc.ex @@ -1,79 +1,79 @@ 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 = IRC.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(IRC.PubSub, 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/nola_irc.ex b/lib/irc/nola_irc.ex index 870b99d..dde99af 100644 --- a/lib/irc/nola_irc.ex +++ b/lib/irc/nola_irc.ex @@ -1,34 +1,33 @@ defmodule Nola.IRC do require Logger def env(), do: Nola.env(:irc) def env(key, default \\ nil), do: Keyword.get(env(), key, default) def application_childs do import Supervisor.Spec IRC.Connection.setup() Nola.Plugins.setup() [ worker(Registry, [[keys: :duplicate, name: IRC.ConnectionPubSub]], id: :registr_irc_conn), - worker(Registry, [[keys: :duplicate, name: IRC.PubSub]], id: :registry_irc), worker(IRC.Membership, []), worker(IRC.Account, []), worker(IRC.UserTrack.Storage, []), worker(Nola.Plugins.Account, []), supervisor(Nola.Plugins.Supervisor, [], [name: Nola.Plugins.Supervisor]), supervisor(IRC.Connection.Supervisor, [], [name: IRC.Connection.Supervisor]), supervisor(IRC.PuppetConnection.Supervisor, [], [name: IRC.PuppetConnection.Supervisor]), ] end # Start plugins first to let them get on connection events. def after_start() do Logger.info("Starting plugins") Nola.Plugins.start_all() Logger.info("Starting connections") IRC.Connection.start_all() end end diff --git a/lib/matrix/room.ex b/lib/matrix/room.ex index c790760..12fabc8 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(IRC.PubSub, "#{state.network}:events", plugin: __MODULE__) + {:ok, _} = Registry.register(Nola.PubSub, "#{state.network}:events", plugin: __MODULE__) for t <- ["messages", "triggers", "outputs", "events"] do - {:ok, _} = Registry.register(IRC.PubSub, "#{state.network}/#{state.channel}:#{t}", plugin: __MODULE__) + {: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 IRC.Memberships ? acc end else acc end end) |> Enum.filter(& &1) for m <- members, do: IRC.UserTrack.joined(state.id, %{network: "matrix", nick: m, user: m, host: "matrix."}, [], true) accounts = IRC.UserTrack.channel(state.network, state.channel) |> Enum.filter(& &1) |> Enum.map(fn(tuple) -> IRC.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 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) IRC.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 IRC.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 IRC.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 = IRC.Account.get(account_id) user = IRC.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/application.ex b/lib/nola/application.ex index 4f3d1da..53ca439 100644 --- a/lib/nola/application.ex +++ b/lib/nola/application.ex @@ -1,56 +1,47 @@ defmodule Nola.Application do use Application - # See https://hexdocs.pm/elixir/Application.html - # for more information on OTP Applications def start(_type, _args) do import Supervisor.Spec Logger.add_backend(Sentry.LoggerBackend) :ok = Nola.Matrix.setup() :ok = Nola.TelegramRoom.setup() # Define workers and child supervisors to be supervised children = [ - # Start the endpoint when the application starts supervisor(NolaWeb.Endpoint, []), - # Start your own worker by calling: Nola.Worker.start_link(arg1, arg2, arg3) - # worker(Nola.Worker, [arg1, arg2, arg3]), worker(Registry, [[keys: :duplicate, name: Nola.BroadcastRegistry]], id: :registry_broadcast), worker(Nola.IcecastAgent, []), worker(Nola.Token, []), worker(Nola.AuthToken, []), Nola.Subnet, {GenMagic.Pool, [name: Nola.GenMagic, pool_size: 2]}, - #worker(Nola.Icecast, []), + worker(Registry, [[keys: :duplicate, name: Nola.PubSub]], id: :registry_nola_pubsub), ] ++ Nola.IRC.application_childs ++ Nola.Matrix.application_childs - # See https://hexdocs.pm/elixir/Supervisor.html - # for other strategies and supported options opts = [strategy: :one_for_one, name: Nola.Supervisor] sup = Supervisor.start_link(children, opts) start_telegram() spawn_link(fn() -> Nola.IRC.after_start() end) spawn_link(fn() -> Nola.Matrix.after_start() end) spawn_link(fn() -> Nola.TelegramRoom.after_start() end) sup end - # Tell Phoenix to update the endpoint configuration - # whenever the application is updated. def config_change(changed, _new, removed) do NolaWeb.Endpoint.config_change(changed, removed) :ok end defp start_telegram() do token = Keyword.get(Application.get_env(:nola, :telegram, []), :key) options = [ username: Keyword.get(Application.get_env(:nola, :telegram, []), :nick, "beauttebot"), purge: false ] telegram = Telegram.Bot.ChatBot.Supervisor.start_link({Nola.Telegram, token, options}) end end diff --git a/lib/plugins/account.ex b/lib/plugins/account.ex index c11d077..245710a 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 `` ``** 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 ``** 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(IRC.PubSub, "messages:private", []) + {:ok, _} = Registry.register(Nola.PubSub, "messages:private", []) {:ok, nil} end def handle_info({:irc, :text, m = %IRC.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 : 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 spec = [{{:"$1", :"$2"}, [{:==, :"$2", {:const, account.id}}], [:"$1"]}] predicates = :dets.select(IRC.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 users = for user <- IRC.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 account = IRC.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 #account = IRC.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 IRC.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 if IRC.Account.get_meta(m.account, "sms-number") do IRC.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 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 code = String.downcase(EntropyString.small_id()) IRC.Account.put_meta(m.account, "sms-validation-code", code) IRC.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 code = String.downcase(EntropyString.small_id()) IRC.Account.delete_meta(m.account, "telegram-id") IRC.Account.put_meta(m.account, "telegram-validation-code", code) IRC.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 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 result = case String.split(m.text, " ") do ["getmeta"] -> for {k, v} <- IRC.Account.get_all_meta(m.account) do case k do "u:"<>key -> "(user) #{key}: #{v}" key -> "#{key}: #{v}" end end ["getmeta", key] -> value = IRC.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 result = case String.split(m.text, " ") do ["setusermeta", key, value] -> IRC.Account.put_user_meta(m.account, key, value) "ok" _ -> "usage: setusermeta " end m.replyfun.(result) {:noreply, state} end def handle_info(_, state) do {:noreply, state} end defp join_account(m, id, token) do old_account = IRC.Account.lookup(m.sender) new_account = IRC.Account.get(id) if new_account && token == new_account.token do case IRC.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 7f44ca2..8ef83fa 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 `` ` [annotation]`**: enregistre un nouveau verre de `montant` d'une boisson à `degrés d'alcool`. * **!santai `` ``**: 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 ``**: points par jour, sur X j. * **!alcoolisme `[pseudo]`**: affiche les points d'alcoolisme. * **!alcoolisme `[pseudo]` ``**: affiche les points d'alcoolisme par jour sur X j. * **+alcoolisme `` `` `[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 ``**: affiche tu pourras être sobre pour ``, 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 `` ``**: donne le nombre d'unités d'alcool dans `` à `°`. * **!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(IRC.PubSub, sub, plugin: __MODULE__) + {: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 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 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 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 {: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 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 {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 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 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 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 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/^(?\d+[.]\d+)cl\s+(?\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 = IRC.Membership.notify_channels(m.account) -- [{m.network,m.channel}] for {net, chan} <- notify do user = IRC.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} <- IRC.Membership.notify_channels(m.account) do user = IRC.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 m.replyfun.("!santai [commentaire]") {:noreply, state} end def get_all_stats() do IRC.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 IRC.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 IRC.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() :: %{IRC.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 nicks = IRC.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, & 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 account = case args do [nick] -> IRC.Account.find_always_by_nick(m.network, m.channel, nick) [] -> m.account end if account do user = IRC.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 nicks = IRC.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 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 = IRC.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 = IRC.Account.get(nick) user = IRC.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 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 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 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 = IRC.Membership.notify_channels(m.account) -- [{m.network,m.channel}] for {net, chan} <- notify do user = IRC.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 {account, duration} = case args do [nick | rest] -> {IRC.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 = IRC.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, & 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, & 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/alcoolog_announcer.ex b/lib/plugins/alcoolog_announcer.ex index 674f19b..167fc01 100644 --- a/lib/plugins/alcoolog_announcer.ex +++ b/lib/plugins/alcoolog_announcer.ex @@ -1,269 +1,269 @@ defmodule Nola.Plugins.AlcoologAnnouncer do require Logger @moduledoc """ Annonce changements d'alcoolog """ @channel "#dmz" @seconds 30 @apero [ "C'EST L'HEURE DE L'APÉRRROOOOOOOO !!", "SAAAAANNNNNNNTTTTTTTTAAAAAAAAIIIIIIIIIIIIIIIIIIIIII", "APÉRO ? APÉRO !", {:timed, ["/!\\ à vos tires bouchons…", "… débouchez …", "… BUVEZ! SANTAIII!"]}, "/!\\ ALERTE APÉRO /!\\", "CED !!! VASE DE ROUGE !", "DIDI UN PETIT RICARD™??!", "ALLEZ GUIGUI UNE PETITE BIERE ?", {:timed, ["/!\\ à vos tires bouchons…", "… débouchez …", "… BUVEZ! SANTAIII!"]}, "APPPPAIIIRRREAAUUUUUUUUUUU" ] def irc_doc, do: nil def start_link(), do: GenServer.start_link(__MODULE__, [], name: __MODULE__) def log(account) do dets_filename = (Nola.data_path() <> "/" <> "alcoologlog.dets") |> String.to_charlist {:ok, dets} = :dets.open_file(dets_filename, [{:type,:bag}]) from = ~U[2020-08-23 19:41:40.524154Z] to = ~U[2020-08-24 19:41:40.524154Z] select = [ {{:"$1", :"$2", :_}, [ {:andalso, {:andalso, {:==, :"$1", {:const, account.id}}, {:>, :"$2", {:const, DateTime.to_unix(from)}}}, {:<, :"$2", {:const, DateTime.to_unix(to)}}} ], [:"$_"]} ] res = :dets.select(dets, select) :dets.close(dets) res end def init(_) do - {:ok, _} = Registry.register(IRC.PubSub, "account", []) + {:ok, _} = Registry.register(Nola.PubSub, "account", []) stats = get_stats() Process.send_after(self(), :stats, :timer.seconds(30)) dets_filename = (Nola.data_path() <> "/" <> "alcoologlog.dets") |> String.to_charlist {:ok, dets} = :dets.open_file(dets_filename, [{:type,:bag}]) ets = nil # :ets.new(__MODULE__.ETS, [:ordered_set, :named_table, :protected, {:read_concurrency, true}]) {:ok, {stats, now(), dets, ets}}#, {:continue, :traverse}} end def handle_continue(:traverse, state = {_, _, dets, ets}) do traverse_fun = fn(obj, dets) -> case obj do {nick, %DateTime{} = dt, active} -> :dets.delete_object(dets, obj) :dets.insert(dets, {nick, DateTime.to_unix(dt), active}) IO.puts("ok #{inspect obj}") dets {nick, ts, value} -> :ets.insert(ets, { {nick, ts}, value }) dets end end :dets.foldl(traverse_fun, dets, dets) :dets.sync(dets) IO.puts("alcoolog announcer fixed") {:noreply, state} end def alcohol_reached(old, new, level) do (old.active < level && new.active >= level) && (new.active5m >= level) end def alcohol_below(old, new, level) do (old.active > level && new.active <= level) && (new.active5m <= level) end def handle_info(:stats, {old_stats, old_now, dets, ets}) do stats = get_stats() now = now() if old_now.hour < 18 && now.hour == 18 do apero = Enum.shuffle(@apero) |> Enum.random() case apero do {:timed, list} -> spawn(fn() -> for line <- list do IRC.Connection.broadcast_message("evolu.net", "#dmz", line) :timer.sleep(:timer.seconds(5)) end end) string -> IRC.Connection.broadcast_message("evolu.net", "#dmz", string) end end #IO.puts "newstats #{inspect stats}" events = for {acct, old} <- old_stats do new = Map.get(stats, acct, nil) #IO.puts "#{acct}: #{inspect(old)} -> #{inspect(new)}" now = DateTime.to_unix(DateTime.utc_now()) if new && new[:active] do :dets.insert(dets, {acct, now, new[:active]}) :ets.insert(ets, {{acct, now}, new[:active]}) else :dets.insert(dets, {acct, now, 0.0}) :ets.insert(ets, {{acct, now}, new[:active]}) end event = cond do old == nil -> nil (old.active > 0) && (new == nil) -> :sober new == nil -> nil alcohol_reached(old, new, 0.5) -> :stopconduire alcohol_reached(old, new, 1.0) -> :g1 alcohol_reached(old, new, 2.0) -> :g2 alcohol_reached(old, new, 3.0) -> :g3 alcohol_reached(old, new, 4.0) -> :g4 alcohol_reached(old, new, 5.0) -> :g5 alcohol_reached(old, new, 6.0) -> :g6 alcohol_reached(old, new, 7.0) -> :g7 alcohol_reached(old, new, 10.0) -> :g10 alcohol_reached(old, new, 13.74) -> :record alcohol_below(old, new, 0.5) -> :conduire alcohol_below(old, new, 1.0) -> :fini1g alcohol_below(old, new, 2.0) -> :fini2g alcohol_below(old, new, 3.0) -> :fini3g alcohol_below(old, new, 4.0) -> :fini4g (old.rising) && (!new.rising) -> :lowering true -> nil end {acct, event} end for {acct, event} <- events do message = case event do :g1 -> [ "[vigicuite jaune] LE GRAMME! LE GRAMME O/", "début de vigicuite jaune ! LE GRAMME ! \\O/", "waiiiiiiii le grammmeee", "bourraiiiiiiiiiiide 1 grammeeeeeeeeeee", ] :g2 -> [ "[vigicuite orange] \\o_YAY 2 GRAMMES ! _o/", "PAITAIIIIIIIIII DEUX GRAMMEESSSSSSSSSSSSSSSSS", "bourrrrrraiiiiiiiiiiiiiiiide 2 grammeeeeeeeeeees", ] :g3 -> [ "et un ! et deux ! et TROIS GRAMMEEESSSSSSS", "[vigicuite rouge] _o/ BOURRAIIDDDEEEE 3 GRAMMESSSSSSSSS \\o/ \\o/" ] :g4 -> [ "[vigicuite écarlate] et un, et deux, et trois, ET QUATRES GRAMMEESSSSSSSSSSSSSSSSSSSssssss" ] :g5 -> "[vigicuite écarlate+] PUTAIN 5 GRAMMES !" :g6 -> "[vigicuite écarlate++] 6 grammes ? Vous pouvez joindre Alcool info service au 0 980 980 930" :g7 -> "[vigicuite c'est la merde] 7 grammes. Le SAMU, c'est le 15." :g10 -> "BORDLE 10 GRAMMES" :record -> "RECORD DU MONDE BATTU ! >13.74g/l !!" :fini1g -> [ "fin d'alerte vigicuite jaune, passage en vert (<1g/l)", "/!\\ alerte moins de 1g/l /!\\" ] :fini2g -> [ "t'as moins de 2 g/l, faut se reprendre là [vigicuite jaune]" ] :fini3g -> [ "fin d'alerte vigicuite rouge, passage en orange (<3g/l)" ] :fini4g -> [ "fin d'alerte vigicuite écarlate, passage en rouge (<4g/l)" ] :lowering -> [ "attention ça baisse!", "tu vas quand même pas en rester là ?", "IL FAUT CONTINUER À BOIRE !", "t'abandonnes déjà ?", "!santai ?", "faut pas en rester là", "il faut se resservir", "coucou faut reboire", "encore un petit verre ?", "abwaaaaaaaaaaaaarrrrrrrrrrrrrr", "taux d'alcoolémie en chute ! agissez avant qu'il soit trop tard!", "ÇA BAISSE !!" ] :stopconduire -> [ "0.5g! bientot le gramme?", "tu peux plus prendre la route... mais... tu peux prendre la route du gramme! !santai !", "fini la conduite!", "0.5! continues faut pas en rester là!", "beau début, continues !", "ça monte! 0.5g/l!" ] :conduire -> [ "tu peux conduire, ou recommencer à boire! niveau critique!", "!santai ?", "tu peux reprendre la route, ou reprendre la route du gramme..", "attention, niveau critique!", "il faut boire !!", "trop de sang dans ton alcool, c'est mauvais pour la santé", "faut pas en rester là !", ] :sober -> [ "sobre…", "/!\\ alerte sobriété /!\\", "... sobre?!?!", "sobre :(", "attention, t'es sobre :/", "danger, alcoolémie à 0.0 !", "sobre! c'était bien on recommence quand ?", "sobre ? Faut recommencer...", "T'es sobre. Ne te laisses pas abattre- ton caviste peut aider.", "Vous êtes sobre ? Ceci n'est pas une fatalité - resservez vous vite !" ] _ -> nil end message = case message do m when is_binary(m) -> m m when is_list(m) -> m |> Enum.shuffle() |> Enum.random() nil -> nil end if message do #IO.puts("#{acct}: #{message}") account = IRC.Account.get(acct) for {net, chan} <- IRC.Membership.notify_channels(account) do user = IRC.UserTrack.find_by_account(net, account) nick = if(user, do: user.nick, else: account.name) IRC.Connection.broadcast_message(net, chan, "#{nick}: #{message}") end end end timer() #IO.puts "tick stats ok" {:noreply, {stats,now,dets,ets}} end def handle_info(_, state) do {:noreply, state} end defp now() do DateTime.utc_now() |> Timex.Timezone.convert("Europe/Paris") end defp get_stats() do Enum.into(Nola.Plugins.Alcoolog.get_all_stats(), %{}) end defp timer() do Process.send_after(self(), :stats, :timer.seconds(@seconds)) end end diff --git a/lib/plugins/base.ex b/lib/plugins/base.ex index b7b5e16..3188495 100644 --- a/lib/plugins/base.ex +++ b/lib/plugins/base.ex @@ -1,132 +1,132 @@ defmodule Nola.Plugins.Base do 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(IRC.PubSub, "trigger:version", regopts) - {:ok, _} = Registry.register(IRC.PubSub, "trigger:help", regopts) - {:ok, _} = Registry.register(IRC.PubSub, "trigger:liquidrender", regopts) - {:ok, _} = Registry.register(IRC.PubSub, "trigger:plugin", regopts) - {:ok, _} = Registry.register(IRC.PubSub, "trigger:plugins", regopts) + {:ok, _} = Registry.register(Nola.PubSub, "trigger:version", regopts) + {:ok, _} = Registry.register(Nola.PubSub, "trigger:help", regopts) + {:ok, _} = Registry.register(Nola.PubSub, "trigger:liquidrender", regopts) + {:ok, _} = Registry.register(Nola.PubSub, "trigger:plugin", regopts) + {:ok, _} = Registry.register(Nola.PubSub, "trigger:plugins", regopts) {:ok, nil} end def handle_info({:irc, :trigger, "plugins", msg = %{trigger: %{type: :bang, args: []}}}, _) do enabled_string = Nola.Plugins.enabled() |> Enum.map(fn(mod) -> mod |> Macro.underscore() |> String.split("/", parts: :infinity) |> List.last() |> Enum.sort() end) |> Enum.join(", ") msg.replyfun.("Enabled plugins: #{enabled_string}") {:noreply, nil} end def handle_info({:irc, :trigger, "plugin", %{trigger: %{type: :query, args: [plugin]}} = m}, _) do module = Module.concat([Nola.Plugins, Macro.camelize(plugin)]) with true <- Code.ensure_loaded?(module), pid when is_pid(pid) <- GenServer.whereis(module) do m.replyfun.("loaded, active: #{inspect(pid)}") else false -> m.replyfun.("not loaded") nil -> msg = case Nola.Plugins.get(module) do :disabled -> "disabled" {_, false, _} -> "disabled" _ -> "not active" end m.replyfun.(msg) end {:noreply, nil} end def handle_info({:irc, :trigger, "plugin", %{trigger: %{type: :plus, args: [plugin]}} = m}, _) do module = Module.concat([Nola.Plugins, Macro.camelize(plugin)]) with true <- Code.ensure_loaded?(module), Nola.Plugins.switch(module, true), {:ok, pid} <- Nola.Plugins.start(module) do m.replyfun.("started: #{inspect(pid)}") else false -> m.replyfun.("not loaded") :ignore -> m.replyfun.("disabled or throttled") {:error, _} -> m.replyfun.("start error") end {:noreply, nil} end def handle_info({:irc, :trigger, "plugin", %{trigger: %{type: :tilde, args: [plugin]}} = m}, _) do module = Module.concat([Nola.Plugins, Macro.camelize(plugin)]) with true <- Code.ensure_loaded?(module), pid when is_pid(pid) <- GenServer.whereis(module), :ok <- GenServer.stop(pid), {:ok, pid} <- Nola.Plugins.start(module) do m.replyfun.("restarted: #{inspect(pid)}") else false -> m.replyfun.("not loaded") nil -> m.replyfun.("not active") end {:noreply, nil} end def handle_info({:irc, :trigger, "plugin", %{trigger: %{type: :minus, args: [plugin]}} = m}, _) do module = Module.concat([Nola.Plugins, Macro.camelize(plugin)]) with true <- Code.ensure_loaded?(module), pid when is_pid(pid) <- GenServer.whereis(module), :ok <- GenServer.stop(pid) do IRC.Plugin.switch(module, false) m.replyfun.("stopped: #{inspect(pid)}") else false -> m.replyfun.("not loaded") nil -> m.replyfun.("not active") end {:noreply, nil} end def handle_info({:irc, :trigger, "liquidrender", m = %{trigger: %{args: args}}}, _) do template = Enum.join(args, " ") m.replyfun.(Tmpl.render(template, m)) {:noreply, nil} end def handle_info({:irc, :trigger, "help", m = %{trigger: %{type: :bang}}}, _) do url = NolaWeb.Router.Helpers.irc_url(NolaWeb.Endpoint, :index, m.network, NolaWeb.format_chan(m.channel)) m.replyfun.("-> #{url}") {:noreply, nil} end def handle_info({:irc, :trigger, "version", message = %{trigger: %{type: :bang}}}, _) do {:ok, vsn} = :application.get_key(:nola, :vsn) ver = List.to_string(vsn) url = NolaWeb.Router.Helpers.irc_url(NolaWeb.Endpoint, :index) elixir_ver = Application.started_applications() |> List.keyfind(:elixir, 0) |> elem(2) |> to_string() otp_ver = :erlang.system_info(:system_version) |> to_string() |> String.trim() system = :erlang.system_info(:system_architecture) |> to_string() brand = Nola.brand(:name) owner = "#{Nola.brand(:owner)} <#{Nola.brand(:owner_email)}>" message.replyfun.([ <<"🤖 I am a robot running", 2, "#{brand}, version #{ver}", 2, " — source: #{Nola.source_url()}">>, "🦾 Elixir #{elixir_ver} #{otp_ver} on #{system}", "👷‍♀️ Owner: h#{owner}", "🌍 Web interface: #{url}" ]) {:noreply, nil} end def handle_info(msg, _) do {:noreply, nil} end end diff --git a/lib/plugins/boursorama.ex b/lib/plugins/boursorama.ex index f872584..77977a5 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(IRC.PubSub, "trigger:cac40", regopts) - {:ok, _} = Registry.register(IRC.PubSub, "trigger:caca40", regopts) + {: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 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/buffer.ex b/lib/plugins/buffer.ex index 67aea35..42a435e 100644 --- a/lib/plugins/buffer.ex +++ b/lib/plugins/buffer.ex @@ -1,44 +1,44 @@ defmodule Nola.Plugins.Buffer do @table __MODULE__.ETS def irc_doc, do: nil def table(), do: @table def select_buffer(network, channel, limit \\ 50) do import Ex2ms spec = fun do {{n, c, _}, m} when n == ^network and (c == ^channel or is_nil(c)) -> m end :ets.select(@table, spec, limit) end def start_link() do GenServer.start_link(__MODULE__, [], name: __MODULE__) end def init(_) do for e <- ~w(messages triggers events outputs) do - {:ok, _} = Registry.register(IRC.PubSub, e, plugin: __MODULE__) + {:ok, _} = Registry.register(Nola.PubSub, e, plugin: __MODULE__) end {:ok, :ets.new(@table, [:named_table, :ordered_set, :protected])} end def handle_info({:irc, :trigger, _, message}, ets), do: handle_message(message, ets) def handle_info({:irc, :text, message}, ets), do: handle_message(message, ets) def handle_info({:irc, :event, event}, ets), do: handle_message(event, ets) defp handle_message(message = %{network: network}, ets) do key = {network, Map.get(message, :channel), ts(message.at)} :ets.insert(ets, {key, message}) {:noreply, ets} end defp ts(nil), do: ts(NaiveDateTime.utc_now()) defp ts(naive = %NaiveDateTime{}) do ts = naive |> DateTime.from_naive!("Etc/UTC") |> DateTime.to_unix() -ts end end diff --git a/lib/plugins/calc.ex b/lib/plugins/calc.ex index 91fbbae..e58e1b1 100644 --- a/lib/plugins/calc.ex +++ b/lib/plugins/calc.ex @@ -1,37 +1,37 @@ defmodule Nola.Plugins.Calc do @moduledoc """ # calc * **!calc ``**: évalue l'expression mathématique ``. """ def irc_doc, do: @moduledoc def start_link() do GenServer.start_link(__MODULE__, [], name: __MODULE__) end def init(_) do - {:ok, _} = Registry.register(IRC.PubSub, "trigger:calc", [plugin: __MODULE__]) + {: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 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 624bae7..31483aa 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(IRC.PubSub, "trigger:coronavirus", [plugin: __MODULE__]) + {: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 [ [], ["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 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 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 03968c8..067f468 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(IRC.PubSub, "messages", [plugin: __MODULE__]) - {:ok, _} = Registry.register(IRC.PubSub, "triggers", [plugin: __MODULE__]) + {: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 {:noreply, correction(m, state)} end def handle_info({:irc, :text, m = %IRC.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/dice.ex b/lib/plugins/dice.ex index 2c1515b..dcf7d0b 100644 --- a/lib/plugins/dice.ex +++ b/lib/plugins/dice.ex @@ -1,66 +1,66 @@ defmodule Nola.Plugins.Dice do require Logger @moduledoc """ # dice * **!dice `[1 | lancés]` `[6 | faces]`**: lance une ou plusieurs fois un dé de 6 ou autre faces """ @default_faces 6 @default_rolls 1 @max_rolls 50 def short_irc_doc, do: "!dice (jeter un dé)" defstruct client: nil, dets: nil def irc_doc, do: @moduledoc def start_link() do GenServer.start_link(__MODULE__, [], name: __MODULE__) end def init([]) do - {:ok, _} = Registry.register(IRC.PubSub, "trigger:dice", [plugin: __MODULE__]) + {:ok, _} = Registry.register(Nola.PubSub, "trigger:dice", [plugin: __MODULE__]) {:ok, %__MODULE__{}} end def handle_info({:irc, :trigger, _, message = %{trigger: %{type: :bang, args: args}}}, state) do to_integer = fn(string, default) -> case Integer.parse(string) do {int, _} -> int _ -> default end end {rolls, faces} = case args do [] -> {@default_rolls, @default_faces} [faces, rolls] -> {to_integer.(rolls, @default_rolls), to_integer.(faces, @default_faces)} [rolls] -> {to_integer.(rolls, @default_rolls), @default_faces} end roll(state, message, faces, rolls) {:noreply, state} end def handle_info(info, state) do {:noreply, state} end defp roll(state, message, faces, 1) when faces > 0 do random = :crypto.rand_uniform(1, faces+1) message.replyfun.("#{message.sender.nick} dice: #{random}") end defp roll(state, message, faces, rolls) when faces > 0 and rolls > 0 and rolls <= @max_rolls do {results, acc} = Enum.map_reduce(Range.new(1, rolls), 0, fn(i, acc) -> random = :crypto.rand_uniform(1, faces+1) {random, acc + random} end) results = Enum.join(results, "; ") message.replyfun.("#{message.sender.nick} dice: [#{acc}] #{results}") end defp roll(_, _, _, _, _), do: nil end diff --git a/lib/plugins/finance.ex b/lib/plugins/finance.ex index d6f890a..3ecc2fb 100644 --- a/lib/plugins/finance.ex +++ b/lib/plugins/finance.ex @@ -1,190 +1,190 @@ defmodule Nola.Plugins.Finance do require Logger @moduledoc """ # finance Données de [alphavantage.co](https://alphavantage.co). ## forex / monnaies / crypto-monnaies * **`!forex [MONNAIE2]`**: taux de change entre deux monnaies. * **`!forex `**: converti `montant` entre deux monnaies * **`?currency `**: recherche une monnaie Utiliser le symbole des monnaies (EUR, USD, ...). ## bourses * **`!stocks `** * **`?stocks `** cherche un symbole Pour les symboles non-US, ajouter le suffixe (RNO Paris: RNO.PAR). """ @currency_list "http://www.alphavantage.co/physical_currency_list/" @crypto_list "http://www.alphavantage.co/digital_currency_list/" HTTPoison.start() load_currency = fn(url) -> resp = HTTPoison.get!(url) resp.body |> String.strip() |> String.split("\n") |> Enum.drop(1) |> Enum.map(fn(line) -> [symbol, name] = line |> String.strip() |> String.split(",", parts: 2) {symbol, name} end) |> Enum.into(Map.new) end fiat = load_currency.(@currency_list) crypto = load_currency.(@crypto_list) @currencies Map.merge(fiat, crypto) 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(IRC.PubSub, "trigger:forex", regopts) - {:ok, _} = Registry.register(IRC.PubSub, "trigger:currency", regopts) - {:ok, _} = Registry.register(IRC.PubSub, "trigger:stocks", regopts) + {:ok, _} = Registry.register(Nola.PubSub, "trigger:forex", regopts) + {:ok, _} = Registry.register(Nola.PubSub, "trigger:currency", regopts) + {:ok, _} = Registry.register(Nola.PubSub, "trigger:stocks", regopts) {:ok, nil} end def handle_info({:irc, :trigger, "stocks", message = %{trigger: %{type: :query, args: args = search}}}, state) do search = Enum.join(search, "%20") url = "https://www.alphavantage.co/query?function=SYMBOL_SEARCH&keywords=#{search}&apikey=#{api_key()}" case HTTPoison.get(url) do {:ok, %HTTPoison.Response{status_code: 200, body: data}} -> data = Poison.decode!(data) if error = Map.get(data, "Error Message") do Logger.error("AlphaVantage API invalid request #{url} - #{inspect error}") message.replyfun.("stocks: requête invalide") else items = for item <- Map.get(data, "bestMatches") do symbol = Map.get(item, "1. symbol") name = Map.get(item, "2. name") type = Map.get(item, "3. type") region = Map.get(item, "4. region") currency = Map.get(item, "8. currency") "#{symbol}: #{name} (#{region}; #{currency}; #{type})" end |> Enum.join(", ") items = if items == "" do "no results!" else items end message.replyfun.(items) end {:ok, resp = %HTTPoison.Response{status_code: code}} -> Logger.error "AlphaVantage API error: #{code} #{url} - #{inspect resp}" message.replyfun.("forex: erreur (api #{code})") {:error, %HTTPoison.Error{reason: error}} -> Logger.error "AlphaVantage HTTP error: #{inspect error}" message.replyfun.("forex: erreur (http #{inspect error})") end {:noreply, state} end def handle_info({:irc, :trigger, "stocks", message = %{trigger: %{type: :bang, args: args = [symbol]}}}, state) do url = "https://www.alphavantage.co/query?function=GLOBAL_QUOTE&symbol=#{symbol}&apikey=#{api_key()}" case HTTPoison.get(url) do {:ok, %HTTPoison.Response{status_code: 200, body: data}} -> data = Poison.decode!(data) if error = Map.get(data, "Error Message") do Logger.error("AlphaVantage API invalid request #{url} - #{inspect error}") message.replyfun.("stocks: requête invalide") else data = Map.get(data, "Global Quote") open = Map.get(data, "02. open") high = Map.get(data, "03. high") low = Map.get(data, "04. low") price = Map.get(data, "05. price") volume = Map.get(data, "06. volume") prev_close = Map.get(data, "08. previous close") change = Map.get(data, "09. change") change_pct = Map.get(data, "10. change percent") msg = "#{symbol}: #{price} #{change} [#{change_pct}] (high: #{high}, low: #{low}, open: #{open}, prev close: #{prev_close}) (volume: #{volume})" message.replyfun.(msg) end {:ok, resp = %HTTPoison.Response{status_code: code}} -> Logger.error "AlphaVantage API error: #{code} #{url} - #{inspect resp}" message.replyfun.("stocks: erreur (api #{code})") {:error, %HTTPoison.Error{reason: error}} -> Logger.error "AlphaVantage HTTP error: #{inspect error}" message.replyfun.("stocks: erreur (http #{inspect error})") end {:noreply, state} end def handle_info({:irc, :trigger, "forex", message = %{trigger: %{type: :bang, args: args = [_ | _]}}}, state) do {amount, from, to} = case args do [amount, from, to] -> {amount, _} = Float.parse(amount) {amount, from, to} [from, to] -> {1, from, to} [from] -> {1, from, "EUR"} end url = "https://www.alphavantage.co/query?function=CURRENCY_EXCHANGE_RATE&from_currency=#{from}&to_currency=#{to}&apikey=#{api_key()}" case HTTPoison.get(url) do {:ok, %HTTPoison.Response{status_code: 200, body: data}} -> data = Poison.decode!(data) if error = Map.get(data, "Error Message") do Logger.error("AlphaVantage API invalid request #{url} - #{inspect error}") message.replyfun.("forex: requête invalide") else data = Map.get(data, "Realtime Currency Exchange Rate") from_name = Map.get(data, "2. From_Currency Name") to_name = Map.get(data, "4. To_Currency Name") rate = Map.get(data, "5. Exchange Rate") {rate, _} = Float.parse(rate) value = amount*rate message.replyfun.("#{amount} #{from} (#{from_name}) -> #{value} #{to} (#{to_name}) (#{rate})") end {:ok, resp = %HTTPoison.Response{status_code: code}} -> Logger.error "AlphaVantage API error: #{code} #{url} - #{inspect resp}" message.replyfun.("forex: erreur (api #{code})") {:error, %HTTPoison.Error{reason: error}} -> Logger.error "AlphaVantage HTTP error: #{inspect error}" message.replyfun.("forex: erreur (http #{inspect error})") end {:noreply, state} end def handle_info({:irc, :trigger, "currency", message = %{trigger: %{type: :query, args: args = search}}}, state) do search = Enum.join(search, " ") results = Enum.filter(@currencies, fn({symbol, name}) -> String.contains?(String.downcase(name), String.downcase(search)) || String.contains?(String.downcase(symbol), String.downcase(search)) end) |> Enum.map(fn({symbol, name}) -> "#{symbol}: #{name}" end) |> Enum.join(", ") if results == "" do message.replyfun.("no results!") else message.replyfun.(results) end {:noreply, state} end defp api_key() do Application.get_env(:nola, :alphavantage, []) |> Keyword.get(:api_key, "demo") end end diff --git a/lib/plugins/gpt.ex b/lib/plugins/gpt.ex index 3d2a66d..1171d19 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]` ``** run a prompt * **+gpt `[short ref|run id]` ``** continue a prompt * **?gpt offensive ``** 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(IRC.PubSub, "trigger:#{@trigger}", regopts) + {: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 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 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 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 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 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 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/kick_roulette.ex b/lib/plugins/kick_roulette.ex index f6d2ba2..b25b46d 100644 --- a/lib/plugins/kick_roulette.ex +++ b/lib/plugins/kick_roulette.ex @@ -1,32 +1,32 @@ defmodule Nola.Plugins.KickRoulette do @moduledoc """ # kick roulette * **!kick**, tentez votre chance… """ def irc_doc, do: @moduledoc def start_link() do GenServer.start_link(__MODULE__, [], name: __MODULE__) end def init([]) do - {:ok, _} = Registry.register(IRC.PubSub, "trigger:kick", [plugin: __MODULE__]) + {:ok, _} = Registry.register(Nola.PubSub, "trigger:kick", [plugin: __MODULE__]) {:ok, nil} end def handle_info({:irc, :trigger, "kick", message = %{trigger: %{type: :bang, args: []}}}, _) do if 5 == :crypto.rand_uniform(1, 6) do spawn(fn() -> :timer.sleep(:crypto.rand_uniform(200, 10_000)) message.replyfun.({:kick, message.sender.nick, "perdu"}) end) end {:noreply, nil} end def handle_info(msg, _) do {:noreply, nil} end end diff --git a/lib/plugins/last_fm.ex b/lib/plugins/last_fm.ex index 1a9b7dd..ae4b1ee 100644 --- a/lib/plugins/last_fm.ex +++ b/lib/plugins/last_fm.ex @@ -1,187 +1,187 @@ defmodule Nola.Plugins.LastFm do require Logger @moduledoc """ # last.fm * **!lastfm|np `[nick|username]`** * **.lastfm|np** * **+lastfm, -lastfm `; ?lastfm`** Configurer un nom d'utilisateur last.fm """ @single_trigger ~w(lastfm np) @pubsub_topics ~w(trigger:lastfm trigger:np) defstruct dets: nil def irc_doc, do: @moduledoc def start_link() do GenServer.start_link(__MODULE__, [], name: __MODULE__) end def init([]) do regopts = [type: __MODULE__] - for t <- @pubsub_topics, do: {:ok, _} = Registry.register(IRC.PubSub, t, type: __MODULE__) + for t <- @pubsub_topics, do: {:ok, _} = Registry.register(Nola.PubSub, t, type: __MODULE__) dets_filename = (Nola.data_path() <> "/" <> "lastfm.dets") |> String.to_charlist {:ok, dets} = :dets.open_file(dets_filename, []) {:ok, %__MODULE__{dets: dets}} end def handle_info({:irc, :trigger, "lastfm", message = %{trigger: %{type: :plus, args: [username]}}}, state) do username = String.strip(username) :ok = :dets.insert(state.dets, {message.account.id, username}) message.replyfun.("#{message.sender.nick}: nom d'utilisateur last.fm configuré: \"#{username}\".") {:noreply, state} end def handle_info({:irc, :trigger, "lastfm", message = %{trigger: %{type: :minus, args: []}}}, state) do text = case :dets.lookup(state.dets, message.account.id) do [{_nick, _username}] -> :dets.delete(state.dets, message.account.id) message.replyfun.("#{message.sender.nick}: nom d'utilisateur last.fm enlevé.") _ -> nil end {:noreply, state} end def handle_info({:irc, :trigger, "lastfm", message = %{trigger: %{type: :query, args: []}}}, state) do text = case :dets.lookup(state.dets, message.account.id) do [{_nick, username}] -> message.replyfun.("#{message.sender.nick}: #{username}.") _ -> nil end {:noreply, state} end def handle_info({:irc, :trigger, _, message = %{trigger: %{type: :bang, args: []}}}, state) do irc_now_playing(message.account.id, message, state) {:noreply, state} end def handle_info({:irc, :trigger, _, message = %{trigger: %{type: :bang, args: [nick_or_user]}}}, state) do irc_now_playing(nick_or_user, message, state) {:noreply, state} end def handle_info({:irc, :trigger, _, message = %{trigger: %{type: :dot}}}, state) do members = IRC.Membership.members(message.network, message.channel) foldfun = fn({nick, user}, acc) -> [{nick,user}|acc] end usernames = :dets.foldl(foldfun, [], state.dets) |> Enum.uniq() |> Enum.filter(fn({acct,_}) -> Enum.member?(members, acct) end) |> Enum.map(fn({_, u}) -> u end) for u <- usernames, do: irc_now_playing(u, message, state) {:noreply, state} end def handle_info(info, state) do {:noreply, state} end def terminate(_reason, state) do if state.dets do :dets.sync(state.dets) :dets.close(state.dets) end :ok end defp irc_now_playing(nick_or_user, message, state) do nick_or_user = String.strip(nick_or_user) id_or_user = if account = IRC.Account.get(nick_or_user) || IRC.Account.find_always_by_nick(message.network, message.channel, nick_or_user) do account.id else nick_or_user end username = case :dets.lookup(state.dets, id_or_user) do [{_, username}] -> username _ -> id_or_user end case now_playing(username) do {:error, text} when is_binary(text) -> message.replyfun.(text) {:ok, map} when is_map(map) -> track = fetch_track(username, map) text = format_now_playing(map, track) user = if account = IRC.Account.get(id_or_user) do user = IRC.UserTrack.find_by_account(message.network, account) if(user, do: user.nick, else: account.name) else username end if user && text do message.replyfun.("#{user} #{text}") else message.replyfun.("#{username}: pas de résultat") end other -> message.replyfun.("erreur :(") end end defp now_playing(user) do api = Application.get_env(:nola, :lastfm)[:api_key] url = "http://ws.audioscrobbler.com/2.0/?method=user.getrecenttracks&format=json&limit=1&extended=1" <> "&api_key=" <> api <> "&user="<> user case HTTPoison.get(url) do {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> Jason.decode(body) {:ok, %HTTPoison.Response{status_code: 404}} -> {:error, "last.fm: utilisateur \"#{user}\" inexistant"} {:ok, %HTTPoison.Response{status_code: code}} -> {:error, "last.fm: erreur #{to_string(code)}"} error -> Logger.error "Lastfm http error: #{inspect error}" :error end end defp fetch_track(user, %{"recenttracks" => %{"track" => [ t = %{"name" => name, "artist" => %{"name" => artist}} | _]}}) do api = Application.get_env(:nola, :lastfm)[:api_key] url = "http://ws.audioscrobbler.com/2.0/?method=track.getInfo&format=json" <> "&api_key=" <> api <> "&username="<> user <> "&artist="<>URI.encode(artist)<>"&track="<>URI.encode(name) case HTTPoison.get(url) do {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> case Jason.decode(body) do {:ok, body} -> body["track"] || %{} _ -> %{} end error -> Logger.error "Lastfm http error: #{inspect error}" :error end end defp format_now_playing(%{"recenttracks" => %{"track" => [track = %{"@attr" => %{"nowplaying" => "true"}} | _]}}, et) do format_track(true, track, et) end defp format_now_playing(%{"recenttracks" => %{"track" => [track | _]}}, et) do format_track(false, track, et) end defp format_now_playing(%{"error" => err, "message" => message}, _) do "last.fm error #{err}: #{message}" end defp format_now_playing(miss) do nil end defp format_track(np, track, extended) do artist = track["artist"]["name"] album = if track["album"]["#text"], do: " (" <> track["album"]["#text"] <> ")", else: "" name = track["name"] <> album action = if np, do: "écoute ", else: "a écouté" love = if track["loved"] != "0", do: "❤️" count = if x = extended["userplaycount"], do: "x#{x} #{love}" tags = (get_in(extended, ["toptags", "tag"]) || []) |> Enum.map(fn(tag) -> tag["name"] end) |> Enum.filter(& &1) |> Enum.join(", ") [action, artist, name, count, tags, track["url"]] |> Enum.filter(& &1) |> Enum.map(&String.trim(&1)) |> Enum.join(" - ") end end diff --git a/lib/plugins/link.ex b/lib/plugins/link.ex index 381bdab..4c4261f 100644 --- a/lib/plugins/link.ex +++ b/lib/plugins/link.ex @@ -1,271 +1,271 @@ defmodule Nola.Plugins.Link do @moduledoc """ # Link Previewer An extensible link previewer for IRC. To extend the supported sites, create a new handler implementing the callbacks. See `link/` directory. The first in list handler that returns true to the `match/2` callback will be used, and if the handler returns `:error` or crashes, will fallback to the default preview. Unsupported websites will use the default link preview method, which is for html document the title, otherwise it'll use the mimetype and size. ## Configuration: ``` config :nola, Nola.Plugins.Link, handlers: [ Nola.Plugins.Link.Youtube: [ invidious: true ], Nola.Plugins.Link.Twitter: [], Nola.Plugins.Link.Imgur: [], ] ``` """ @ircdoc """ # Link preview Previews links (just post a link!). Announces real URL after redirections and provides extended support for YouTube, Twitter and Imgur. """ def short_irc_doc, do: false def irc_doc, do: @ircdoc require Logger def start_link() do GenServer.start_link(__MODULE__, [], name: __MODULE__) end @callback match(uri :: URI.t, options :: Keyword.t) :: {true, params :: Map.t} | false @callback expand(uri :: URI.t, params :: Map.t, options :: Keyword.t) :: {:ok, lines :: [] | String.t} | :error @callback post_match(uri :: URI.t, content_type :: binary, headers :: [], opts :: Keyword.t) :: {:body | :file, params :: Map.t} | false @callback post_expand(uri :: URI.t, body :: binary() | Path.t, params :: Map.t, options :: Keyword.t) :: {:ok, lines :: [] | String.t} | :error @optional_callbacks [expand: 3, post_expand: 4] defstruct [:client] def init([]) do - {:ok, _} = Registry.register(IRC.PubSub, "messages", [plugin: __MODULE__]) - #{:ok, _} = Registry.register(IRC.PubSub, "messages:telegram", [plugin: __MODULE__]) + {:ok, _} = Registry.register(Nola.PubSub, "messages", [plugin: __MODULE__]) + #{:ok, _} = Registry.register(Nola.PubSub, "messages:telegram", [plugin: __MODULE__]) Logger.info("Link handler started") {:ok, %__MODULE__{}} end def handle_info({:irc, :text, 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 spawn(fn() -> :timer.kill_after(:timer.seconds(30)) case expand_link([uri]) do {:ok, uris, text} -> text = case uris do [uri] -> text [luri | _] -> if luri.host == uri.host && luri.path == luri.path do text else ["-> #{URI.to_string(luri)}", text] end end if is_list(text) do for line <- text, do: message.replyfun.(line) else message.replyfun.(text) end _ -> nil end end) end end end) {:noreply, state} end def handle_info(msg, state) do {:noreply, state} end def terminate(_reason, state) do :ok end # 1. Match the first valid handler # 2. Try to run the handler # 3. If :error or crash, default link. # If :skip, nothing # 4. ? # Over five redirections: cancel. def expand_link(acc = [_, _, _, _, _ | _]) do {:ok, acc, "link redirects more than five times"} end def expand_link(acc=[uri | _]) do Logger.debug("link: expanding: #{inspect uri}") handlers = Keyword.get(Application.get_env(:nola, __MODULE__, [handlers: []]), :handlers) handler = Enum.reduce_while(handlers, nil, fn({module, opts}, acc) -> Logger.debug("link: attempt expanding: #{inspect module} for #{inspect uri}") module = Module.concat([module]) case module.match(uri, opts) do {true, params} -> {:halt, {module, params, opts}} false -> {:cont, acc} end end) run_expand(acc, handler) end def run_expand(acc, nil) do expand_default(acc) end def run_expand(acc=[uri|_], {module, params, opts}) do Logger.debug("link: expanding #{inspect uri} with #{inspect module}") case module.expand(uri, params, opts) do {:ok, data} -> {:ok, acc, data} :error -> expand_default(acc) :skip -> nil end rescue e -> Logger.error("link: rescued #{inspect uri} with #{inspect module}: #{inspect e}") Logger.error(Exception.format(:error, e, __STACKTRACE__)) expand_default(acc) catch e, b -> Logger.error("link: catched #{inspect uri} with #{inspect module}: #{inspect {e, b}}") expand_default(acc) end defp get(url, headers \\ [], options \\ []) do get_req(url, :hackney.get(url, headers, <<>>, options)) end defp get_req(_, {:error, reason}) do {:error, reason} end defp get_req(url, {:ok, 200, headers, client}) do headers = Enum.reduce(headers, %{}, fn({key, value}, acc) -> Map.put(acc, String.downcase(key), value) end) content_type = Map.get(headers, "content-type", "application/octect-stream") length = Map.get(headers, "content-length", "0") {length, _} = Integer.parse(length) handlers = Keyword.get(Application.get_env(:nola, __MODULE__, [handlers: []]), :handlers) handler = Enum.reduce_while(handlers, false, fn({module, opts}, acc) -> module = Module.concat([module]) try do case module.post_match(url, content_type, headers, opts) do {mode, params} when mode in [:body, :file] -> {:halt, {module, params, opts, mode}} false -> {:cont, acc} end rescue e -> Logger.error(inspect(e)) {:cont, false} catch e, b -> Logger.error(inspect({b})) {:cont, false} end end) cond do handler != false and length <= 30_000_000 -> case get_body(url, 30_000_000, client, handler, <<>>) do {:ok, _} = ok -> ok :error -> {:ok, "file: #{content_type}, size: #{human_size(length)}"} end #String.starts_with?(content_type, "text/html") && length <= 30_000_000 -> # get_body(url, 30_000_000, client, <<>>) true -> :hackney.close(client) {:ok, "file: #{content_type}, size: #{human_size(length)}"} end end defp get_req(_, {:ok, redirect, headers, client}) when redirect in 300..399 do headers = Enum.reduce(headers, %{}, fn({key, value}, acc) -> Map.put(acc, String.downcase(key), value) end) location = Map.get(headers, "location") :hackney.close(client) {:redirect, location} end defp get_req(_, {:ok, status, headers, client}) do :hackney.close(client) {:error, status, headers} end defp get_body(url, len, client, {handler, params, opts, mode} = h, acc) when len >= byte_size(acc) do case :hackney.stream_body(client) do {:ok, data} -> get_body(url, len, client, h, << acc::binary, data::binary >>) :done -> body = case mode do :body -> acc :file -> {:ok, tmpfile} = Plug.Upload.random_file("linkplugin") File.write!(tmpfile, acc) tmpfile end handler.post_expand(url, body, params, opts) {:error, reason} -> {:ok, "failed to fetch body: #{inspect reason}"} end end defp get_body(_, len, client, h, _acc) do :hackney.close(client) IO.inspect(h) {:ok, "Error: file over 30"} end def expand_default(acc = [uri = %URI{scheme: scheme} | _]) when scheme in ["http", "https"] do Logger.debug("link: expanding #{uri} with default") headers = [{"user-agent", "DmzBot (like TwitterBot)"}] options = [follow_redirect: false, max_body_length: 30_000_000] case get(URI.to_string(uri), headers, options) do {:ok, text} -> {:ok, acc, text} {:redirect, link} -> new_uri = URI.parse(link) #new_uri = %URI{new_uri | scheme: scheme, authority: uri.authority, host: uri.host, port: uri.port} expand_link([new_uri | acc]) {:error, status, _headers} -> text = Plug.Conn.Status.reason_phrase(status) {:ok, acc, "Error: HTTP #{text} (#{status})"} {:error, {:tls_alert, {:handshake_failure, err}}} -> {:ok, acc, "TLS Error: #{to_string(err)}"} {:error, reason} -> {:ok, acc, "Error: #{to_string(reason)}"} end end # Unsupported scheme, came from a redirect. def expand_default(acc = [uri | _]) do {:ok, [uri], "-> #{URI.to_string(uri)}"} end defp human_size(bytes) do bytes |> FileSize.new(:b) |> FileSize.scale() |> FileSize.format() end end diff --git a/lib/plugins/logger.ex b/lib/plugins/logger.ex index 69cac66..77bee8b 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(IRC.PubSub, "triggers", regopts) - {:ok, _} = Registry.register(IRC.PubSub, "messages", regopts) - {:ok, _} = Registry.register(IRC.PubSub, "messages:telegram", regopts) - {:ok, _} = Registry.register(IRC.PubSub, "irc:outputs", regopts) - {:ok, _} = Registry.register(IRC.PubSub, "messages:private", regopts) + {: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 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 f0ccfd2..1f1c1e1 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 ``** crée un lien outline pour ``. * **+outline ``** active outline pour ``. * **-outline ``** désactive outline pour ``. """ 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(IRC.PubSub, "trigger:outline", regopts) - {:ok, _} = Registry.register(IRC.PubSub, "messages", regopts) + {: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 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 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 line = "-> #{outline(url)}" message.replyfun.(line) end def handle_info({:irc, :text, message = %IRC.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 80c5af0..f7738ad 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(IRC.PubSub, "account", regopts) - {:ok, _} = Registry.register(IRC.PubSub, "messages", regopts) - {:ok, _} = Registry.register(IRC.PubSub, "triggers", regopts) + {: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 IRC.Account.get(nick) do nick else if acct = IRC.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 !IRC.Account.get(nick) do if acct = IRC.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 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 = IRC.Account.get(account_id) user = IRC.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 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 = IRC.Account.get(account_id) user = IRC.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 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 state = handle_preums(m, state) {:noreply, state} end # Message fallback def handle_info({:irc, :text, m = %IRC.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 state end defp handle_preums(m = %IRC.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 3331625..59b2dea 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(IRC.PubSub, "trigger:#{420*coeff}", [plugin: __MODULE__]) + {:ok, _} = Registry.register(Nola.PubSub, "trigger:#{420*coeff}", [plugin: __MODULE__]) end - {:ok, _} = Registry.register(IRC.PubSub, "account", [plugin: __MODULE__]) + {: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 {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 account = IRC.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 2096de8..a2a1c7b 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(IRC.PubSub, "trigger:radiofrance", regopts) - {:ok, _} = Registry.register(IRC.PubSub, "trigger:rf", regopts) + {:ok, _} = Registry.register(Nola.PubSub, "trigger:radiofrance", regopts) + {:ok, _} = Registry.register(Nola.PubSub, "trigger:rf", regopts) for s <- @shortcuts do - {:ok, _} = Registry.register(IRC.PubSub, "trigger:#{s}", regopts) + {: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 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 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 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 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/say.ex b/lib/plugins/say.ex index 91ffacc..e036bd4 100644 --- a/lib/plugins/say.ex +++ b/lib/plugins/say.ex @@ -1,73 +1,73 @@ defmodule Nola.Plugins.Say do def irc_doc do """ # say Say something... * **!say `` ``** say something on `channel` * **!asay `` ``** same but anonymously You must be a member of the channel. """ end def start_link() do GenServer.start_link(__MODULE__, [], name: __MODULE__) end def init([]) do regopts = [type: __MODULE__] - {:ok, _} = Registry.register(IRC.PubSub, "trigger:say", regopts) - {:ok, _} = Registry.register(IRC.PubSub, "trigger:asay", regopts) - {:ok, _} = Registry.register(IRC.PubSub, "messages:private", regopts) + {:ok, _} = Registry.register(Nola.PubSub, "trigger:say", regopts) + {:ok, _} = Registry.register(Nola.PubSub, "trigger:asay", regopts) + {:ok, _} = Registry.register(Nola.PubSub, "messages:private", regopts) {:ok, nil} end def handle_info({:irc, :trigger, "say", m = %{trigger: %{type: :bang, args: [target | text]}}}, state) do text = Enum.join(text, " ") say_for(m.account, target, text, true) {:noreply, state} end def handle_info({:irc, :trigger, "asay", m = %{trigger: %{type: :bang, args: [target | text]}}}, state) do text = Enum.join(text, " ") say_for(m.account, target, text, false) {:noreply, state} end def handle_info({:irc, :text, m = %{text: "say "<>rest}}, state) do case String.split(rest, " ", parts: 2) do [target, text] -> say_for(m.account, target, text, true) _ -> nil end {:noreply, state} end def handle_info({:irc, :text, m = %{text: "asay "<>rest}}, state) do case String.split(rest, " ", parts: 2) do [target, text] -> say_for(m.account, target, text, false) _ -> nil end {:noreply, state} end def handle_info(_, state) do {:noreply, state} end defp say_for(account, target, text, with_nick?) do for {net, chan} <- IRC.Membership.of_account(account) do chan2 = String.replace(chan, "#", "") if (target == "#{net}/#{chan}" || target == "#{net}/#{chan2}" || target == chan || target == chan2) do if with_nick? do IRC.send_message_as(account, net, chan, text) else IRC.Connection.broadcast_message(net, chan, text) end end end end end diff --git a/lib/plugins/script.ex b/lib/plugins/script.ex index d383abb..c8d00a9 100644 --- a/lib/plugins/script.ex +++ b/lib/plugins/script.ex @@ -1,42 +1,42 @@ defmodule Nola.Plugins.Script do require Logger @moduledoc """ Allows to run outside scripts. Scripts are expected to be long running and receive/send data as JSON over stdin/stdout. """ @ircdoc """ # script Allows to run an outside script. * **+script `` `[command]`** défini/lance un script * **-script ``** arrête un script * **-script del ``** supprime un script """ def irc_doc, do: @ircdoc def start_link() do GenServer.start_link(__MODULE__, [], name: __MODULE__) end def init([]) do - {:ok, _} = Registry.register(IRC.PubSub, "trigger:script", [plugin: __MODULE__]) + {:ok, _} = Registry.register(Nola.PubSub, "trigger:script", [plugin: __MODULE__]) dets_filename = (Nola.data_path() <> "/" <> "scripts.dets") |> String.to_charlist {:ok, dets} = :dets.open_file(dets_filename, []) {:ok, %{dets: dets}} end def handle_info({:irc, :trigger, "script", m = %{trigger: %{type: :plus, args: [name | args]}}}, state) do end def handle_info({:irc, :trigger, "script", m = %{trigger: %{type: :minus, args: args}}}, state) do case args do ["del", name] -> :ok #prout [name] -> :ok#stop end end end diff --git a/lib/plugins/seen.ex b/lib/plugins/seen.ex index 65af0c1..cdebd59 100644 --- a/lib/plugins/seen.ex +++ b/lib/plugins/seen.ex @@ -1,59 +1,59 @@ defmodule Nola.Plugins.Seen do @moduledoc """ # seen * **!seen ``** """ 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(IRC.PubSub, "triggers", regopts) - {:ok, _} = Registry.register(IRC.PubSub, "messages", regopts) + {: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 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 :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 ad16f78..bc666b2 100644 --- a/lib/plugins/sms.ex +++ b/lib/plugins/sms.ex @@ -1,165 +1,165 @@ defmodule Nola.Plugins.Sms do @moduledoc """ ## sms * **!sms `` ``** 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 = IRC.Account.find_meta_account("sms-validation-code", String.downcase(key)) if account do net = IRC.Account.get_meta(account, "sms-validation-target") IRC.Account.put_meta(account, "sms-number", from) IRC.Account.delete_meta(account, "sms-validation-code") IRC.Account.delete_meta(account, "sms-validation-number") IRC.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 = IRC.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{ 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(IRC.PubSub, "trigger:sms", [plugin: __MODULE__]) + {: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 with \ {:tree, false} <- {:tree, m.sender.nick == "Tree"}, {_, %IRC.Account{} = account} <- {:account, IRC.Account.find_always_by_nick(m.network, m.channel, nick)}, {_, number} when not is_nil(number) <- {:number, IRC.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 8978c82..34e9f94 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 `` ``**: 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(IRC.PubSub, "account", regopts) - {:ok, _} = Registry.register(IRC.PubSub, "trigger:tell", regopts) + {: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 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 = IRC.Account.get(from) user = IRC.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 = IRC.Account.find_always_by_nick(m.network, m.channel, nick_target) message = Enum.join(message, " ") with \ {:target, %IRC.Account{} = target} <- {:target, target}, {:same, false} <- {:same, target.id == m.account.id}, target_user = IRC.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/txt.ex b/lib/plugins/txt.ex index f6e71a3..f33aab0 100644 --- a/lib/plugins/txt.ex +++ b/lib/plugins/txt.ex @@ -1,556 +1,556 @@ defmodule Nola.Plugins.Txt do alias IRC.UserTrack require Logger @moduledoc """ # [txt]({{context_path}}/txt) * **.txt**: liste des fichiers et statistiques. Les fichiers avec une `*` sont vérrouillés. [Voir sur le web]({{context_path}}/txt). * **!txt**: lis aléatoirement une ligne dans tous les fichiers. * **!txt ``**: recherche une ligne dans tous les fichiers. * **~txt**: essaie de générer une phrase (markov). * **~txt ``**: essaie de générer une phrase commencant par ``. * **!`FICHIER`**: lis aléatoirement une ligne du fichier `FICHIER`. * **!`FICHIER` ``**: lis la ligne `` du fichier `FICHIER`. * **!`FICHIER` ``**: recherche une ligne contenant `` dans `FICHIER`. * **+txt `**: crée le fichier ``. * **+`FICHIER` ``**: ajoute une ligne `` dans le fichier `FICHIER`. * **-`FICHIER` ``**: supprime la ligne `` du fichier `FICHIER`. * **-txtrw, +txtrw**. op seulement. active/désactive le mode lecture seule. * **+txtlock ``, -txtlock ``**. op seulement. active/désactive le verrouillage d'un fichier. Insérez `\\\\` pour faire un saut de ligne. """ def short_irc_doc, do: "!txt https://sys.115ans.net/irc/txt " def irc_doc, do: @moduledoc def start_link() do GenServer.start_link(__MODULE__, [], name: __MODULE__) end defstruct triggers: %{}, rw: true, locks: nil, markov_handler: nil, markov: nil def random(file) do GenServer.call(__MODULE__, {:random, file}) end def reply_random(message, file) do if line = random(file) do line |> format_line(nil, message) |> message.replyfun.() line end end def init([]) do dets_locks_filename = (Nola.data_path() <> "/" <> "txtlocks.dets") |> String.to_charlist {:ok, locks} = :dets.open_file(dets_locks_filename, []) markov_handler = Keyword.get(Application.get_env(:nola, __MODULE__, []), :markov_handler, Nola.Plugins.Txt.Markov.Native) {:ok, markov} = markov_handler.start_link() - {:ok, _} = Registry.register(IRC.PubSub, "triggers", [plugin: __MODULE__]) + {:ok, _} = Registry.register(Nola.PubSub, "triggers", [plugin: __MODULE__]) {:ok, %__MODULE__{locks: locks, markov_handler: markov_handler, markov: markov, triggers: load()}} end def handle_info({:received, "!reload", _, chan}, state) do {:noreply, %__MODULE__{state | triggers: load()}} end # # ADMIN: RW/RO # def handle_info({:irc, :trigger, "txtrw", msg = %{channel: channel, trigger: %{type: :plus}}}, state = %{rw: false}) do if channel && UserTrack.operator?(msg.network, channel, msg.sender.nick) do msg.replyfun.("txt: écriture réactivée") {:noreply, %__MODULE__{state | rw: true}} else {:noreply, state} end end def handle_info({:irc, :trigger, "txtrw", msg = %{channel: channel, trigger: %{type: :minus}}}, state = %{rw: true}) do if channel && UserTrack.operator?(msg.network, channel, msg.sender.nick) do msg.replyfun.("txt: écriture désactivée") {:noreply, %__MODULE__{state | rw: false}} else {:noreply, state} end end # # ADMIN: LOCKS # def handle_info({:irc, :trigger, "txtlock", msg = %{trigger: %{type: :plus, args: [trigger]}}}, state) do with \ {trigger, _} <- clean_trigger(trigger), true <- UserTrack.operator?(msg.network, msg.channel, msg.sender.nick) do :dets.insert(state.locks, {trigger}) msg.replyfun.("txt: #{trigger} verrouillé") end {:noreply, state} end def handle_info({:irc, :trigger, "txtlock", msg = %{trigger: %{type: :minus, args: [trigger]}}}, state) do with \ {trigger, _} <- clean_trigger(trigger), true <- UserTrack.operator?(msg.network, msg.channel, msg.sender.nick), true <- :dets.member(state.locks, trigger) do :dets.delete(state.locks, trigger) msg.replyfun.("txt: #{trigger} déverrouillé") end {:noreply, state} end # # FILE LIST # def handle_info({:irc, :trigger, "txt", msg = %{trigger: %{type: :dot}}}, state) do map = Enum.map(state.triggers, fn({key, data}) -> ignore? = String.contains?(key, ".") locked? = case :dets.lookup(state.locks, key) do [{trigger}] -> "*" _ -> "" end unless ignore?, do: "#{key}: #{to_string(Enum.count(data))}#{locked?}" end) |> Enum.filter(& &1) total = Enum.reduce(state.triggers, 0, fn({_, data}, acc) -> acc + Enum.count(data) end) detail = Enum.join(map, ", ") total = ". total: #{Enum.count(state.triggers)} fichiers, #{to_string(total)} lignes. Détail: https://sys.115ans.net/irc/txt" ro = if !state.rw, do: " (lecture seule activée)", else: "" (detail<>total<>ro) |> msg.replyfun.() {:noreply, state} end # # GLOBAL: RANDOM # def handle_info({:irc, :trigger, "txt", msg = %{trigger: %{type: :bang, args: []}}}, state) do result = Enum.reduce(state.triggers, [], fn({trigger, data}, acc) -> Enum.reduce(data, acc, fn({l, _}, acc) -> [{trigger, l} | acc] end) end) |> Enum.shuffle() if !Enum.empty?(result) do {source, line} = Enum.random(result) msg.replyfun.(format_line(line, "#{source}: ", msg)) end {:noreply, state} end def handle_info({:irc, :trigger, "txt", msg = %{trigger: %{type: :bang, args: args}}}, state) do grep = Enum.join(args, " ") |> String.downcase |> :unicode.characters_to_nfd_binary() result = with_stateful_results(msg, {:bang,"txt",msg.network,msg.channel,grep}, fn() -> Enum.reduce(state.triggers, [], fn({trigger, data}, acc) -> if !String.contains?(trigger, ".") do Enum.reduce(data, acc, fn({l, _}, acc) -> [{trigger, l} | acc] end) else acc end end) |> Enum.filter(fn({_, line}) -> line |> String.downcase() |> :unicode.characters_to_nfd_binary() |> String.contains?(grep) end) |> Enum.shuffle() end) if result do {source, line} = result msg.replyfun.(["#{source}: " | line]) end {:noreply, state} end def with_stateful_results(msg, key, initfun) do me = self() scope = {msg.network, msg.channel || msg.sender.nick} key = {__MODULE__, me, scope, key} with_stateful_results(key, initfun) end def with_stateful_results(key, initfun) do pid = case :global.whereis_name(key) do :undefined -> start_stateful_results(key, initfun.()) pid -> pid end if pid, do: wait_stateful_results(key, initfun, pid) end def start_stateful_results(key, []) do nil end def start_stateful_results(key, list) do me = self() {pid, _} = spawn_monitor(fn() -> Process.monitor(me) stateful_results(me, list) end) :yes = :global.register_name(key, pid) pid end def wait_stateful_results(key, initfun, pid) do send(pid, :get) receive do {:stateful_results, line} -> line {:DOWN, _ref, :process, ^pid, reason} -> with_stateful_results(key, initfun) after 5000 -> nil end end defp stateful_results(owner, []) do send(owner, :empty) :ok end @stateful_results_expire :timer.minutes(30) defp stateful_results(owner, [line | rest] = acc) do receive do :get -> send(owner, {:stateful_results, line}) stateful_results(owner, rest) {:DOWN, _ref, :process, ^owner, _} -> :ok after @stateful_results_expire -> :ok end end # # GLOBAL: MARKOV # def handle_info({:irc, :trigger, "txt", msg = %{trigger: %{type: :tilde, args: []}}}, state) do case state.markov_handler.sentence(state.markov) do {:ok, line} -> msg.replyfun.(line) error -> Logger.error "Txt Markov error: "<>inspect error end {:noreply, state} end def handle_info({:irc, :trigger, "txt", msg = %{trigger: %{type: :tilde, args: complete}}}, state) do complete = Enum.join(complete, " ") case state.markov_handler.complete_sentence(complete, state.markov) do {:ok, line} -> msg.replyfun.(line) error -> Logger.error "Txt Markov error: "<>inspect error end {:noreply, state} end # # TXT CREATE # def handle_info({:irc, :trigger, "txt", msg = %{trigger: %{type: :plus, args: [trigger]}}}, state) do with \ {trigger, _} <- clean_trigger(trigger), true <- can_write?(state, msg, trigger), :ok <- create_file(trigger) do msg.replyfun.("#{trigger}.txt créé. Ajouter: `+#{trigger} …` ; Lire: `!#{trigger}`") {:noreply, %__MODULE__{state | triggers: load()}} else _ -> {:noreply, state} end end # # TXT: RANDOM # def handle_info({:irc, :trigger, trigger, m = %{trigger: %{type: :query, args: opts}}}, state) do {trigger, _} = clean_trigger(trigger) if Map.get(state.triggers, trigger) do url = if m.channel do NolaWeb.Router.Helpers.irc_url(NolaWeb.Endpoint, :txt, m.network, NolaWeb.format_chan(m.channel), trigger) else NolaWeb.Router.Helpers.irc_url(NolaWeb.Endpoint, :txt, trigger) end m.replyfun.("-> #{url}") end {:noreply, state} end def handle_info({:irc, :trigger, trigger, msg = %{trigger: %{type: :bang, args: opts}}}, state) do {trigger, _} = clean_trigger(trigger) line = get_random(msg, state.triggers, trigger, String.trim(Enum.join(opts, " "))) if line do msg.replyfun.(format_line(line, nil, msg)) end {:noreply, state} end # # TXT: ADD # def handle_info({:irc, :trigger, trigger, msg = %{trigger: %{type: :plus, args: content}}}, state) do with \ true <- can_write?(state, msg, trigger), {:ok, idx} <- add(state.triggers, msg.text) do msg.replyfun.("#{msg.sender.nick}: ajouté à #{trigger}. (#{idx})") {:noreply, %__MODULE__{state | triggers: load()}} else {:error, {:jaro, string, idx}} -> msg.replyfun.("#{msg.sender.nick}: doublon #{trigger}##{idx}: #{string}") error -> Logger.debug("txt add failed: #{inspect error}") {:noreply, state} end end # # TXT: DELETE # def handle_info({:irc, :trigger, trigger, msg = %{trigger: %{type: :minus, args: [id]}}}, state) do with \ true <- can_write?(state, msg, trigger), data <- Map.get(state.triggers, trigger), {id, ""} <- Integer.parse(id), {text, _id} <- Enum.find(data, fn({_, idx}) -> id-1 == idx end) do data = data |> Enum.into(Map.new) data = Map.delete(data, text) msg.replyfun.("#{msg.sender.nick}: #{trigger}.txt##{id} supprimée: #{text}") dump(trigger, data) {:noreply, %__MODULE__{state | triggers: load()}} else _ -> {:noreply, state} end end def handle_info(:reload_markov, state=%__MODULE__{triggers: triggers, markov: markov}) do state.markov_handler.reload(state.triggers, state.markov) {:noreply, state} end def handle_info(msg, state) do {:noreply, state} end def handle_call({:random, file}, _from, state) do random = get_random(nil, state.triggers, file, []) {:reply, random, state} end def terminate(_reason, state) do if state.locks do :dets.sync(state.locks) :dets.close(state.locks) end :ok end # Load/Reloads text files from disk defp load() do triggers = Path.wildcard(directory() <> "/*.txt") |> Enum.reduce(%{}, fn(path, m) -> file = Path.basename(path) key = String.replace(file, ".txt", "") data = directory() <> file |> File.read! |> String.split("\n") |> Enum.reject(fn(line) -> cond do line == "" -> true !line -> true true -> false end end) |> Enum.with_index Map.put(m, key, data) end) |> Enum.sort |> Enum.into(Map.new) send(self(), :reload_markov) triggers end defp dump(trigger, data) do data = data |> Enum.sort_by(fn({_, idx}) -> idx end) |> Enum.map(fn({text, _}) -> text end) |> Enum.join("\n") File.write!(directory() <> "/" <> trigger <> ".txt", data<>"\n", []) end defp get_random(msg, triggers, trigger, []) do if data = Map.get(triggers, trigger) do {data, _idx} = Enum.random(data) data else nil end end defp get_random(msg, triggers, trigger, opt) do arg = case Integer.parse(opt) do {pos, ""} -> {:index, pos} {_pos, _some_string} -> {:grep, opt} _error -> {:grep, opt} end get_with_param(msg, triggers, trigger, arg) end defp get_with_param(msg, triggers, trigger, {:index, pos}) do data = Map.get(triggers, trigger, %{}) case Enum.find(data, fn({_, index}) -> index+1 == pos end) do {text, _} -> text _ -> nil end end defp get_with_param(msg, triggers, trigger, {:grep, query}) do out = with_stateful_results(msg, {:grep, trigger, query}, fn() -> data = Map.get(triggers, trigger, %{}) regex = Regex.compile!("#{query}", "i") Enum.filter(data, fn({txt, _}) -> Regex.match?(regex, txt) end) |> Enum.map(fn({txt, _}) -> txt end) |> Enum.shuffle() end) if out, do: out end defp create_file(name) do File.touch!(directory() <> "/" <> name <> ".txt") :ok end defp add(triggers, trigger_and_content) do case String.split(trigger_and_content, " ", parts: 2) do [trigger, content] -> {trigger, _} = clean_trigger(trigger) if Map.has_key?(triggers, trigger) do jaro = Enum.find(triggers[trigger], fn({string, idx}) -> String.jaro_distance(content, string) > 0.9 end) if jaro do {string, idx} = jaro {:error, {:jaro, string, idx}} else File.write!(directory() <> "/" <> trigger <> ".txt", content<>"\n", [:append]) idx = Enum.count(triggers[trigger])+1 {:ok, idx} end else {:error, :notxt} end _ -> {:error, :badarg} end end # fixme: this is definitely the ugliest thing i've ever done defp clean_trigger(trigger) do [trigger | opts] = trigger |> String.strip |> String.split(" ", parts: 2) trigger = trigger |> String.downcase |> :unicode.characters_to_nfd_binary() |> String.replace(~r/[^a-z0-9._]/, "") |> String.trim(".") |> String.trim("_") {trigger, opts} end def format_line(line, prefix, msg) do prefix = unless(prefix, do: "", else: prefix) prefix <> line |> String.split("\\\\") |> Enum.map(fn(line) -> String.split(line, "\\\\\\\\") end) |> List.flatten() |> Enum.map(fn(line) -> String.trim(line) |> Tmpl.render(msg) end) end def directory() do Application.get_env(:nola, :data_path) <> "/irc.txt/" end defp can_write?(%{rw: rw?, locks: locks}, msg = %{channel: nil, sender: sender}, trigger) do admin? = IRC.admin?(sender) locked? = case :dets.lookup(locks, trigger) do [{trigger}] -> true _ -> false end unlocked? = if rw? == false, do: false, else: !locked? can? = unlocked? || admin? if !can? do reason = if !rw?, do: "lecture seule", else: "fichier vérrouillé" msg.replyfun.("#{sender.nick}: permission refusée (#{reason})") end can? end defp can_write?(state = %__MODULE__{rw: rw?, locks: locks}, msg = %{channel: channel, sender: sender}, trigger) do admin? = IRC.admin?(sender) operator? = IRC.UserTrack.operator?(msg.network, channel, sender.nick) locked? = case :dets.lookup(locks, trigger) do [{trigger}] -> true _ -> false end unlocked? = if rw? == false, do: false, else: !locked? can? = admin? || operator? || unlocked? if !can? do reason = if !rw?, do: "lecture seule", else: "fichier vérrouillé" msg.replyfun.("#{sender.nick}: permission refusée (#{reason})") end can? end end diff --git a/lib/plugins/untappd.ex b/lib/plugins/untappd.ex index cf5c694..e1731bd 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 ` Information about the first beer matching `` * `?beer ` List the 10 firsts beer matching `` _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(IRC.PubSub, "trigger:beer", [plugin: __MODULE__]) + {: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 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 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 c4dd719..a9a2d24 100644 --- a/lib/plugins/user_mention.ex +++ b/lib/plugins/user_mention.ex @@ -1,52 +1,52 @@ defmodule Nola.Plugins.UserMention do @moduledoc """ # mention * **@`` ``**: 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(IRC.PubSub, "triggers", plugin: __MODULE__) + {: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 nick = nick |> String.trim(":") |> String.trim(",") target = IRC.Account.find_always_by_nick(network, channel, nick) if target do telegram = IRC.Account.get_meta(target, "telegram-id") sms = IRC.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 dcd1874..caef306 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 ``**: retourne le premier résultat de la `` 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(IRC.PubSub, "trigger:wp", [plugin: __MODULE__]) + {: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 irc_random(message) {:noreply, state} end def handle_info({:irc, :trigger, _, message = %IRC.Message{trigger: %IRC.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 c2f106e..02c1c51 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 `** lance `` sur WolframAlpha """ def irc_doc, do: @moduledoc def start_link() do GenServer.start_link(__MODULE__, [], name: __MODULE__) end def init(_) do - {:ok, _} = Registry.register(IRC.PubSub, "trigger:wa", [plugin: __MODULE__]) + {: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 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 c62f73c..e23fd45 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 ``**, !youtube ``: retourne le premier résultat de la `` 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(IRC.PubSub, t, [plugin: __MODULE__]) + 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 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 ca8a437..49fc06d 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(IRC.PubSub, "#{net}/#{chan}:messages", plugin: __MODULE__) - {:ok, _} = Registry.register(IRC.PubSub, "#{net}/#{chan}:triggers", plugin: __MODULE__) - {:ok, _} = Registry.register(IRC.PubSub, "#{net}/#{chan}:outputs", plugin: __MODULE__) + {: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 = IRC.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 |> IRC.Account.new_account() |> IRC.Account.update_account_name(name) |> IRC.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 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), <> = 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/web/controllers/irc_auth_sse_controller.ex b/lib/web/controllers/irc_auth_sse_controller.ex index 62ee2b5..01c840b 100644 --- a/lib/web/controllers/irc_auth_sse_controller.ex +++ b/lib/web/controllers/irc_auth_sse_controller.ex @@ -1,66 +1,66 @@ defmodule NolaWeb.IrcAuthSseController do use NolaWeb, :controller require Logger @ping_interval 20_000 @expire_delay :timer.minutes(3) def sse(conn, params) do perks = if uri = Map.get(params, "redirect_to") do {:redirect, uri} else nil end token = String.downcase(EntropyString.random_string(65)) conn |> assign(:token, token) |> assign(:perks, perks) |> put_resp_header("X-Accel-Buffering", "no") |> put_resp_header("content-type", "text/event-stream") |> send_chunked(200) |> subscribe() |> send_sse_message("token", token) |> sse_loop end def subscribe(conn) do :timer.send_interval(@ping_interval, {:event, :ping}) :timer.send_after(@expire_delay, {:event, :expire}) - {:ok, _} = Registry.register(IRC.PubSub, "messages:private", []) + {:ok, _} = Registry.register(Nola.PubSub, "messages:private", []) conn end def sse_loop(conn) do {type, event, exit} = receive do {:event, :ping} -> {"ping", "ping", false} {:event, :expire} -> {"expire", "expire", true} {:irc, :text, %{account: account, text: token} = m} -> if String.downcase(String.trim(token)) == conn.assigns.token do path = Nola.AuthToken.new_path(account.id, conn.assigns.perks) m.replyfun.("ok!") {"authenticated", path, true} else {nil, nil, false} end _ -> {nil, nil, false} end conn = if type do send_sse_message(conn, type, event) else conn end if exit do conn else sse_loop(conn) end end defp send_sse_message(conn, type, data) do {:ok, conn} = chunk(conn, "event: #{type}\ndata: #{data}\n\n") conn end end diff --git a/lib/web/live/chat_live.ex b/lib/web/live/chat_live.ex index 678ea36..88afa0b 100644 --- a/lib/web/live/chat_live.ex +++ b/lib/web/live/chat_live.ex @@ -1,120 +1,120 @@ defmodule NolaWeb.ChatLive do use Phoenix.LiveView use Phoenix.HTML require Logger def mount(%{"network" => network, "chan" => chan}, %{"account" => account_id}, socket) do chan = NolaWeb.reformat_chan(chan) connection = IRC.Connection.get_network(network, chan) account = IRC.Account.get(account_id) membership = IRC.Membership.of_account(IRC.Account.get("DRgpD4fLf8PDJMLp8Dtb")) if account && connection && Enum.member?(membership, {connection.network, chan}) do - {:ok, _} = Registry.register(IRC.PubSub, "#{connection.network}:events", plugin: __MODULE__) + {:ok, _} = Registry.register(Nola.PubSub, "#{connection.network}:events", plugin: __MODULE__) for t <- ["messages", "triggers", "outputs", "events"] do - {:ok, _} = Registry.register(IRC.PubSub, "#{connection.network}/#{chan}:#{t}", plugin: __MODULE__) + {:ok, _} = Registry.register(Nola.PubSub, "#{connection.network}/#{chan}:#{t}", plugin: __MODULE__) end IRC.PuppetConnection.start(account, connection) users = IRC.UserTrack.channel(connection.network, chan) |> Enum.map(fn(tuple) -> IRC.UserTrack.User.from_tuple(tuple) end) |> Enum.reduce(Map.new, fn(user = %{id: id}, acc) -> Map.put(acc, id, user) end) backlog = case Nola.IRC.Buffer.select_buffer(connection.network, chan) do {backlog, _} -> {backlog, _} = Enum.reduce(backlog, {backlog, nil}, &reduce_contextual_event/2) Enum.reverse(backlog) _ -> [] end socket = socket |> assign(:connection_id, connection.id) |> assign(:network, connection.network) |> assign(:chan, chan) |> assign(:title, "live") |> assign(:channel, chan) |> assign(:account_id, account.id) |> assign(:backlog, backlog) |> assign(:users, users) |> assign(:counter, 0) {:ok, socket} else {:ok, redirect(socket, to: "/")} end end def handle_event("send", %{"message" => %{"text" => text}}, socket) do account = IRC.Account.get(socket.assigns.account_id) IRC.send_message_as(account, socket.assigns.network, socket.assigns.channel, text, true) {:noreply, assign(socket, :counter, socket.assigns.counter + 1)} end def handle_info({:irc, :event, event = %{type: :join, user_id: id}}, socket) do if user = IRC.UserTrack.lookup(id) do socket = socket |> assign(:users, Map.put(socket.assigns.users, id, user)) |> append_to_backlog(event) {:noreply, socket} else {:noreply, socket} end end def handle_info({:irc, :event, event = %{type: :nick, user_id: id, nick: nick}}, socket) do socket = socket |> assign(:users, update_in(socket.assigns.users, [id, :nick], nick)) |> append_to_backlog(event) {:noreply, socket} end def handle_info({:irc, :event, event = %{type: :quit, user_id: id}}, socket) do socket = socket |> assign(:users, Map.delete(socket.assigns.users, id)) |> append_to_backlog(event) {:noreply, socket} end def handle_info({:irc, :event, event = %{type: :part, user_id: id}}, socket) do socket = socket |> assign(:users, Map.delete(socket.assigns.users, id)) |> append_to_backlog(event) {:noreply, socket} end def handle_info({:irc, :trigger, _, message}, socket) do handle_info({:irc, nil, message}, socket) end def handle_info({:irc, :text, message}, socket) do IO.inspect({:live_message, message}) socket = socket |> append_to_backlog(message) {:noreply, socket} end def handle_info(info, socket) do Logger.debug("Unhandled info: #{inspect info}") {:noreply, socket} end defp append_to_backlog(socket, line) do {add, _} = reduce_contextual_event(line, {[], List.last(socket.assigns.backlog)}) assign(socket, :backlog, socket.assigns.backlog ++ add) end defp reduce_contextual_event(line, {acc, nil}) do {[line | acc], line} end defp reduce_contextual_event(line, {acc, last}) do if NaiveDateTime.to_date(last.at) != NaiveDateTime.to_date(line.at) do {[%{type: :day_changed, date: NaiveDateTime.to_date(line.at), at: nil}, line | acc], line} else {[line | acc], line} end end end