diff options
110 files changed, 6362 insertions, 4121 deletions
diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..09b7b82 --- /dev/null +++ b/.tool-versions @@ -0,0 +1,2 @@ +erlang 28.0.1 +elixir 1.18.4-otp-28 diff --git a/config/config.exs b/config/config.exs index bf52838..6468a16 100644 --- a/config/config.exs +++ b/config/config.exs @@ -1,6 +1,7 @@ import Config config :logger, level: :debug + config :logger, :console, format: "$date $time [$level] $metadata$message\n", metadata: :all @@ -35,8 +36,7 @@ config :nola, NolaWeb.Endpoint, render_errors: [view: NolaWeb.ErrorView, accepts: ~w(html json)], server: true, live_view: [signing_salt: "CHANGE_ME_FFS"], - pubsub: [name: NolaWeb.PubSub, - adapter: Phoenix.PubSub.PG2] + pubsub: [name: NolaWeb.PubSub, adapter: Phoenix.PubSub.PG2] config :mime, :types, %{"text/event-stream" => ["sse"]} @@ -49,8 +49,43 @@ config :nola, :youtube, invidious: "yewtu.be" config :mnesia, - dir: '.mnesia/#{Mix.env}/#{node()}' + dir: '.mnesia/#{Mix.env()}/#{node()}' + +config :nola, Nola.Plugins.Link, + proxy: nil, + scraper: [ + service: "usescraper", + config: [ + api_key: "xxxx", + http_options: [ + timeout: :timer.seconds(120), + recv_timeout: :timer.seconds(120) + ] + ] + ], + store: [ + ttl: :timer.hours(24), + inhibit: :timer.hours(16), + interval: :timer.minutes(30) + ], + handlers: [ + "Nola.Plugins.Link.Image": [], + "Nola.Plugins.Link.HTML": [], + "Nola.Plugins.Link.PDF": [], + "Nola.Plugins.Link.YouTube": [ + invidious: true + ], + "Nola.Plugins.Link.Twitter": [ + expand_quoted: true + ], + "Nola.Plugins.Link.Imgur": [], + "Nola.Plugins.Link.Github": [], + "Nola.Plugins.Link.Reddit": [], + "Nola.Plugins.Link.ImgDebridLink": [] + ] + +config :floki, :html_parser, Floki.HTMLParser.FastHtml # Import environment specific config. This must remain at the bottom # of this file so it overrides the configuration defined above. -import_config "#{Mix.env}.exs" +import_config "#{Mix.env()}.exs" diff --git a/lib/alcool.ex b/lib/alcool.ex index 40384ba..044e13b 100644 --- a/lib/alcool.ex +++ b/lib/alcool.ex @@ -1,16 +1,14 @@ defmodule Alcool do - - @spec units(Float.t, Float.t) :: Float.t + @spec units(Float.t(), Float.t()) :: Float.t() def units(cl, degrees) do - kg(cl, degrees)*100 + kg(cl, degrees) * 100 end def grams(cl, degrees) do - kg(cl, degrees)*1000 + kg(cl, degrees) * 1000 end def kg(cl, degrees) do - (((cl/100) * 0.8) * (degrees/100)) + cl / 100 * 0.8 * (degrees / 100) end - end diff --git a/lib/couch.ex b/lib/couch.ex index 6b39100..8180e6f 100644 --- a/lib/couch.ex +++ b/lib/couch.ex @@ -3,17 +3,24 @@ defmodule Couch do Simple, no-frills, CouchDB client """ - @type base_error :: {:error, :bad_request} | {:error, :unauthorized} | - {:error, :server_error} | {:error, :service_unavailable} | - {:error, :bad_response} | {:error, HTTPoison.Error.t()} + @type base_error :: + {:error, :bad_request} + | {:error, :unauthorized} + | {:error, :server_error} + | {:error, :service_unavailable} + | {:error, :bad_response} + | {:error, HTTPoison.Error.t()} def new(db, params \\ []) do {url, headers, options} = prepare_request([db], [], params) + case HTTPoison.put(url, headers, options) do {:ok, %HTTPoison.Response{status_code: ok, body: body}} when ok in [201, 202] -> :ok + {:ok, %HTTPoison.Response{status_code: 412}} -> {:error, :exists} + error -> handle_generic_response(error) end @@ -24,48 +31,70 @@ defmodule Couch do `params` are [documented here](https://docs.couchdb.org/en/3.2.2-docs/api/document/common.html) """ - @spec get(String.t(), String.t() | :all_docs, Keyword.t()) :: {:ok, Map.t()} | {:error, :not_found} | {:error, any()} + @spec get(String.t(), String.t() | :all_docs, Keyword.t()) :: + {:ok, Map.t()} | {:error, :not_found} | {:error, any()} def get(db, doc, params \\ []) def get(db, :all_docs, params), do: get(db, "_all_docs", params) + def get(db, doc, params) do {url, headers, options} = prepare_request([db, doc], [], params) + case HTTPoison.get(url, headers, options) do {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> {:ok, Poison.decode!(body)} - error -> handle_generic_response(error) + + error -> + handle_generic_response(error) end end - @spec post(String.t(), Map.t(), Keyword.t()) :: {:error, :operation_failed} | {:error, :not_found} | {:error, :exists} | {:error, any()} + @spec post(String.t(), Map.t(), Keyword.t()) :: + {:error, :operation_failed} | {:error, :not_found} | {:error, :exists} | {:error, any()} def post(db, data, params \\ []) do - {url, headers, options} = prepare_request([db], [{"content-type", "application/json"}], params) - with \ - {:ok, %HTTPoison.Response{status_code: ok, body: body}} when ok in [201, 202] <- HTTPoison.post(url, Poison.encode!(data), headers, options), - {:json, {:ok, %{"ok" => true, "id" => id, "rev" => rev}}} <- {:json, Poison.decode(body)} do - {:ok, id, rev} + {url, headers, options} = + prepare_request([db], [{"content-type", "application/json"}], params) + + with {:ok, %HTTPoison.Response{status_code: ok, body: body}} when ok in [201, 202] <- + HTTPoison.post(url, Poison.encode!(data), headers, options), + {:json, {:ok, %{"ok" => true, "id" => id, "rev" => rev}}} <- {:json, Poison.decode(body)} do + {:ok, id, rev} else - {:ok, %HTTPoison.Response{status_code: 409}} -> {:error, :exists} + {:ok, %HTTPoison.Response{status_code: 409}} -> + {:error, :exists} + {:json, {:ok, body}} -> - Logger.error("couch: operation failed: #{inspect body}") + Logger.error("couch: operation failed: #{inspect(body)}") {:error, :operation_failed} - error -> handle_generic_response(error) + + error -> + handle_generic_response(error) end end - @spec put(String.t(), Map.t(), Keyword.t()) :: {:error, :operation_failed} | {:error, :not_found} | {:error, :conflict} | {:error, any()} + @spec put(String.t(), Map.t(), Keyword.t()) :: + {:error, :operation_failed} + | {:error, :not_found} + | {:error, :conflict} + | {:error, any()} def put(db, doc = %{"_id" => id, "_rev" => _}, params \\ []) do - {url, headers, options} = prepare_request([db, id], [{"content-type", "application/json"}], params) + {url, headers, options} = + prepare_request([db, id], [{"content-type", "application/json"}], params) + case HTTPoison.put(url, Poison.encode!(doc), headers, options) do {:ok, %HTTPoison.Response{status_code: ok, body: body}} when ok in [201, 202] -> body = Poison.decode!(body) + if Map.get(body, "ok") do {:ok, Map.get(body, "id"), Map.get(body, "rev")} else {:error, :operation_failed} end + {:ok, %HTTPoison.Response{status_code: 209}} -> {:error, :conflict} - error -> handle_generic_response(error) + + error -> + handle_generic_response(error) end end @@ -74,22 +103,30 @@ defmodule Couch do base_url = Keyword.get(config, :url, "http://localhost:5984") - path = path - |> Enum.filter(& &1) - |> Enum.map(&to_string/1) - |> Enum.map(fn(part) -> part - |> String.replace("/", "%2F") - |> String.replace("#", "%23") - end) - |> Path.join() - - url = base_url - |> URI.merge(path) - |> to_string() - - headers = headers ++ [{"accept", "application/json"}, {"user-agent", "#{Nola.brand(:name)} v#{Nola.version()}"}] - - params = Enum.map(params, fn({k, v}) -> {to_string(k), v} end) + path = + path + |> Enum.filter(& &1) + |> Enum.map(&to_string/1) + |> Enum.map(fn part -> + part + |> String.replace("/", "%2F") + |> String.replace("#", "%23") + end) + |> Path.join() + + url = + base_url + |> URI.merge(path) + |> to_string() + + headers = + headers ++ + [ + {"accept", "application/json"}, + {"user-agent", "#{Nola.brand(:name)} v#{Nola.version()}"} + ] + + params = Enum.map(params, fn {k, v} -> {to_string(k), v} end) client_options = Keyword.get(config, :client_options, []) options = [params: params] ++ options ++ client_options @@ -102,7 +139,8 @@ defmodule Couch do {url, headers, options} end - defp handle_generic_response({:ok, %HTTPoison.Response{status_code: code}}), do: {:error, Plug.Conn.Status.reason_atom(code)} - defp handle_generic_response({:error, %HTTPoison.Error{reason: reason}}), do: {:error, reason} + defp handle_generic_response({:ok, %HTTPoison.Response{status_code: code}}), + do: {:error, Plug.Conn.Status.reason_atom(code)} + defp handle_generic_response({:error, %HTTPoison.Error{reason: reason}}), do: {:error, reason} end @@ -6,6 +6,7 @@ defmodule Nola.Irc do def send_message_as(account, network, channel, text, force_puppet \\ false, meta \\ []) do connection = Nola.Irc.Connection.get_network(network) + if connection && (force_puppet || Nola.Irc.PuppetConnection.whereis(account, connection)) do Nola.Irc.PuppetConnection.start_and_send_message(account, connection, channel, text, meta) else @@ -21,7 +22,7 @@ defmodule Nola.Irc 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? + |> Enum.any?() end defp admin_part_match?(:_, _), do: true @@ -34,9 +35,13 @@ defmodule Nola.Irc do Nola.Irc.Connection.setup() [ - worker(Registry, [[keys: :duplicate, name: Nola.Irc.ConnectionPubSub]], id: :registr_irc_conn), - supervisor(Nola.Irc.Connection.Supervisor, [], [name: Nola.Irc.Connection.Supervisor]), - supervisor(Nola.Irc.PuppetConnection.Supervisor, [], [name: Nola.Irc.PuppetConnection.Supervisor]), + worker(Registry, [[keys: :duplicate, name: Nola.Irc.ConnectionPubSub]], + id: :registr_irc_conn + ), + supervisor(Nola.Irc.Connection.Supervisor, [], name: Nola.Irc.Connection.Supervisor), + supervisor(Nola.Irc.PuppetConnection.Supervisor, [], + name: Nola.Irc.PuppetConnection.Supervisor + ) ] end @@ -45,5 +50,4 @@ defmodule Nola.Irc do Logger.info("Starting connections") Nola.Irc.Connection.start_all() end - end diff --git a/lib/irc/admin_handler.ex b/lib/irc/admin_handler.ex index cb953db..e4834fa 100644 --- a/lib/irc/admin_handler.ex +++ b/lib/irc/admin_handler.ex @@ -13,17 +13,22 @@ defmodule Nola.Irc.AdminHandler do end def init([client]) do - ExIRC.Client.add_handler client, self + ExIRC.Client.add_handler(client, self()) {:ok, _} = Registry.register(Nola.PubSub, "op", []) {:ok, client} end - def handle_info({:irc, :trigger, "op", m = %Nola.Message{trigger: %Nola.Trigger{type: :bang}, sender: sender}}, client) do + def handle_info( + {:irc, :trigger, "op", + m = %Nola.Message{trigger: %Nola.Trigger{type: :bang}, sender: sender}}, + client + ) do if Nola.Irc.admin?(sender) do m.replyfun.({:mode, "+o"}) else m.replyfun.({:kick, "non"}) end + {:noreply, client} end @@ -31,11 +36,11 @@ defmodule Nola.Irc.AdminHandler do if Nola.Irc.admin?(sender) do ExIRC.Client.mode(client, chan, "+o", sender.nick) end + {:noreply, client} end def handle_info(msg, client) do {:noreply, client} end - end diff --git a/lib/irc/connection.ex b/lib/irc/connection.ex index 87da466..4de76cd 100644 --- a/lib/irc/connection.ex +++ b/lib/irc/connection.ex @@ -32,18 +32,18 @@ defmodule Nola.Irc.Connection do def irc_doc, do: nil @min_backoff :timer.seconds(5) - @max_backoff :timer.seconds(2*60) + @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: [] + 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 @@ -54,7 +54,12 @@ defmodule Nola.Irc.Connection do end def start_child(%Nola.Irc.Connection{} = conn) do - spec = %{id: conn.id, start: {Nola.Irc.Connection, :start_link, [conn]}, restart: :transient} + spec = %{ + id: conn.id, + start: {Nola.Irc.Connection, :start_link, [conn]}, + restart: :transient + } + DynamicSupervisor.start_child(__MODULE__, spec) end @@ -68,7 +73,6 @@ defmodule Nola.Irc.Connection do end end - def changeset(params) do import Ecto.Changeset @@ -79,11 +83,23 @@ defmodule Nola.Irc.Connection do 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} + {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} + %__MODULE__{ + id: id, + network: network, + host: host, + port: port, + nick: nick, + user: user, + name: name, + pass: pass, + tls: tls, + channels: channels + } end ## -- MANAGER API @@ -102,7 +118,7 @@ defmodule Nola.Irc.Connection do end def connections() do - :dets.foldl(fn(object, acc) -> [from_tuple(object) | acc] end, [], dets()) + :dets.foldl(fn object, acc -> [from_tuple(object) | acc] end, [], dets()) end def start_all() do @@ -110,23 +126,29 @@ defmodule Nola.Irc.Connection do 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) + 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) + 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}}}], - [:"$_"]} + 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 @@ -147,7 +169,9 @@ defmodule Nola.Irc.Connection do case :global.whereis_name(id) do pid when is_pid(pid) -> GenServer.stop(pid, :normal) - _ -> :error + + _ -> + :error end end @@ -160,7 +184,9 @@ defmodule Nola.Irc.Connection do :dets.insert(dets(), to_tuple(conn)) Nola.Irc.Connection.Supervisor.start_child(conn) end - error -> error + + error -> + error end end @@ -175,11 +201,12 @@ defmodule Nola.Irc.Connection do def broadcast_message(net, chan, message, meta \\ []) do dispatch("conn", {:broadcast, net, chan, message}, meta, Nola.Irc.ConnectionPubSub) end - #def broadcast_message(list, message, meta \\ []) when is_list(list) do + + # def broadcast_message(list, message, meta \\ []) when is_list(list) do # for {net, chan} <- list do # broadcast_message(net, chan, message, meta) # end - #send + # send def privmsg(channel, line) do GenServer.cast(__MODULE__, {:privmsg, channel, line}) @@ -187,13 +214,22 @@ defmodule Nola.Irc.Connection do def init([conn]) do Logger.metadata(conn: conn.id) - backoff = :backoff.init(@min_backoff, @max_backoff) - |> :backoff.type(:jitter) - {:ok, %{client: nil, - ignore_channel_for_a_while: conn.network == "snoonet", - backoff: backoff, - channels: Map.new, - conn: conn, connected_server: nil, connected_port: nil, network: conn.network}, {:continue, :connect}} + + backoff = + :backoff.init(@min_backoff, @max_backoff) + |> :backoff.type(:jitter) + + {:ok, + %{ + client: nil, + ignore_channel_for_a_while: conn.network == "snoonet", + backoff: backoff, + channels: Map.new(), + conn: conn, + connected_server: nil, + connected_port: nil, + network: conn.network + }, {:continue, :connect}} end @triggers %{ @@ -214,31 +250,50 @@ defmodule Nola.Irc.Connection do } def handle_continue(:connect, state) do - client_opts = [] - |> Keyword.put(:network, state.conn.network) + client_opts = + [] + |> Keyword.put(:network, state.conn.network) + {:ok, _} = Registry.register(Nola.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 + + 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]) + + {conn_fun, opts2} = + if state.conn.tls, + do: {:connect_ssl!, [{:cacerts, :certifi.cacerts()}]}, + else: {:connect!, []} + + conn_result = + apply(ExIRC.Client, conn_fun, [ + client, + to_charlist(state.conn.host), + state.conn.port, + opts ++ opts2 + ]) + + Logger.warn( + "Connecting to #{state.conn.host}:#{state.conn.port} using #{conn_fun} => #{inspect(conn_result)}" + ) {: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") + Logger.info("#{inspect(self())} Disconnected -- reconnecting in #{inspect(delay)}ms") Process.send_after(self(), :connect, delay) - {:noreply, %{state | channels: Map.new, backoff: backoff}} + {:noreply, %{state | channels: Map.new(), backoff: backoff}} end def handle_info(:connect, state) do @@ -249,13 +304,29 @@ defmodule Nola.Irc.Connection 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}") + 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 | channels: Map.new, backoff: backoff, connected_server: server, connected_port: port}} + + ExIRC.Client.logon( + state.client, + state.conn.pass || "", + state.conn.nick, + state.conn.user, + state.conn.name + ) + + {:noreply, + %{ + state + | channels: Map.new(), + backoff: backoff, + connected_server: server, + connected_port: port + }} end # Logon successful @@ -269,9 +340,11 @@ defmodule Nola.Irc.Connection do # ISUP def handle_info({:isup, network}, state) when is_binary(network) do Nola.UserTrack.clear_network(state.network) + if network != state.network do Logger.warn("Possibly misconfigured network: #{network} != #{state.network}") end + {:noreply, state} end @@ -283,49 +356,88 @@ defmodule Nola.Irc.Connection do # Received something in a channel def handle_info({:received, text, sender, chan}, state) do - channel_state = Map.get(state.channels, chan, Map.new) - if state.ignore_channel_for_a_while && DateTime.diff(DateTime.utc_now(), Map.get(channel_state, :joined)) < 30 do + channel_state = Map.get(state.channels, chan, Map.new()) + + if state.ignore_channel_for_a_while && + DateTime.diff(DateTime.utc_now(), Map.get(channel_state, :joined)) < 30 do :ignore_channel else - user = if user = Nola.UserTrack.find_by_nick(state.network, sender.nick) do - user - else - Logger.error("Could not lookup user for message: #{inspect {state.network, chan, sender.nick}}") - user = Nola.UserTrack.joined(chan, sender, []) - ExIRC.Client.who(state.client, chan) # Rewho everything in case of need ? We shouldn't not know that user.. - user - end + user = + if user = Nola.UserTrack.find_by_nick(state.network, sender.nick) do + user + else + Logger.error( + "Could not lookup user for message: #{inspect({state.network, chan, sender.nick})}" + ) + + user = Nola.UserTrack.joined(chan, sender, []) + # Rewho everything in case of need ? We shouldn't not know that user.. + ExIRC.Client.who(state.client, chan) + 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}}") + # Rewho everything in case of need ? We shouldn't not know that user.. + ExIRC.Client.who(state.client, chan) + + 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 + reply_fun = fn text -> irc_reply(state, {chan, sender}, text) end account = Nola.Account.lookup(sender) - message = %Nola.Message{id: FlakeId.get(), transport: :irc, at: NaiveDateTime.utc_now(), text: text, network: state.network, - account: account, sender: sender, channel: chan, replyfun: reply_fun, - trigger: extract_trigger(text)} - message = case Nola.UserTrack.messaged(message) do - :ok -> message - {:ok, message} -> message - end + + message = %Nola.Message{ + id: FlakeId.get(), + transport: :irc, + at: NaiveDateTime.utc_now(), + text: text, + network: state.network, + account: account, + sender: sender, + channel: chan, + replyfun: reply_fun, + trigger: extract_trigger(text) + } + + message = + case Nola.UserTrack.messaged(message) do + :ok -> message + {:ok, message} -> message + end + publish(message, ["#{message.network}/#{chan}:messages"]) end end 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 + reply_fun = fn text -> irc_reply(state, {sender.nick, sender}, text) end account = Nola.Account.lookup(sender) - message = %Nola.Message{id: FlakeId.get(), transport: :irc, text: text, network: state.network, at: NaiveDateTime.utc_now(), - account: account, sender: sender, replyfun: reply_fun, trigger: extract_trigger(text)} - message = case Nola.UserTrack.messaged(message) do - :ok -> message - {:ok, message} -> message - end + + message = %Nola.Message{ + id: FlakeId.get(), + transport: :irc, + text: text, + network: state.network, + at: NaiveDateTime.utc_now(), + account: account, + sender: sender, + replyfun: reply_fun, + trigger: extract_trigger(text) + } + + message = + case Nola.UserTrack.messaged(message) do + :ok -> message + {:ok, message} -> message + end + publish(message, ["messages:private", "#{message.network}/#{account.id}:messages"]) {:noreply, state} end @@ -334,38 +446,45 @@ defmodule Nola.Irc.Connection do def handle_info({:broadcast, net, account = %Nola.Account{}, message}, state) do if net == state.conn.network do user = Nola.UserTrack.find_by_account(net, account) + if user do irc_reply(state, {user.nick, nil}, message) end end + {:noreply, state} end + def handle_info({:broadcast, net, chan, message}, state) do if net == state.conn.network && Enum.member?(state.conn.channels, chan) do irc_reply(state, {chan, nil}, message) end + {:noreply, state} end ## -- UserTrack def handle_info({:joined, channel}, state) do - channels = Map.put(state.channels, channel, %{joined: DateTime.utc_now}) + channels = Map.put(state.channels, channel, %{joined: DateTime.utc_now()}) ExIRC.Client.who(state.client, channel) {:noreply, %{state | channels: channels}} end def handle_info({:who, channel, whos}, state) do - accounts = Enum.map(whos, fn(who = %ExIRC.Who{nick: nick, operator?: operator}) -> - priv = if operator, do: [:operator], else: [] - # Don't touch -- on WHO the bot joined, not the users. - Nola.UserTrack.joined(channel, who, priv, false) - account = Nola.Account.lookup(who) - if account do - {:account, who.network, channel, who.nick, account.id} - end - end) - |> Enum.filter(fn(x) -> x end) + accounts = + Enum.map(whos, fn who = %ExIRC.Who{nick: nick, operator?: operator} -> + priv = if operator, do: [:operator], else: [] + # Don't touch -- on WHO the bot joined, not the users. + Nola.UserTrack.joined(channel, who, priv, false) + account = Nola.Account.lookup(who) + + if account do + {:account, who.network, channel, who.nick, account.id} + end + end) + |> Enum.filter(fn x -> x end) + dispatch("account", {:accounts, accounts}) {:noreply, state} end @@ -378,9 +497,11 @@ defmodule Nola.Irc.Connection do def handle_info({:joined, channel, sender}, state) do Nola.UserTrack.joined(channel, sender, []) account = Nola.Account.lookup(sender) + if account do dispatch("account", {:account, sender.network, channel, sender.nick, account.id}) end + {:noreply, state} end @@ -405,7 +526,7 @@ defmodule Nola.Irc.Connection do end def handle_info(unhandled, client) do - Logger.debug("unhandled: #{inspect unhandled}") + Logger.debug("unhandled: #{inspect(unhandled)}") {:noreply, client} end @@ -416,34 +537,50 @@ defmodule Nola.Irc.Connection do end def publish(m = %Nola.Message{trigger: t = %Nola.Trigger{trigger: trigger}}, keys) do - dispatch(["triggers", "#{m.network}/#{m.channel}:triggers", "trigger:"<>trigger], {:irc, :trigger, trigger, m}, Enum.into(m.meta, [])) + dispatch( + ["triggers", "#{m.network}/#{m.channel}:triggers", "trigger:" <> trigger], + {:irc, :trigger, trigger, m}, + Enum.into(m.meta, []) + ) end def publish_event(net, event = %{type: _}) when is_binary(net) do - event = event - |> Map.put(:at, NaiveDateTime.utc_now()) - |> Map.put(:network, net) + 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) + 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, meta \\ [], sub \\ Nola.PubSub) - def dispatch(key, content, meta, sub) when is_binary(key), do: dispatch([key], content, meta, sub) + def dispatch(key, content, meta, sub) when is_binary(key), + do: dispatch([key], content, meta, sub) + def dispatch(keys, content, meta, sub) when is_list(keys) do - Logger.debug("dispatching: #{inspect keys} #{inspect content}") + Logger.debug("dispatching: #{inspect(keys)} #{inspect(content)}") should_dispatch_data = should_dispatch_data(content) origin = Keyword.get(meta, :origin, :no_origin) + for key <- keys do - spawn(fn() -> + spawn(fn -> Registry.dispatch(sub, key, fn h -> - for {pid, reg} <- h, do: if(should_dispatch?(key, origin, should_dispatch_data, reg), do: send(pid, content)) + for {pid, reg} <- h, + do: + if(should_dispatch?(key, origin, should_dispatch_data, reg), + do: send(pid, content) + ) end) end) end @@ -452,21 +589,26 @@ defmodule Nola.Irc.Connection do defp should_dispatch_data({:irc, :trigger, _, msg}) do should_dispatch_data(msg) end + defp should_dispatch_data({:irc, :text, msg}) do should_dispatch_data(msg) end + defp should_dispatch_data(%Nola.Message{network: network, channel: channel}) do Application.get_env(:nola, :channel_pubsub_plugin_ignore_list, %{}) |> Map.get("#{network}/#{channel}", []) end + defp should_dispatch_data(_) do [] end def should_dispatch?(_, _, [], _), do: true + def should_dispatch?(key, origin, data, reg) do if plugin = Keyword.get(reg, :plugin) do - plugin != origin || !Enum.member?(data, plugin) + # plugin != origin && !Enum.member?(data, plugin) + !Enum.member?(data, plugin) else true end @@ -479,7 +621,7 @@ defmodule Nola.Irc.Connection do def triggers, do: @triggers for {trigger, name} <- @triggers do - def extract_trigger(unquote(trigger)<>text) do + def extract_trigger(unquote(trigger) <> text) do text = String.strip(text) [trigger | args] = String.split(text, " ") %Nola.Trigger{type: unquote(name), trigger: String.downcase(trigger), args: args} @@ -494,15 +636,30 @@ defmodule Nola.Irc.Connection do # 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 = Nola.Irc.Message.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, %Nola.Message{id: FlakeId.get(), transport: :irc, network: network, - channel: target, text: line, sender: %ExIRC.SenderInfo{nick: state.conn.nick}, at: NaiveDateTime.utc_now(), meta: %{self: true}}} - end + defp irc_reply(state = %{client: client, network: network}, {target, _}, text) + when is_binary(text) or is_list(text) do + lines = + Nola.Irc.Message.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, + %Nola.Message{ + id: FlakeId.get(), + transport: :irc, + network: network, + channel: target, + text: line, + sender: %ExIRC.SenderInfo{nick: state.conn.nick}, + at: NaiveDateTime.utc_now(), + meta: %{self: true} + }} + end + for f <- outputs, do: dispatch(["irc:outputs", "#{network}/#{target}:outputs"], f) end @@ -547,12 +704,11 @@ defmodule Nola.Irc.Connection do end defp track_mode(network, channel, nick, mode) do - Logger.warn("Unhandled track_mode: #{inspect {nick, mode}}") + 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/message.ex b/lib/irc/message.ex index 3927079..4b61d36 100644 --- a/lib/irc/message.ex +++ b/lib/irc/message.ex @@ -1,28 +1,27 @@ defmodule Nola.Irc.Message do - @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) + Enum.map(string, fn s -> splitlong(s, max_chars) end) |> List.flatten() end def splitlong(string, max_chars) do string - |> String.codepoints + |> 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))) + max_chars = max_chars - length(String.codepoints(prefix)) + string - |> String.codepoints + |> String.codepoints() |> Enum.chunk_every(max_chars) - |> Enum.map(fn(line) -> prefix <> Enum.join(line) end) + |> Enum.map(fn line -> prefix <> Enum.join(line) end) end - end diff --git a/lib/irc/puppet_connection.ex b/lib/irc/puppet_connection.ex index 51b4c8a..d124568 100644 --- a/lib/irc/puppet_connection.ex +++ b/lib/irc/puppet_connection.ex @@ -1,9 +1,9 @@ defmodule Nola.Irc.PuppetConnection do require Logger @min_backoff :timer.seconds(5) - @max_backoff :timer.seconds(2*60) + @max_backoff :timer.seconds(2 * 60) @max_idle :timer.hours(12) - @env Mix.env + @env Mix.env() defmodule Supervisor do use DynamicSupervisor @@ -13,7 +13,12 @@ defmodule Nola.Irc.PuppetConnection do end def start_child(%Nola.Account{id: account_id}, %Nola.Irc.Connection{id: connection_id}) do - spec = %{id: {account_id, connection_id}, start: {Nola.Irc.PuppetConnection, :start_link, [account_id, connection_id]}, restart: :transient} + spec = %{ + id: {account_id, connection_id}, + start: {Nola.Irc.PuppetConnection, :start_link, [account_id, connection_id]}, + restart: :transient + } + DynamicSupervisor.start_child(__MODULE__, spec) end @@ -27,29 +32,48 @@ defmodule Nola.Irc.PuppetConnection do end end - def whereis(account = %Nola.Account{id: account_id}, connection = %Nola.Irc.Connection{id: connection_id}) do + def whereis( + account = %Nola.Account{id: account_id}, + connection = %Nola.Irc.Connection{id: connection_id} + ) do {:global, name} = name(account_id, connection_id) + case :global.whereis_name(name) do :undefined -> nil pid -> pid end end - def send_message(account = %Nola.Account{id: account_id}, connection = %Nola.Irc.Connection{id: connection_id}, channel, text, meta) do + def send_message( + account = %Nola.Account{id: account_id}, + connection = %Nola.Irc.Connection{id: connection_id}, + channel, + text, + meta + ) do GenServer.cast(name(account_id, connection_id), {:send_message, self(), channel, text, meta}) end - def start_and_send_message(account = %Nola.Account{id: account_id}, connection = %Nola.Irc.Connection{id: connection_id}, channel, text, meta) do + def start_and_send_message( + account = %Nola.Account{id: account_id}, + connection = %Nola.Irc.Connection{id: connection_id}, + channel, + text, + meta + ) do {:global, name} = name(account_id, connection_id) pid = whereis(account, connection) - pid = if !pid do + + pid = + if !pid do case Nola.Irc.PuppetConnection.Supervisor.start_child(account, connection) do {:ok, pid} -> pid {:error, {:already_started, pid}} -> pid end - else - pid - end + else + pid + end + GenServer.cast(pid, {:send_message, self(), channel, text, meta}) end @@ -58,7 +82,9 @@ defmodule Nola.Irc.PuppetConnection do end def start_link(account_id, connection_id) do - GenServer.start_link(__MODULE__, [account_id, connection_id], name: name(account_id, connection_id)) + GenServer.start_link(__MODULE__, [account_id, connection_id], + name: name(account_id, connection_id) + ) end def name(account_id, connection_id) do @@ -69,40 +95,61 @@ defmodule Nola.Irc.PuppetConnection do account = %Nola.Account{} = Nola.Account.get(account_id) connection = %Nola.Irc.Connection{} = Nola.Irc.Connection.lookup(connection_id) Logger.metadata(puppet_conn: account.id <> "@" <> connection.id) - backoff = :backoff.init(@min_backoff, @max_backoff) - |> :backoff.type(:jitter) - idle = :erlang.send_after(@max_idle, self, :idle) - {:ok, %{client: nil, backoff: backoff, idle: idle, connected: false, buffer: [], channels: [], connection_id: connection_id, account_id: account_id, connected_server: nil, connected_port: nil, network: connection.network}, {:continue, :connect}} + + backoff = + :backoff.init(@min_backoff, @max_backoff) + |> :backoff.type(:jitter) + + idle = :erlang.send_after(@max_idle, self(), :idle) + + {:ok, + %{ + client: nil, + backoff: backoff, + idle: idle, + connected: false, + buffer: [], + channels: [], + connection_id: connection_id, + account_id: account_id, + connected_server: nil, + connected_port: nil, + network: connection.network + }, {:continue, :connect}} end def handle_continue(:connect, state) do - #ipv6 = if @env == :prod do + # ipv6 = if @env == :prod do # subnet = Nola.Subnet.assign(state.account_id) # Nola.Account.put_meta(Nola.Account.get(state.account_id), "subnet", subnet) # ip = Pfx.host(subnet, 1) # {:ok, ipv6} = :inet_parse.ipv6_address(to_charlist(ip)) # System.cmd("add-ip6", [ip]) # ipv6 - #end + # end conn = Nola.Irc.Connection.lookup(state.connection_id) - client_opts = [] - |> Keyword.put(:network, conn.network) - client = if state.client && Process.alive?(state.client) do - Logger.info("Reconnecting client") - state.client - else - Logger.info("Connecting") - {:ok, client} = ExIRC.Client.start_link(debug: false) - ExIRC.Client.add_handler(client, self()) - client - end + + client_opts = + [] + |> Keyword.put(:network, conn.network) + + client = + if state.client && Process.alive?(state.client) do + Logger.info("Reconnecting client") + state.client + else + Logger.info("Connecting") + {:ok, client} = ExIRC.Client.start_link(debug: false) + ExIRC.Client.add_handler(client, self()) + client + end base_opts = [ {:nodelay, true} ] - #{ip, opts} = case {ipv6, :inet_res.resolve(to_charlist(conn.host), :in, :aaaa)} do + # {ip, opts} = case {ipv6, :inet_res.resolve(to_charlist(conn.host), :in, :aaaa)} do # {ipv6, {:ok, {:dns_rec, _dns_header, _query, rrs = [{:dns_rr, _, _, _, _, _, _, _, _, _} | _], _, _}}} -> # ip = rrs # |> Enum.map(fn({:dns_rr, _, :aaaa, :in, _, _, ipv6, _, _, _}) -> ipv6 end) @@ -115,56 +162,90 @@ defmodule Nola.Irc.PuppetConnection do # ] # {ip, opts} # _ -> - {ip, opts} = {to_charlist(conn.host), []} - #end + {ip, opts} = {to_charlist(conn.host), []} + # end + + {conn_fun, opts2} = + if state.conn.tls, + do: {:connect_ssl!, [{:cacerts, :certifi.cacerts()}]}, + else: {:connect!, []} - conn_fun = if conn.tls, do: :connect_ssl!, else: :connect! - apply(ExIRC.Client, conn_fun, [client, ip, conn.port, base_opts ++ opts]) + apply(ExIRC.Client, conn_fun, [client, ip, conn.port, base_opts ++ opts ++ opts2]) {:noreply, %{state | client: client}} end def handle_continue(:connected, state) do - state = Enum.reduce(Enum.reverse(state.buffer), state, fn(b, state) -> - {:noreply, state} = handle_cast(b, state) - state - end) + state = + Enum.reduce(Enum.reverse(state.buffer), state, fn b, state -> + {:noreply, state} = handle_cast(b, state) + state + end) + {:noreply, %{state | buffer: []}} end - def handle_cast(cast = {:send_message, _pid, _channel, _text, _meta}, state = %{connected: false, buffer: buffer}) do + def handle_cast( + cast = {:send_message, _pid, _channel, _text, _meta}, + state = %{connected: false, buffer: buffer} + ) do {:noreply, %{state | buffer: [cast | buffer]}} end def handle_cast({:send_message, pid, channel, text, pub_meta}, state = %{connected: true}) do - channels = if !Enum.member?(state.channels, channel) do - ExIRC.Client.join(state.client, channel) - [channel | state.channels] - else - state.channels - end + channels = + if !Enum.member?(state.channels, channel) do + ExIRC.Client.join(state.client, channel) + [channel | state.channels] + else + state.channels + end + ExIRC.Client.msg(state.client, :privmsg, channel, text) meta = %{puppet: true, from: pid, origin: Keyword.get(pub_meta, :origin, __MODULE__)} account = Nola.Account.get(state.account_id) nick = make_nick(state) - sender = %ExIRC.SenderInfo{network: state.network, nick: suffix_nick(nick), user: nick, host: "puppet."} - reply_fun = fn(text) -> + + sender = %ExIRC.SenderInfo{ + network: state.network, + nick: suffix_nick(nick), + user: nick, + host: "puppet." + } + + reply_fun = fn text -> Nola.Irc.Connection.broadcast_message(state.network, channel, text, pub_meta) end - message = %Nola.Message{id: FlakeId.get(), at: NaiveDateTime.utc_now(), text: text, network: state.network, account: account, sender: sender, channel: channel, replyfun: reply_fun, trigger: Nola.Irc.Connection.extract_trigger(text), meta: meta} - message = case Nola.UserTrack.messaged(message) do - :ok -> message - {:ok, message} -> message - end + + message = %Nola.Message{ + id: FlakeId.get(), + at: NaiveDateTime.utc_now(), + text: text, + network: state.network, + account: account, + sender: sender, + channel: channel, + replyfun: reply_fun, + trigger: Nola.Irc.Connection.extract_trigger(text), + meta: meta + } + + message = + case Nola.UserTrack.messaged(message) do + :ok -> message + {:ok, message} -> message + end + Nola.Irc.Connection.publish(message, ["#{message.network}/#{channel}:messages"]) - idle = if length(state.buffer) == 0 do - :erlang.cancel_timer(state.idle) - :erlang.send_after(@max_idle, self(), :idle) - else - state.idle - end + idle = + if length(state.buffer) == 0 do + :erlang.cancel_timer(state.idle) + :erlang.send_after(@max_idle, self(), :idle) + else + state.idle + end {:noreply, %{state | idle: idle, channels: channels}} end @@ -177,7 +258,7 @@ defmodule Nola.Irc.PuppetConnection do def handle_info(:disconnected, state) do {delay, backoff} = :backoff.fail(state.backoff) - Logger.info("#{inspect(self())} Disconnected -- reconnecting in #{inspect delay}ms") + Logger.info("#{inspect(self())} Disconnected -- reconnecting in #{inspect(delay)}ms") Process.send_after(self(), :connect, delay) {:noreply, %{state | connected: false, backoff: backoff}} end @@ -188,10 +269,18 @@ defmodule Nola.Irc.PuppetConnection do # Connection successful def handle_info({:connected, server, port}, state) do - Logger.info("#{inspect(self())} Connected to #{inspect(server)}:#{port} #{inspect state}") + Logger.info("#{inspect(self())} Connected to #{inspect(server)}:#{port} #{inspect(state)}") {_, backoff} = :backoff.succeed(state.backoff) base_nick = make_nick(state) - ExIRC.Client.logon(state.client, "", suffix_nick(base_nick), base_nick, "#{base_nick}'s puppet") + + ExIRC.Client.logon( + state.client, + "", + suffix_nick(base_nick), + base_nick, + "#{base_nick}'s puppet" + ) + {:noreply, %{state | backoff: backoff, connected_server: server, connected_port: port}} end @@ -199,8 +288,17 @@ defmodule Nola.Irc.PuppetConnection do def handle_info(:logged_in, state) do Logger.info("#{inspect(self())} Logged in") {_, backoff} = :backoff.succeed(state.backoff) + # Create an UserTrack entry for the client so it's authenticated to the right account_id already. - Nola.UserTrack.connected(state.network, suffix_nick(make_nick(state)), make_nick(state), "puppet.", state.account_id, %{puppet: true}) + Nola.UserTrack.connected( + state.network, + suffix_nick(make_nick(state)), + make_nick(state), + "puppet.", + state.account_id, + %{puppet: true} + ) + {:noreply, %{state | backoff: backoff}} end @@ -222,17 +320,21 @@ defmodule Nola.Irc.PuppetConnection do account = Nola.Account.get(state.account_id) user = Nola.UserTrack.find_by_account(state.network, account) base_nick = if(user, do: user.nick, else: account.name) - clean_nick = case String.split(base_nick, ":", parts: 2) do - ["@"<>nick, _] -> nick - [nick] -> nick - end + + clean_nick = + case String.split(base_nick, ":", parts: 2) do + ["@" <> nick, _] -> nick + [nick] -> nick + end + clean_nick + |> :unicode.characters_to_nfd_binary() + |> String.replace(~r/[^a-z0-9]/, "") end - if Mix.env == :dev do + if Mix.env() == :dev do def suffix_nick(nick), do: "#{nick}[d]" else def suffix_nick(nick), do: "#{nick}[p]" end - end diff --git a/lib/matrix.ex b/lib/matrix.ex index 0ad0836..384f204 100644 --- a/lib/matrix.ex +++ b/lib/matrix.ex @@ -5,7 +5,7 @@ defmodule Nola.Matrix do @behaviour MatrixAppService.Adapter.Room @behaviour MatrixAppService.Adapter.Transaction @behaviour MatrixAppService.Adapter.User - @env Mix.env + @env Mix.env() def dets(part) do (Nola.data_path() <> "/matrix-#{to_string(part)}.dets") |> String.to_charlist() @@ -20,12 +20,13 @@ defmodule Nola.Matrix do def myself?("@_dev:random.sh"), do: true def myself?("@_bot:random.sh"), do: true - def myself?("@_dev."<>_), do: true - def myself?("@_bot."<>_), do: true + def myself?("@_dev." <> _), do: true + def myself?("@_bot." <> _), do: true def myself?(_), do: false - def mxc_to_http(mxc = "mxc://"<>_) do + def mxc_to_http(mxc = "mxc://" <> _) do uri = URI.parse(mxc) + %URI{uri | scheme: "https", path: "/_matrix/media/r0/download/#{uri.authority}#{uri.path}"} |> URI.to_string() end @@ -41,6 +42,7 @@ defmodule Nola.Matrix do initial_device_display_name: "Application Service", username: if(@env == :dev, do: "_dev.#{id}", else: "_bot.#{id}") ] + Logger.debug("Registering user for #{id}") {:ok, %{"user_id" => mxid}} = Polyjuice.Client.LowLevel.register(client(), opts) :dets.insert(dets(:users), {id, mxid}) @@ -54,27 +56,31 @@ defmodule Nola.Matrix do end end - def user_name("@"<>name) do + def user_name("@" <> name) do [username, _] = String.split(name, ":", parts: 2) username end def application_childs() do import Supervisor.Spec + [ - supervisor(Nola.Matrix.Room.Supervisor, [], [name: Nola.Irc.PuppetConnection.Supervisor]), + supervisor(Nola.Matrix.Room.Supervisor, [], name: Nola.Irc.PuppetConnection.Supervisor) ] end def after_start() do - rooms = :dets.foldl(fn({id, _, _, _}, acc) -> [id | acc] end, [], dets(:rooms)) + rooms = :dets.foldl(fn {id, _, _, _}, acc -> [id | acc] end, [], dets(:rooms)) for room <- rooms, do: Nola.Matrix.Room.start(room) end def lookup_room(room) do case :dets.lookup(dets(:rooms), room) do - [{_, network, channel, opts}] -> {:ok, Map.merge(opts, %{network: network, channel: channel})} - _ -> {:error, :no_such_room} + [{_, network, channel, opts}] -> + {:ok, Map.merge(opts, %{network: network, channel: channel})} + + _ -> + {:error, :no_such_room} end end @@ -93,11 +99,16 @@ defmodule Nola.Matrix do end def create_room(room_alias) do - Logger.debug("Matrix: creating room #{inspect room_alias}") + Logger.debug("Matrix: creating room #{inspect(room_alias)}") localpart = localpart(room_alias) + with {:ok, network, channel} <- extract_network_channel_from_localpart(localpart), %Nola.Irc.Connection{} <- Nola.Irc.Connection.get_network(network, channel), - room = [visibility: :public, room_alias_name: localpart, name: if(network == "random", do: channel, else: "#{network}/#{channel}")], + room = [ + visibility: :public, + room_alias_name: localpart, + name: if(network == "random", do: channel, else: "#{network}/#{channel}") + ], {:ok, %{"room_id" => room_id}} <- Client.Room.create_room(client(), room) do Logger.info("Matrix: created room #{room_alias} #{room_id}") :dets.insert(dets(:rooms), {room_id, network, channel, %{}}) @@ -110,14 +121,15 @@ defmodule Nola.Matrix do end def localpart(room_alias) do - [<<"#", localpart :: binary>>, _] = String.split(room_alias, ":", parts: 2) + [<<"#", localpart::binary>>, _] = String.split(room_alias, ":", parts: 2) localpart end def extract_network_channel_from_localpart(localpart) do - s = localpart - |> String.replace("dev.", "") - |> String.split("/", parts: 2) + s = + localpart + |> String.replace("dev.", "") + |> String.split("/", parts: 2) case s do [network, channel] -> {:ok, network, channel} @@ -132,38 +144,42 @@ defmodule Nola.Matrix do {:ok, room_id} -> Nola.Matrix.Room.start(room_id) :ok - error -> error + + error -> + error end end @impl MatrixAppService.Adapter.Transaction def new_event(event = %MatrixAppService.Event{}) do - Logger.debug("New matrix event: #{inspect event}") + Logger.debug("New matrix event: #{inspect(event)}") + if event.room_id do Nola.Matrix.Room.start_and_send_matrix_event(event.room_id, event) end + :noop end @impl MatrixAppService.Adapter.User def query_user(user_id) do - Logger.warn("Matrix lookup user: #{inspect user_id}") + Logger.warn("Matrix lookup user: #{inspect(user_id)}") :error end def client(opts \\ []) do base_url = Application.get_env(:matrix_app_service, :base_url) access_token = Application.get_env(:matrix_app_service, :access_token) + default_opts = [ access_token: access_token, device_id: "APP_SERVICE", application_service: true, user_id: nil ] + opts = Keyword.merge(default_opts, opts) Polyjuice.Client.LowLevel.create(base_url, opts) end - - end diff --git a/lib/matrix/plug.ex b/lib/matrix/plug.ex index c64ed11..e9be4ad 100644 --- a/lib/matrix/plug.ex +++ b/lib/matrix/plug.ex @@ -1,11 +1,10 @@ defmodule Nola.Matrix.Plug do - defmodule Auth do - def init(state) do + def init(state) do state end - def call(conn, _) do + def call(conn, _) do hs = Application.get_env(:matrix_app_service, :homeserver_token) MatrixAppServiceWeb.AuthPlug.call(conn, hs) end @@ -21,5 +20,4 @@ defmodule Nola.Matrix.Plug do MatrixAppServiceWeb.SetConfigPlug.call(conn, config) end end - end diff --git a/lib/matrix/room.ex b/lib/matrix/room.ex index e2965a5..b3921f6 100644 --- a/lib/matrix/room.ex +++ b/lib/matrix/room.ex @@ -12,7 +12,12 @@ defmodule Nola.Matrix.Room do end def start_child(room_id) do - spec = %{id: room_id, start: {Nola.Matrix.Room, :start_link, [room_id]}, restart: :transient} + spec = %{ + id: room_id, + start: {Nola.Matrix.Room, :start_link, [room_id]}, + restart: :transient + } + DynamicSupervisor.start_child(__MODULE__, spec) end @@ -35,20 +40,23 @@ defmodule Nola.Matrix.Room do 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 + 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 - 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 @@ -65,45 +73,62 @@ defmodule Nola.Matrix.Room do Logger.metadata(matrix_room: room_id) {:ok, _} = Registry.register(Nola.PubSub, "#{state.network}:events", plugin: __MODULE__) + for t <- ["messages", "triggers", "outputs", "events"] do - {:ok, _} = Registry.register(Nola.PubSub, "#{state.network}/#{state.channel}:#{t}", plugin: __MODULE__) + {:ok, _} = + Registry.register(Nola.PubSub, "#{state.network}/#{state.channel}:#{t}", + plugin: __MODULE__ + ) end - state = state - |> Map.put(:id, room_id) + 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}") + 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] + + members = + Enum.reduce(s, [], fn s, acc -> + if s["type"] == "m.room.member" do + if s["content"]["membership"] == "join" do + [s["user_id"] | acc] + else + # XXX: The user left, remove from Nola.Memberships ? + acc + end else - # XXX: The user left, remove from Nola.Memberships ? acc end - else - acc - end - end) - |> Enum.filter(& &1) - - for m <- members, do: Nola.UserTrack.joined(state.id, %{network: "matrix", nick: m, user: m, host: "matrix."}, [], true) - - accounts = Nola.UserTrack.channel(state.network, state.channel) - |> Enum.filter(& &1) - |> Enum.map(fn(tuple) -> Nola.UserTrack.User.from_tuple(tuple).account end) - |> Enum.uniq() - |> Enum.each(fn(account_id) -> - introduce_irc_account(account_id, state) - end) + end) + |> Enum.filter(& &1) + + for m <- members, + do: + Nola.UserTrack.joined( + state.id, + %{network: "matrix", nick: m, user: m, host: "matrix."}, + [], + true + ) + + accounts = + Nola.UserTrack.channel(state.network, state.channel) + |> Enum.filter(& &1) + |> Enum.map(fn tuple -> Nola.UserTrack.User.from_tuple(tuple).account end) + |> Enum.uniq() + |> Enum.each(fn account_id -> + introduce_irc_account(account_id, state) + end) {:noreply, state} end @@ -112,6 +137,7 @@ defmodule Nola.Matrix.Room do 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} @@ -122,14 +148,17 @@ defmodule Nola.Matrix.Room do def handle_irc(message = %Nola.Message{account: account}, state) do unless Map.get(message.meta, :puppet) && Map.get(message.meta, :from) == self() do - opts = if Map.get(message.meta, :self) || is_nil(account) do - [] - else - mxid = Matrix.get_or_create_matrix_user(account.id) - [user_id: mxid] - end - Client.Room.send_message(client(opts),state.id, message.text) + 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 @@ -138,36 +167,56 @@ defmodule Nola.Matrix.Room do {:noreply, state} end - def handle_irc(%{type: quit_or_part, account_id: account_id}, state) when quit_or_part in [:quit, :part] do + 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}") + 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 + def handle_matrix( + event = %{type: "m.room.member", user_id: user_id, content: %{"membership" => "join"}}, + state + ) do _account = get_account(event, state) - Nola.UserTrack.joined(state.id, %{network: "matrix", nick: user_id, user: user_id, host: "matrix."}, [], true) + + Nola.UserTrack.joined( + state.id, + %{network: "matrix", nick: user_id, user: user_id, host: "matrix."}, + [], + true + ) + {:noreply, state} end - def handle_matrix(event = %{type: "m.room.member", user_id: user_id, content: %{"membership" => "leave"}}, state) do + def handle_matrix( + event = %{type: "m.room.member", user_id: user_id, content: %{"membership" => "leave"}}, + state + ) do Nola.UserTrack.parted(state.id, %{network: "matrix", nick: user_id}) {:noreply, state} end - def handle_matrix(event = %{type: "m.room.message", user_id: user_id, content: %{"msgtype" => "m.text", "body" => text}}, state) do + def handle_matrix( + event = %{ + type: "m.room.message", + user_id: user_id, + content: %{"msgtype" => "m.text", "body" => text} + }, + state + ) do Nola.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}") + Logger.warn("Skipped matrix event #{inspect(event)}") {:noreply, state} end @@ -180,17 +229,23 @@ defmodule Nola.Matrix.Room do account = Nola.Account.get(account_id) user = Nola.UserTrack.find_by_account(state.network, account) base_nick = if(user, do: user.nick, else: account.name) + case Client.Profile.put_displayname(client(user_id: mxid), base_nick) do - :ok -> :ok + :ok -> + :ok + error -> - Logger.warn("Failed to update profile for #{mxid}: #{inspect 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 + {:ok, _} -> + :ok + error -> - Logger.warn("Failed to join room for #{mxid}: #{inspect error}") + Logger.warn("Failed to join room for #{mxid}: #{inspect(error)}") end + :ok end - end diff --git a/lib/nola.ex b/lib/nola.ex index 51c2150..a9c7287 100644 --- a/lib/nola.ex +++ b/lib/nola.ex @@ -1,5 +1,4 @@ defmodule Nola do - @default_brand [ name: "Nola", source_url: "https://phab.random.sh/source/Nola/", @@ -26,5 +25,4 @@ defmodule Nola do def version do Application.spec(:nola)[:vsn] end - end diff --git a/lib/nola/account.ex b/lib/nola/account.ex index 70e9e40..d850a82 100644 --- a/lib/nola/account.ex +++ b/lib/nola/account.ex @@ -44,7 +44,7 @@ defmodule Nola.Account do end def start_link() do - GenServer.start_link(__MODULE__, [], [name: __MODULE__]) + GenServer.start_link(__MODULE__, [], name: __MODULE__) end def init(_) do @@ -63,6 +63,7 @@ defmodule Nola.Account do def get_by_name(name) do spec = [{{:_, :"$1", :_}, [{:==, :"$1", {:const, name}}], [:"$_"]}] + case :dets.select(file("db"), spec) do [account] -> from_tuple(account) _ -> nil @@ -71,7 +72,7 @@ defmodule Nola.Account do def get_meta(%__MODULE__{id: id}, key, default \\ nil) do case :dets.lookup(file("meta"), {id, key}) do - [{_, value}] -> (value || default) + [{_, value}] -> value || default _ -> default end end @@ -86,8 +87,12 @@ defmodule Nola.Account do @doc "Find an account given a specific meta `key` and `value`." @spec find_meta_account(String.t(), String.t()) :: t() | nil def find_meta_account(key, value) do - #spec = [{{{:"$1", :"$2"}, :"$3"}, [:andalso, {:==, :"$2", {:const, key}}, {:==, :"$3", {:const, value}}], [:"$1"]}] - spec = [{{{:"$1", :"$2"}, :"$3"}, [{:andalso, {:==, :"$2", {:const, key}}, {:==, {:const, value}, :"$3"}}], [:"$1"]}] + # spec = [{{{:"$1", :"$2"}, :"$3"}, [:andalso, {:==, :"$2", {:const, key}}, {:==, :"$3", {:const, value}}], [:"$1"]}] + spec = [ + {{{:"$1", :"$2"}, :"$3"}, + [{:andalso, {:==, :"$2", {:const, key}}, {:==, {:const, value}, :"$3"}}], [:"$1"]} + ] + case :dets.select(file("meta"), spec) do [id] -> get(id) _ -> nil @@ -100,7 +105,7 @@ defmodule Nola.Account do end def put_user_meta(account = %__MODULE__{}, key, value) do - put_meta(account, "u:"<>key, value) + put_meta(account, "u:" <> key, value) end def put_meta(%__MODULE__{id: id}, key, value) do @@ -112,15 +117,15 @@ defmodule Nola.Account do end def all_accounts() do - :dets.traverse(file("db"), fn(obj) -> {:continue, from_tuple(obj)} end) + :dets.traverse(file("db"), fn obj -> {:continue, from_tuple(obj)} end) end def all_predicates() do - :dets.traverse(file("predicates"), fn(obj) -> {:continue, obj} end) + :dets.traverse(file("predicates"), fn obj -> {:continue, obj} end) end def all_meta() do - :dets.traverse(file("meta"), fn(obj) -> {:continue, obj} end) + :dets.traverse(file("meta"), fn obj -> {:continue, obj} end) end def merge_account(old_id, new_id) do @@ -130,16 +135,19 @@ defmodule Nola.Account do for pred <- predicates, do: :ok = :dets.insert(file("predicates"), {pred, new_id}) spec = [{{{:"$1", :"$2"}, :"$3"}, [{:==, :"$1", {:const, old_id}}], [{{:"$2", :"$3"}}]}] metas = :dets.select(file("meta"), spec) - for {k,v} <- metas do + + for {k, v} <- metas do :dets.delete(file("meta"), {{old_id, k}}) :ok = :dets.insert(file("meta"), {{new_id, k}, v}) end + :dets.delete(file("db"), old_id) Nola.Membership.merge_account(old_id, new_id) Nola.UserTrack.merge_account(old_id, new_id) Nola.Irc.Connection.dispatch("account", {:account_change, old_id, new_id}) Nola.Irc.Connection.dispatch("conn", {:account_change, old_id, new_id}) end + :ok end @@ -150,16 +158,15 @@ defmodule Nola.Account do @doc "Always find an account by nickname, even if offline. Uses predicates and then account name." def find_always_by_nick(network, chan, nick) do - with \ - nil <- find_by_nick(network, nick), + with nil <- find_by_nick(network, nick), nil <- do_lookup(%User{network: network, nick: nick}, false), - nil <- get_by_name(nick) - do + nil <- get_by_name(nick) do nil else %__MODULE__{} = account -> memberships = Nola.Membership.of_account(account) - if Enum.any?(memberships, fn({net, ch}) -> (net == network) or (chan && chan == ch) end) do + + if Enum.any?(memberships, fn {net, ch} -> net == network or (chan && chan == ch) end) do account else nil @@ -173,9 +180,14 @@ defmodule Nola.Account do def lookup(something, make_default \\ true) do account = do_lookup(something, make_default) + if account && Map.get(something, :nick) do - Nola.Irc.Connection.dispatch("account", {:account_auth, Map.get(something, :nick), account.id}) + Nola.Irc.Connection.dispatch( + "account", + {:account_auth, Map.get(something, :nick), account.id} + ) end + account end @@ -198,7 +210,8 @@ defmodule Nola.Account do end end - defp do_lookup(message = %Nola.Message{account: account_id}, make_default) when is_binary(account_id) do + defp do_lookup(message = %Nola.Message{account: account_id}, make_default) + when is_binary(account_id) do get(account_id) end @@ -206,8 +219,12 @@ defmodule Nola.Account do if user = Nola.UserTrack.find_by_nick(sender) do lookup(user, make_default) else - #FIXME this will never work with continued lookup by other methods as Who isn't compatible - lookup_by_nick(sender, :dets.lookup(file("predicates"), {sender.network,{:nick, sender.nick}}), make_default) + # FIXME this will never work with continued lookup by other methods as Who isn't compatible + lookup_by_nick( + sender, + :dets.lookup(file("predicates"), {sender.network, {:nick, sender.nick}}), + make_default + ) end end @@ -220,7 +237,7 @@ defmodule Nola.Account do end defp do_lookup(user = %User{network: server, nick: nick}, make_default) do - lookup_by_nick(user, :dets.lookup(file("predicates"), {server,{:nick, nick}}), make_default) + lookup_by_nick(user, :dets.lookup(file("predicates"), {server, {:nick, nick}}), make_default) end defp do_lookup(nil, _) do @@ -232,7 +249,7 @@ defmodule Nola.Account do end defp lookup_by_nick(user, _, make_default) do - #authenticate_by_host(user) + # authenticate_by_host(user) if make_default, do: new_account(user), else: nil end @@ -259,5 +276,4 @@ defmodule Nola.Account do spec = [{{:"$1", :"$2"}, [{:==, :"$2", {:const, account.id}}], [:"$1"]}] :dets.select(file("predicates"), spec) end - end diff --git a/lib/nola/application.ex b/lib/nola/application.ex index d56d4cb..73cc80b 100644 --- a/lib/nola/application.ex +++ b/lib/nola/application.ex @@ -4,39 +4,42 @@ defmodule Nola.Application do def start(_type, _args) do import Supervisor.Spec - Logger.add_backend(Sentry.LoggerBackend) - Nola.Plugins.setup() :ok = Nola.Matrix.setup() :ok = Nola.TelegramRoom.setup() # Define workers and child supervisors to be supervised - children = [ - supervisor(NolaWeb.Endpoint, []), - 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(Registry, [[keys: :duplicate, name: Nola.PubSub]], id: :registry_nola_pubsub), - worker(Nola.Membership, []), - worker(Nola.Account, []), - worker(Nola.UserTrack.Storage, []), - worker(Nola.Plugins.Account, []), - supervisor(Nola.Plugins.Supervisor, [], [name: Nola.Plugins.Supervisor]), - ] ++ Nola.Irc.application_childs - ++ Nola.Matrix.application_childs + children = + [ + supervisor(NolaWeb.Endpoint, []), + 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(Registry, [[keys: :duplicate, name: Nola.PubSub]], id: :registry_nola_pubsub), + worker(Nola.Membership, []), + worker(Nola.Account, []), + worker(Nola.UserTrack.Storage, []), + worker(Nola.Plugins.Account, []), + worker(Nola.Plugins.Link.Store, []), + supervisor(Nola.Plugins.Supervisor, [], name: Nola.Plugins.Supervisor) + ] ++ + Nola.Irc.application_childs() ++ + Nola.Matrix.application_childs() opts = [strategy: :one_for_one, name: Nola.Supervisor] sup = Supervisor.start_link(children, opts) - + start_telegram() Nola.Plugins.start_all() - spawn_link(fn() -> Nola.Irc.after_start() end) - spawn_link(fn() -> Nola.Matrix.after_start() end) - spawn_link(fn() -> Nola.TelegramRoom.after_start() end) + 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 @@ -47,11 +50,12 @@ defmodule Nola.Application do 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/nola/auth_token.ex b/lib/nola/auth_token.ex index 9760ec7..0da4aab 100644 --- a/lib/nola/auth_token.ex +++ b/lib/nola/auth_token.ex @@ -2,7 +2,7 @@ defmodule Nola.AuthToken do use GenServer def start_link() do - GenServer.start_link(__MODULE__, [], [name: __MODULE__]) + GenServer.start_link(__MODULE__, [], name: __MODULE__) end def lookup(id) do @@ -13,6 +13,7 @@ defmodule Nola.AuthToken do case new(account, perks) do {:ok, id} -> NolaWeb.Router.Helpers.login_path(NolaWeb.Endpoint, :token, id) + error -> error end @@ -22,6 +23,7 @@ defmodule Nola.AuthToken do case new(account, perks) do {:ok, id} -> NolaWeb.Router.Helpers.login_url(NolaWeb.Endpoint, :token, id) + error -> error end @@ -32,15 +34,14 @@ defmodule Nola.AuthToken do end def init(_) do - {:ok, Map.new} + {:ok, Map.new()} end def handle_call({:lookup, id}, _, state) do IO.inspect(state) - with \ - {account, date, perks} <- Map.get(state, id), - d when d > 0 <- DateTime.diff(date, DateTime.utc_now()) - do + + with {account, date, perks} <- Map.get(state, id), + d when d > 0 <- DateTime.diff(date, DateTime.utc_now()) do {:reply, {:ok, account, perks}, Map.delete(state, id)} else x -> @@ -51,9 +52,11 @@ defmodule Nola.AuthToken do def handle_call({:new, account, perks}, _, state) do id = Nola.UserTrack.Id.token() - expire = DateTime.utc_now() - |> DateTime.add(15*60, :second) + + expire = + DateTime.utc_now() + |> DateTime.add(15 * 60, :second) + {:reply, {:ok, id}, Map.put(state, id, {account, expire, perks})} end - end diff --git a/lib/nola/icecast.ex b/lib/nola/icecast.ex index 5a53192..021af0b 100644 --- a/lib/nola/icecast.ex +++ b/lib/nola/icecast.ex @@ -23,69 +23,82 @@ defmodule Nola.Icecast do end defp poll(state) do - state = case request(base_url(), :get) do - {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> - #update_json_stats(Jason.decode(body)) - stats = update_stats(body) - if state != stats do - Logger.info "Icecast Update: " <> inspect(stats) - Nola.IcecastAgent.update(stats) - Registry.dispatch(Nola.BroadcastRegistry, "icecast", fn ws -> - for {pid, _} <- ws, do: send(pid, {:icecast, stats}) - end) - stats - else + state = + case request(base_url(), :get) do + {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> + # update_json_stats(Jason.decode(body)) + stats = update_stats(body) + + if state != stats do + Logger.info("Icecast Update: " <> inspect(stats)) + Nola.IcecastAgent.update(stats) + + Registry.dispatch(Nola.BroadcastRegistry, "icecast", fn ws -> + for {pid, _} <- ws, do: send(pid, {:icecast, stats}) + end) + + stats + else + state + end + + error -> + Logger.error("Icecast HTTP Error: #{inspect(error)}") state - end - error -> - Logger.error "Icecast HTTP Error: #{inspect error}" - state - end + end + interval = Application.get_env(:nola, :icecast_poll_interval, 60_000) :timer.send_after(interval, :poll) state end defp update_stats(html) do - raw = Floki.find(html, "div.roundbox") - |> Enum.map(fn(html) -> - html = Floki.raw_html(html) - [{"h3", _, ["Mount Point /"<>mount]}] = Floki.find(html, "h3.mount") - stats = Floki.find(html, "tr") - |> Enum.map(fn({"tr", _, tds}) -> - [{"td", _, keys}, {"td", _, values}] = tds - key = List.first(keys) - value = List.first(values) - {key, value} + raw = + Floki.find(html, "div.roundbox") + |> Enum.map(fn html -> + html = Floki.raw_html(html) + [{"h3", _, ["Mount Point /" <> mount]}] = Floki.find(html, "h3.mount") + + stats = + Floki.find(html, "tr") + |> Enum.map(fn {"tr", _, tds} -> + [{"td", _, keys}, {"td", _, values}] = tds + key = List.first(keys) + value = List.first(values) + {key, value} + end) + |> Enum.into(Map.new()) + + {mount, stats} end) - |> Enum.into(Map.new) - {mount, stats} - end) - |> Enum.into(Map.new) + |> Enum.into(Map.new()) live? = if Map.get(raw["live"], "Content Type:", false), do: true, else: false - np = if live? do - raw["live"]["Currently playing:"] - else - raw["autodj"]["Currently playing:"] - end + + np = + if live? do + raw["live"]["Currently playing:"] + else + raw["autodj"]["Currently playing:"] + end genre = raw["live"]["Genre:"] || nil %{np: np || "", live: live? || false, genre: genre} end defp update_json_stats({:ok, body}) do - Logger.debug "JSON STATS: #{inspect body}" + Logger.debug("JSON STATS: #{inspect(body)}") end defp update_json_stats(error) do - Logger.error "Failed to decode JSON Stats: #{inspect error}" + Logger.error("Failed to decode JSON Stats: #{inspect(error)}") end defp request(uri, method, body \\ [], headers \\ []) do headers = [{"user-agent", "Nola-API[115ans.net, sys.115ans.net] href@random.sh"}] ++ headers options = @httpoison_opts - case :ok do #:fuse.ask(@fuse, :sync) do + # :fuse.ask(@fuse, :sync) do + case :ok do :ok -> run_request(method, uri, body, headers, options) :blown -> :blown end @@ -93,14 +106,18 @@ defmodule Nola.Icecast do # This is to work around hackney's behaviour of returning `{:error, :closed}` when a pool connection has been closed # (keep-alive expired). We just retry the request immediatly up to five times. - defp run_request(method, uri, body, headers, options), do: run_request(method, uri, body, headers, options, 0) + defp run_request(method, uri, body, headers, options), + do: run_request(method, uri, body, headers, options, 0) + defp run_request(method, uri, body, headers, options, retries) when retries < 4 do case HTTPoison.request(method, uri, body, headers, options) do {:error, :closed} -> run_request(method, uri, body, headers, options, retries + 1) other -> other end end - defp run_request(method, uri, body, headers, options, _exceeded_retries), do: {:error, :unavailable} + + defp run_request(method, uri, body, headers, options, _exceeded_retries), + do: {:error, :unavailable} # # -- URIs @@ -113,5 +130,4 @@ defmodule Nola.Icecast do defp base_url do "http://91.121.59.45:8089" end - end diff --git a/lib/nola/icecast_agent.ex b/lib/nola/icecast_agent.ex index 8a3a72b..563b372 100644 --- a/lib/nola/icecast_agent.ex +++ b/lib/nola/icecast_agent.ex @@ -6,12 +6,10 @@ defmodule Nola.IcecastAgent do end def update(stats) do - Agent.update(__MODULE__, fn(_old) -> stats end) + Agent.update(__MODULE__, fn _old -> stats end) end def get do - Agent.get(__MODULE__, fn(stats) -> stats end) + Agent.get(__MODULE__, fn stats -> stats end) end - end - diff --git a/lib/nola/membership.ex b/lib/nola/membership.ex index 1c7303b..182f44d 100644 --- a/lib/nola/membership.ex +++ b/lib/nola/membership.ex @@ -7,11 +7,11 @@ defmodule Nola.Membership do # Format: {key, last_seen} defp dets() do - to_charlist(Nola.data_path <> "/memberships.dets") + to_charlist(Nola.data_path() <> "/memberships.dets") end def start_link() do - GenServer.start_link(__MODULE__, [], [name: __MODULE__]) + GenServer.start_link(__MODULE__, [], name: __MODULE__) end def init(_) do @@ -25,9 +25,10 @@ defmodule Nola.Membership do end def merge_account(old_id, new_id) do - #iex(37)> :ets.fun2ms(fn({{old_id, _, _}, _}=obj) when old_id == "42" -> obj end) + # iex(37)> :ets.fun2ms(fn({{old_id, _, _}, _}=obj) when old_id == "42" -> obj end) spec = [{{{:"$1", :_, :_}, :_}, [{:==, :"$1", {:const, old_id}}], [:"$_"]}] - Util.ets_mutate_select_each(:dets, dets(), spec, fn(table, obj = {{_old, net, chan}, ts}) -> + + Util.ets_mutate_select_each(:dets, dets(), spec, fn table, obj = {{_old, net, chan}, ts} -> :dets.delete_object(table, obj) :dets.insert(table, {{new_id, net, chan}, ts}) end) @@ -36,6 +37,7 @@ defmodule Nola.Membership do def touch(%Nola.Account{id: id}, network, channel) do :dets.insert(dets(), {{id, network, channel}, NaiveDateTime.utc_now()}) end + def touch(account_id, network, channel) do if account = Nola.Account.get(account_id) do touch(account, network, channel) @@ -43,21 +45,28 @@ defmodule Nola.Membership do end def notify_channels(account, minutes \\ 30, last_active \\ true) do - not_before = NaiveDateTime.add(NaiveDateTime.utc_now(), (minutes*-60), :second) + not_before = NaiveDateTime.add(NaiveDateTime.utc_now(), minutes * -60, :second) spec = [{{{:"$1", :_, :_}, :_}, [{:==, :"$1", {:const, account.id}}], [:"$_"]}] - memberships = :dets.select(dets(), spec) - |> Enum.sort_by(fn({_, ts}) -> ts end, {:desc, NaiveDateTime}) - active_memberships = Enum.filter(memberships, fn({_, ts}) -> NaiveDateTime.compare(ts, not_before) == :gt end) + + memberships = + :dets.select(dets(), spec) + |> Enum.sort_by(fn {_, ts} -> ts end, {:desc, NaiveDateTime}) + + active_memberships = + Enum.filter(memberships, fn {_, ts} -> NaiveDateTime.compare(ts, not_before) == :gt end) + cond do active_memberships == [] && last_active -> case memberships do - [{{_, net, chan}, _}|_] -> [{net, chan}] + [{{_, net, chan}, _} | _] -> [{net, chan}] _ -> [] end + active_memberships == [] -> [] + true -> - Enum.map(active_memberships, fn({{_, net, chan}, _}) -> {net,chan} end) + Enum.map(active_memberships, fn {{_, net, chan}, _} -> {net, chan} end) end end @@ -78,16 +87,18 @@ defmodule Nola.Membership do end def members(network, channel) do - #iex(19)> :ets.fun2ms(fn({{id, net, chan}, ts}) when net == network and chan == channel and ts > min_seen -> id end) - limit = 0 # NaiveDateTime.add(NaiveDateTime.utc_now, 30*((24*-60)*60), :second) + # iex(19)> :ets.fun2ms(fn({{id, net, chan}, ts}) when net == network and chan == channel and ts > min_seen -> id end) + # NaiveDateTime.add(NaiveDateTime.utc_now, 30*((24*-60)*60), :second) + limit = 0 + spec = [ {{{:"$1", :"$2", :"$3"}, :"$4"}, [ - {:andalso, - {:andalso, {:==, :"$2", {:const, network}}, {:==, :"$3", {:const, channel}}}, + {:andalso, {:andalso, {:==, :"$2", {:const, network}}, {:==, :"$3", {:const, channel}}}, {:>, :"$4", {:const, limit}}} ], [:"$1"]} ] + :dets.select(dets(), spec) end @@ -122,8 +133,6 @@ defmodule Nola.Membership do {account, user, nick} end end - |> Enum.filter(fn(x) -> x end) + |> Enum.filter(fn x -> x end) end - - end diff --git a/lib/nola/message.ex b/lib/nola/message.ex index b4e76da..1819e77 100644 --- a/lib/nola/message.ex +++ b/lib/nola/message.ex @@ -9,15 +9,14 @@ defmodule Nola.Message do defstruct [ :id, :text, - {:transport, :irc}, - :network, - :account, - :sender, - :channel, - :trigger, - :replyfun, - :at, - {:meta, %{}} - ] - + {:transport, :irc}, + :network, + :account, + :sender, + :channel, + :trigger, + :replyfun, + :at, + {:meta, %{}} + ] end diff --git a/lib/nola/plugins.ex b/lib/nola/plugins.ex index ac94736..6bfcfec 100644 --- a/lib/nola/plugins.ex +++ b/lib/nola/plugins.ex @@ -31,7 +31,7 @@ defmodule Nola.Plugins do Nola.Plugins.Untappd, Nola.Plugins.UserMention, Nola.Plugins.WolframAlpha, - Nola.Plugins.YouTube, + Nola.Plugins.YouTube ] defmodule Supervisor do @@ -44,13 +44,23 @@ defmodule Nola.Plugins do def start_child(module, opts \\ []) do Logger.info("Starting #{module}") - spec = %{id: {Nola.Plugins,module}, start: {Nola.Plugins, :start_link, [module, opts]}, name: module, restart: :transient} + + spec = %{ + id: {Nola.Plugins, module}, + start: {Nola.Plugins, :start_link, [module, opts]}, + name: module, + restart: :transient + } + case DynamicSupervisor.start_child(__MODULE__, spec) do - {:ok, _} = res -> res + {:ok, _} = res -> + res + :ignore -> Logger.warn("Ignored #{module}") :ignore - {:error,_} = res -> + + {:error, _} = res -> Logger.error("Could not start #{module}: #{inspect(res, pretty: true)}") res end @@ -73,10 +83,14 @@ defmodule Nola.Plugins do end def enabled() do - :dets.foldl(fn - {name, true, _}, acc -> [name | acc] - _, acc -> acc - end, [], dets()) + :dets.foldl( + fn + {name, true, _}, acc -> [name | acc] + _, acc -> acc + end, + [], + dets() + ) end def start_all() do @@ -107,10 +121,12 @@ defmodule Nola.Plugins do @doc "Enables or disables a plugin" def switch(name, value) when is_boolean(value) do - last = case get(name) do - {:ok, last} -> last - _ -> nil - end + last = + case get(name) do + {:ok, last} -> last + _ -> nil + end + :dets.insert(dets(), {name, value, last}) end @@ -124,8 +140,7 @@ defmodule Nola.Plugins do def start_link(module, options \\ []) do with {:disabled, {_, true, last}} <- {:disabled, get(module)}, - {:throttled, false} <- {:throttled, false} - do + {:throttled, false} <- {:throttled, false} do module.start_link() else {error, _} -> @@ -133,5 +148,4 @@ defmodule Nola.Plugins do :ignore end end - end diff --git a/lib/nola/subnet.ex b/lib/nola/subnet.ex index ac9d8e6..de469a6 100644 --- a/lib/nola/subnet.ex +++ b/lib/nola/subnet.ex @@ -17,22 +17,24 @@ defmodule Nola.Subnet do end def assign(binary) when is_binary(binary) do - result = if subnet = find_subnet_for(binary) do - {:ok, subnet} - else - Agent.get_and_update(__MODULE__, fn(dets) -> - {subnet, _} = available_select(dets) - :dets.insert(dets, {subnet, binary}) - :dets.sync(dets) - {{:new, subnet}, dets} - end) - end + result = + if subnet = find_subnet_for(binary) do + {:ok, subnet} + else + Agent.get_and_update(__MODULE__, fn dets -> + {subnet, _} = available_select(dets) + :dets.insert(dets, {subnet, binary}) + :dets.sync(dets) + {{:new, subnet}, dets} + end) + end case result do {:new, subnet} -> ip = Pfx.host(subnet, 1) set_reverse(binary, ip) subnet + {:ok, subnet} -> subnet end @@ -49,16 +51,40 @@ defmodule Nola.Subnet do ip_fqdn = Pfx.dns_ptr(ip) ip_local = String.replace(ip_fqdn, ".#{ptr_zone}", "") rev? = String.ends_with?(value, ".users.goulag.org") + if rev? do {:ok, rev_zone} = PowerDNSex.show_zone("users.goulag.org") - rev_update? = Enum.any?(rev_zone.rrsets, fn(rr) -> rr.name == "#{ip_fqdn}." end) - record = %{name: "#{value}.", type: "AAAA", ttl: 8600, records: [%{content: ip, disabled: false}]} - if(rev_update?, do: PowerDNSex.update_record(rev_zone, record), else: PowerDNSex.create_record(rev_zone, record)) + rev_update? = Enum.any?(rev_zone.rrsets, fn rr -> rr.name == "#{ip_fqdn}." end) + + record = %{ + name: "#{value}.", + type: "AAAA", + ttl: 8600, + records: [%{content: ip, disabled: false}] + } + + if(rev_update?, + do: PowerDNSex.update_record(rev_zone, record), + else: PowerDNSex.create_record(rev_zone, record) + ) end + {:ok, zone} = PowerDNSex.show_zone(ptr_zone) - update? = Enum.any?(zone.rrsets, fn(rr) -> rr.name == "#{ip_fqdn}." end) - record = %{name: "#{ip_fqdn}.", type: "PTR", ttl: 3600, records: [%{content: "#{value}.", disabled: false}]} - pdns = if(update?, do: PowerDNSex.update_record(zone, record), else: PowerDNSex.create_record(zone, record)) + update? = Enum.any?(zone.rrsets, fn rr -> rr.name == "#{ip_fqdn}." end) + + record = %{ + name: "#{ip_fqdn}.", + type: "PTR", + ttl: 3600, + records: [%{content: "#{value}.", disabled: false}] + } + + pdns = + if(update?, + do: PowerDNSex.update_record(zone, record), + else: PowerDNSex.create_record(zone, record) + ) + :ok end @@ -76,9 +102,10 @@ defmodule Nola.Subnet do defp available_select(dets) do spec = [{{:"$1", :"$2"}, [is_integer: :"$2"], [{{:"$1", :"$2"}}]}] {subnets, _} = :dets.select(dets, spec, 20) - subnet = subnets - |> Enum.sort_by(fn({_, last}) -> last end) - |> List.first() - end + subnet = + subnets + |> Enum.sort_by(fn {_, last} -> last end) + |> List.first() + end end diff --git a/lib/nola/token.ex b/lib/nola/token.ex index 179bed2..f4fdd86 100644 --- a/lib/nola/token.ex +++ b/lib/nola/token.ex @@ -2,15 +2,15 @@ defmodule Nola.Token do use GenServer def start_link() do - GenServer.start_link(__MODULE__, [], [name: __MODULE__]) + GenServer.start_link(__MODULE__, [], name: __MODULE__) end def lookup(id) do - with \ - [{_, cred, date}] <- :ets.lookup(__MODULE__.ETS, id), - IO.inspect("cred: #{inspect cred} valid for #{inspect date} now #{inspect DateTime.utc_now()}"), - d when d > 0 <- DateTime.diff(date, DateTime.utc_now()) - do + with [{_, cred, date}] <- :ets.lookup(__MODULE__.ETS, id), + IO.inspect( + "cred: #{inspect(cred)} valid for #{inspect(date)} now #{inspect(DateTime.utc_now())}" + ), + d when d > 0 <- DateTime.diff(date, DateTime.utc_now()) do {:ok, cred} else err -> {:error, err} @@ -22,17 +22,21 @@ defmodule Nola.Token do end def init(_) do - ets = :ets.new(__MODULE__.ETS, [:ordered_set, :named_table, :protected, {:read_concurrency, true}]) + ets = + :ets.new(__MODULE__.ETS, [:ordered_set, :named_table, :protected, {:read_concurrency, true}]) + {:ok, ets} end def handle_call({:new, cred}, _, ets) do id = Nola.UserTrack.Id.large_id() - expire = DateTime.utc_now() - |> DateTime.add(15*60, :second) + + expire = + DateTime.utc_now() + |> DateTime.add(15 * 60, :second) + obj = {id, cred, expire} :ets.insert(ets, obj) {:reply, {:ok, id}, ets} end - end diff --git a/lib/nola/trigger.ex b/lib/nola/trigger.ex index 1dec9ac..d3e791b 100644 --- a/lib/nola/trigger.ex +++ b/lib/nola/trigger.ex @@ -8,5 +8,4 @@ defmodule Nola.Trigger do :trigger, :args ] - end diff --git a/lib/nola/user_track.ex b/lib/nola/user_track.ex index c1218b0..0b07a91 100644 --- a/lib/nola/user_track.ex +++ b/lib/nola/user_track.ex @@ -8,23 +8,23 @@ defmodule Nola.UserTrack do # Privilege map: # %{"#channel" => [:operator, :voice] defmodule Storage do - def delete(id) do - op(fn(ets) -> :ets.delete(ets, id) end) + op(fn ets -> :ets.delete(ets, id) end) end def insert(tuple) do - op(fn(ets) -> :ets.insert(ets, tuple) end) + op(fn ets -> :ets.insert(ets, tuple) end) end def clear_network(network) do - op(fn(ets) -> + op(fn ets -> spec = [ {{:_, :"$1", :_, :_, :_, :_, :_, :_, :_, :_, :_, :_}, - [ - {:==, :"$1", {:const, network}} - ], [:"$_"]} + [ + {:==, :"$1", {:const, network}} + ], [:"$_"]} ] + :ets.match_delete(ets, spec) end) end @@ -34,7 +34,7 @@ defmodule Nola.UserTrack do end def start_link do - GenServer.start_link(__MODULE__, [], [name: __MODULE__]) + GenServer.start_link(__MODULE__, [], name: __MODULE__) end def init([]) do @@ -43,13 +43,15 @@ defmodule Nola.UserTrack do end def handle_call({:op, fun}, _from, ets) do - returned = try do - {:ok, fun.(ets)} - rescue - rescued -> {:error, rescued} - catch - rescued -> {:error, rescued} - end + returned = + try do + {:ok, fun.(ets)} + rescue + rescued -> {:error, rescued} + catch + rescued -> {:error, rescued} + end + {:reply, returned, ets} end @@ -58,31 +60,63 @@ defmodule Nola.UserTrack do end end - defmodule Id, do: use EntropyString + defmodule Id, do: use(EntropyString) defmodule User do - defstruct [:id, :account, :network, :nick, {:nicks, []}, :username, :host, :realname, {:privileges, %{}}, {:last_active, %{}}, {:options, %{}}] + defstruct [ + :id, + :account, + :network, + :nick, + {:nicks, []}, + :username, + :host, + :realname, + {:privileges, %{}}, + {:last_active, %{}}, + {:options, %{}} + ] def to_tuple(u = %__MODULE__{}) do - {u.id || Nola.UserTrack.Id.large_id, u.network, u.account, String.downcase(u.nick), u.nick, u.nicks || [], u.username, u.host, u.realname, u.privileges, u.last_active, u.options} + {u.id || Nola.UserTrack.Id.large_id(), u.network, u.account, String.downcase(u.nick), + u.nick, u.nicks || [], u.username, u.host, u.realname, u.privileges, u.last_active, + u.options} end - #tuple size: 11 - def from_tuple({id, network, account, _downcased_nick, nick, nicks, username, host, realname, privs, last_active, opts}) do - struct = %__MODULE__{id: id, account: account, network: network, nick: nick, nicks: nicks, username: username, host: host, realname: realname, privileges: privs, last_active: last_active, options: opts} + # tuple size: 11 + def from_tuple( + {id, network, account, _downcased_nick, nick, nicks, username, host, realname, privs, + last_active, opts} + ) do + struct = %__MODULE__{ + id: id, + account: account, + network: network, + nick: nick, + nicks: nicks, + username: username, + host: host, + realname: realname, + privileges: privs, + last_active: last_active, + options: opts + } end end def find_by_account(%Nola.Account{id: id}) do - #iex(15)> :ets.fun2ms(fn(obj = {_, net, acct, _, _, _, _, _, _}) when net == network and acct == account -> obj end) + # iex(15)> :ets.fun2ms(fn(obj = {_, net, acct, _, _, _, _, _, _}) when net == network and acct == account -> obj end) spec = [ {{:_, :_, :"$2", :_, :_, :_, :_, :_, :_, :_, :_, :_}, [ - {:==, :"$2", {:const, id}} + {:==, :"$2", {:const, id}} ], [:"$_"]} ] - results = :ets.select(@ets, spec) - |> Enum.filter(& &1) + + results = + :ets.select(@ets, spec) + |> Enum.filter(& &1) + for obj <- results, do: User.from_tuple(obj) end @@ -91,27 +125,39 @@ defmodule Nola.UserTrack do end def find_by_account(network, %Nola.Account{id: id}) do - #iex(15)> :ets.fun2ms(fn(obj = {_, net, acct, _, _, _, _, _, _}) when net == network and acct == account -> obj end) + # iex(15)> :ets.fun2ms(fn(obj = {_, net, acct, _, _, _, _, _, _}) when net == network and acct == account -> obj end) spec = [ {{:_, :"$1", :"$2", :_, :_, :_, :_, :_, :_, :_, :_, :_}, [ - {:andalso, {:==, :"$1", {:const, network}}, - {:==, :"$2", {:const, id}}} + {:andalso, {:==, :"$1", {:const, network}}, {:==, :"$2", {:const, id}}} ], [:"$_"]} ] + case :ets.select(@ets, spec) do results = [_r | _] -> - result = results - |> Enum.reject(fn({_, net, _, _, _, _, _, _, _, _, actives, opts}) -> network != "matrix" && net == "matrix" end) - |> Enum.reject(fn({_, net, _, _, _, _, _, _, _, _, actives, opts}) -> network != "telegram" && net == "telegram" end) - |> Enum.reject(fn({_, _, _, _, _, _, _, _, _, _, actives, opts}) -> network not in ["matrix", "telegram"] && Map.get(opts, :puppet) end) - |> Enum.sort_by(fn({_, _, _, _, _, _, _, _, _, _, actives, _}) -> - Map.get(actives, nil) - end, {:desc, NaiveDateTime}) - |> List.first + result = + results + |> Enum.reject(fn {_, net, _, _, _, _, _, _, _, _, actives, opts} -> + network != "matrix" && net == "matrix" + end) + |> Enum.reject(fn {_, net, _, _, _, _, _, _, _, _, actives, opts} -> + network != "telegram" && net == "telegram" + end) + |> Enum.reject(fn {_, _, _, _, _, _, _, _, _, _, actives, opts} -> + network not in ["matrix", "telegram"] && Map.get(opts, :puppet) + end) + |> Enum.sort_by( + fn {_, _, _, _, _, _, _, _, _, _, actives, _} -> + Map.get(actives, nil) + end, + {:desc, NaiveDateTime} + ) + |> List.first() if result, do: User.from_tuple(result) - _ -> nil + + _ -> + nil end end @@ -119,18 +165,23 @@ defmodule Nola.UserTrack do Storage.clear_network(network) end - def merge_account(old_id, new_id) do - #iex(15)> :ets.fun2ms(fn(obj = {_, net, acct, _, _, _, _, _, _}) when net == network and acct == account -> obj end) + # iex(15)> :ets.fun2ms(fn(obj = {_, net, acct, _, _, _, _, _, _}) when net == network and acct == account -> obj end) spec = [ {{:_, :_, :"$1", :_, :_, :_, :_, :_, :_, :_, :_, :_}, [ - {:==, :"$1", {:const, old_id}} + {:==, :"$1", {:const, old_id}} ], [:"$_"]} ] - Enum.each(:ets.select(@ets, spec), fn({id, net, _, downcased_nick, nick, nicks, username, host, realname, privs, active, opts}) -> - Storage.op(fn(ets) -> - :ets.insert(@ets, {id, net, new_id, downcased_nick, nick, nicks, username, host, realname, privs, active, opts}) + + Enum.each(:ets.select(@ets, spec), fn {id, net, _, downcased_nick, nick, nicks, username, + host, realname, privs, active, opts} -> + Storage.op(fn ets -> + :ets.insert( + @ets, + {id, net, new_id, downcased_nick, nick, nicks, username, host, realname, privs, active, + opts} + ) end) end) end @@ -139,14 +190,18 @@ defmodule Nola.UserTrack do find_by_nick(network, nick) end - def find_by_nick(%ExIRC.SenderInfo{network: network, nick: nick}) do find_by_nick(network, nick) end def find_by_nick(network, nick) do - case :ets.match(@ets, {:"$1", network, :_, String.downcase(nick), :_, :_, :_, :_, :_, :_, :_, :_}) do - [[id] | _] -> lookup(id) + case :ets.match( + @ets, + {:"$1", network, :_, String.downcase(nick), :_, :_, :_, :_, :_, :_, :_, :_} + ) do + [[id] | _] -> + lookup(id) + _ -> nil end @@ -171,7 +226,7 @@ defmodule Nola.UserTrack do end def channel(network, channel) do - Enum.filter(to_list(), fn({_, network, _, _, _, _, _, _, _, channels, _, _}) -> + Enum.filter(to_list(), fn {_, network, _, _, _, _, _, _, _, channels, _, _} -> Map.get(channels, channel) end) end @@ -179,53 +234,92 @@ defmodule Nola.UserTrack do # TODO def connected(network, nick, user, host, account_id, opts \\ %{}) do if account = Nola.Account.get(account_id) do - user = if user = find_by_nick(network, nick) do - user - else - user = %User{id: Nola.UserTrack.Id.large_id, account: account_id, network: network, nick: nick, username: user, host: host, privileges: %{}, options: opts} - Storage.op(fn(ets) -> - :ets.insert(ets, User.to_tuple(user)) - end) - user - end + user = + if user = find_by_nick(network, nick) do + user + else + user = %User{ + id: Nola.UserTrack.Id.large_id(), + account: account_id, + network: network, + nick: nick, + username: user, + host: host, + privileges: %{}, + options: opts + } + + Storage.op(fn ets -> + :ets.insert(ets, User.to_tuple(user)) + end) + + user + end + + Nola.Irc.Connection.publish_event(network, %{ + type: :connect, + user_id: user.id, + account_id: user.account + }) - Nola.Irc.Connection.publish_event(network, %{type: :connect, user_id: user.id, account_id: user.account}) :ok else :error end end - def joined(c, s), do: joined(c,s,[]) + def joined(c, s), do: joined(c, s, []) - def joined(channel, sender=%{nick: nick, user: uname, host: host}, privileges, touch \\ true) do - privileges = if Nola.Irc.admin?(sender) do - privileges ++ [:admin] - else privileges end - user = if user = find_by_nick(sender.network, nick) do - %User{user | username: uname, host: host, privileges: Map.put(user.privileges || %{}, channel, privileges)} - else - user = %User{id: Nola.UserTrack.Id.large_id, network: sender.network, nick: nick, username: uname, host: host, privileges: %{channel => privileges}} + def joined(channel, sender = %{nick: nick, user: uname, host: host}, privileges, touch \\ true) do + privileges = + if Nola.Irc.admin?(sender) do + privileges ++ [:admin] + else + privileges + end + + user = + if user = find_by_nick(sender.network, nick) do + %User{ + user + | username: uname, + host: host, + privileges: Map.put(user.privileges || %{}, channel, privileges) + } + else + user = %User{ + id: Nola.UserTrack.Id.large_id(), + network: sender.network, + nick: nick, + username: uname, + host: host, + privileges: %{channel => privileges} + } + + account = Nola.Account.lookup(user).id + user = %User{user | account: account} + end - account = Nola.Account.lookup(user).id - user = %User{user | account: account} - end user = touch_struct(user, channel) if touch && user.account do Nola.Membership.touch(user.account, sender.network, channel) end - Storage.op(fn(ets) -> + Storage.op(fn ets -> :ets.insert(ets, User.to_tuple(user)) end) - Nola.Irc.Connection.publish_event({sender.network, channel}, %{type: :join, user_id: user.id, account_id: user.account}) + Nola.Irc.Connection.publish_event({sender.network, channel}, %{ + type: :join, + user_id: user.id, + account_id: user.account + }) user end - #def joined(network, channel, nick, privileges) do + # def joined(network, channel, nick, privileges) do # user = if user = find_by_nick(network, nick) do # %User{user | privileges: Map.put(user.privileges, channel, privileges)} # else @@ -235,18 +329,24 @@ defmodule Nola.UserTrack do # Storage.op(fn(ets) -> # :ets.insert(ets, User.to_tuple(user)) # end) - #end + # end + + def messaged( + %Nola.Message{network: network, account: account, channel: chan, sender: %{nick: nick}} = + m + ) do + {user, account} = + if user = find_by_nick(network, nick) do + {touch_struct(user, chan), account || Nola.Account.lookup(user)} + else + user = %User{network: network, nick: nick, privileges: %{}} + account = Nola.Account.lookup(user) + {%User{user | account: account.id}, account} + end - def messaged(%Nola.Message{network: network, account: account, channel: chan, sender: %{nick: nick}} = m) do - {user, account} = if user = find_by_nick(network, nick) do - {touch_struct(user, chan), account || Nola.Account.lookup(user)} - else - user = %User{network: network, nick: nick, privileges: %{}} - account = Nola.Account.lookup(user) - {%User{user | account: account.id}, account} - end Storage.insert(User.to_tuple(user)) if chan, do: Nola.Membership.touch(account, network, chan) + if !m.account do {:ok, %Nola.Message{m | account: account}} else @@ -257,12 +357,19 @@ defmodule Nola.UserTrack do def renamed(network, old_nick, new_nick) do if user = find_by_nick(network, old_nick) do old_account = Nola.Account.lookup(user) - user = %User{user | nick: new_nick, nicks: [old_nick|user.nicks]} + user = %User{user | nick: new_nick, nicks: [old_nick | user.nicks]} account = Nola.Account.lookup(user, false) || old_account - user = %User{user | nick: new_nick, account: account.id, nicks: [old_nick|user.nicks]} + user = %User{user | nick: new_nick, account: account.id, nicks: [old_nick | user.nicks]} Storage.insert(User.to_tuple(user)) channels = for {channel, _} <- user.privileges, do: channel - Nola.Irc.Connection.publish_event(network, %{type: :nick, user_id: user.id, account_id: account.id, nick: new_nick, old_nick: old_nick}) + + Nola.Irc.Connection.publish_event(network, %{ + type: :nick, + user_id: user.id, + account_id: account.id, + nick: new_nick, + old_nick: old_nick + }) end end @@ -270,12 +377,19 @@ defmodule Nola.UserTrack do if user = find_by_nick(network, nick) do privs = Map.get(user.privileges, channel) - privs = Enum.reduce(add, privs, fn(priv, acc) -> [priv|acc] end) - privs = Enum.reduce(remove, privs, fn(priv, acc) -> List.delete(acc, priv) end) + privs = Enum.reduce(add, privs, fn priv, acc -> [priv | acc] end) + privs = Enum.reduce(remove, privs, fn priv, acc -> List.delete(acc, priv) end) user = %User{user | privileges: Map.put(user.privileges, channel, privs)} Storage.insert(User.to_tuple(user)) - Nola.Irc.Connection.publish_event({network, channel}, %{type: :privileges, user_id: user.id, account_id: user.account, added: add, removed: remove}) + + Nola.Irc.Connection.publish_event({network, channel}, %{ + type: :privileges, + user_id: user.id, + account_id: user.account, + added: add, + removed: remove + }) end end @@ -292,12 +406,25 @@ defmodule Nola.UserTrack do privs = Map.delete(user.privileges, channel) lasts = Map.delete(user.last_active, channel) + if Enum.count(privs) > 0 do user = %User{user | privileges: privs} Storage.insert(User.to_tuple(user)) - Nola.Irc.Connection.publish_event({network, channel}, %{type: :part, user_id: user.id, account_id: user.account, reason: nil}) + + Nola.Irc.Connection.publish_event({network, channel}, %{ + type: :part, + user_id: user.id, + account_id: user.account, + reason: nil + }) else - Nola.Irc.Connection.publish_event(network, %{type: :quit, user_id: user.id, account_id: user.account, reason: "Left all known channels"}) + Nola.Irc.Connection.publish_event(network, %{ + type: :quit, + user_id: user.id, + account_id: user.account, + reason: "Left all known channels" + }) + Storage.delete(user.id) end end @@ -309,17 +436,27 @@ defmodule Nola.UserTrack do for {channel, _} <- user.privileges do Nola.Membership.touch(user.account, sender.network, channel) end - Nola.Irc.Connection.publish_event(sender.network, %{type: :quit, user_id: user.id, account_id: user.account, reason: reason}) + + Nola.Irc.Connection.publish_event(sender.network, %{ + type: :quit, + user_id: user.id, + account_id: user.account, + reason: reason + }) end + Storage.delete(user.id) end end defp touch_struct(user = %User{last_active: last_active}, channel) do now = NaiveDateTime.utc_now() - last_active = last_active - |> Map.put(channel, now) - |> Map.put(nil, now) + + last_active = + last_active + |> Map.put(channel, now) + |> Map.put(nil, now) + %User{user | last_active: last_active} end diff --git a/lib/open_ai.ex b/lib/open_ai.ex index cc0de27..2b8783f 100644 --- a/lib/open_ai.ex +++ b/lib/open_ai.ex @@ -1,20 +1,40 @@ defmodule OpenAi do + require Logger def post(path, data, options \\ []) do config = Application.get_env(:nola, :openai, []) - url = "https://api.openai.com#{path}" - headers = [{"user-agent", "internal private experiment bot, href@random.sh"}, - {"content-type", "application/json"}, - {"authorization", "Bearer " <> Keyword.get(config, :key, "unset-api-key")}] - options = options ++ [timeout: :timer.seconds(180), recv_timeout: :timer.seconds(180)] + base_url = Keyword.get(config, :base_url, "https://api.openai.com") + url = "#{base_url}#{path}" + + headers = [ + {"user-agent", "internal private experiment bot, href@random.sh"}, + {"content-type", "application/json"}, + {"authorization", "Bearer " <> Keyword.get(config, :key, "unset-api-key")} + ] + + options = options ++ [timeout: :timer.seconds(30), recv_timeout: :timer.seconds(30)] + Logger.debug("openai: post: #{url} #{inspect(data)}") + with {:ok, json} <- Poison.encode(data), - {:ok, %HTTPoison.Response{status_code: 200, body: body}} <- HTTPoison.post(url, json, headers, options), + {:ok, %HTTPoison.Response{status_code: 200, body: body}} <- + HTTPoison.post(url, json, headers, options), {:ok, data} <- Poison.decode(body) do {:ok, data} else - {:ok, %HTTPoison.Response{status_code: code}} -> {:error, Plug.Conn.Status.reason_atom(code)} - {:error, %HTTPoison.Error{reason: reason}} -> {:error, reason} + {:ok, %HTTPoison.Response{status_code: code, body: body}} -> + Logger.error("OpenAI: HTTP #{code} #{inspect(body)}") + status = Plug.Conn.Status.reason_atom(code) + + case Poison.decode(body) do + {:ok, %{"error" => %{"message" => message, "code" => code}}} -> + {:error, {status, message}} + + kek -> + {:error, status} + end + + {:error, %HTTPoison.Error{reason: reason}} -> + {:error, reason} end end - end diff --git a/lib/plugins/account.ex b/lib/plugins/account.ex index 0377e1c..2977f4b 100644 --- a/lib/plugins/account.ex +++ b/lib/plugins/account.ex @@ -1,187 +1,219 @@ defmodule Nola.Plugins.Account do - @moduledoc """ - # Account - - * **account** Get current account id and token - * **auth `<account-id>` `<token>`** Authenticate and link the current nickname to an account - * **auth** list authentications methods - * **whoami** list currently authenticated users - * **web** get a one-time login link to web - * **enable-telegram** Link a Telegram account - * **enable-sms** Link a SMS number - * **enable-untappd** Link a Untappd account - * **set-name** set account name - * **setusermeta puppet-nick `<nick>`** Set puppet IRC nickname - """ - - def irc_doc, do: @moduledoc - def start_link(), do: GenServer.start_link(__MODULE__, [], name: __MODULE__) - def init(_) do - {:ok, _} = Registry.register(Nola.PubSub, "messages:private", []) - {:ok, nil} - end + @moduledoc """ + # Account + + * **account** Get current account id and token + * **auth `<account-id>` `<token>`** Authenticate and link the current nickname to an account + * **auth** list authentications methods + * **whoami** list currently authenticated users + * **web** get a one-time login link to web + * **enable-telegram** Link a Telegram account + * **enable-sms** Link a SMS number + * **enable-untappd** Link a Untappd account + * **set-name** set account name + * **setusermeta puppet-nick `<nick>`** Set puppet IRC nickname + """ + + def irc_doc, do: @moduledoc + def start_link(), do: GenServer.start_link(__MODULE__, [], name: __MODULE__) + + def init(_) do + {:ok, _} = Registry.register(Nola.PubSub, "messages:private", []) + {:ok, nil} + end - def handle_info({:irc, :text, m = %Nola.Message{account: account, text: "help"}}, state) do - text = [ - "account: show current account and auth token", - "auth: show authentications methods", - "whoami: list authenticated users", - "set-name <name>: set account name", - "web: login to web", - "enable-sms | disable-sms: enable/change or disable sms", - "enable-telegram: link/change telegram", - "enable-untappd: link untappd account", - "getmeta: show meta datas", - "setusermeta: set user meta", - ] - m.replyfun.(text) - {:noreply, state} - end + def handle_info({:irc, :text, m = %Nola.Message{account: account, text: "help"}}, state) do + text = [ + "account: show current account and auth token", + "auth: show authentications methods", + "whoami: list authenticated users", + "set-name <name>: set account name", + "web: login to web", + "enable-sms | disable-sms: enable/change or disable sms", + "enable-telegram: link/change telegram", + "enable-untappd: link untappd account", + "getmeta: show meta datas", + "setusermeta: set user meta" + ] + + m.replyfun.(text) + {:noreply, state} + end - def handle_info({:irc, :text, m = %Nola.Message{account: account, text: "auth"}}, state) do - spec = [{{:"$1", :"$2"}, [{:==, :"$2", {:const, account.id}}], [:"$1"]}] - predicates = :dets.select(Nola.Account.file("predicates"), spec) - text = for {net, {key, value}} <- predicates, do: "#{net}: #{to_string(key)}: #{value}" - m.replyfun.(text) - {:noreply, state} - end + def handle_info({:irc, :text, m = %Nola.Message{account: account, text: "auth"}}, state) do + spec = [{{:"$1", :"$2"}, [{:==, :"$2", {:const, account.id}}], [:"$1"]}] + predicates = :dets.select(Nola.Account.file("predicates"), spec) + text = for {net, {key, value}} <- predicates, do: "#{net}: #{to_string(key)}: #{value}" + m.replyfun.(text) + {:noreply, state} + end + + def handle_info({:irc, :text, m = %Nola.Message{account: account, text: "whoami"}}, state) do + users = + for user <- Nola.UserTrack.find_by_account(m.account) do + chans = + Enum.map(user.privileges, fn {chan, _} -> chan end) + |> Enum.join(" ") - def handle_info({:irc, :text, m = %Nola.Message{account: account, text: "whoami"}}, state) do - users = for user <- Nola.UserTrack.find_by_account(m.account) do - chans = Enum.map(user.privileges, fn({chan, _}) -> chan end) - |> Enum.join(" ") "#{user.network} - #{user.nick}!#{user.username}@#{user.host} - #{chans}" end - m.replyfun.(users) - {:noreply, state} - end - def handle_info({:irc, :text, m = %Nola.Message{account: account, text: "account"}}, state) do - account = Nola.Account.lookup(m.sender) - text = ["Account Id: #{account.id}", - "Authenticate to this account from another network: \"auth #{account.id} #{account.token}\" to the other bot!"] - m.replyfun.(text) - {:noreply, state} - end + m.replyfun.(users) + {:noreply, state} + end - def handle_info({:irc, :text, m = %Nola.Message{sender: sender, text: "auth"<>_}}, state) do - #account = Nola.Account.lookup(m.sender) - case String.split(m.text, " ") do - ["auth", id, token] -> - join_account(m, id, token) - _ -> - m.replyfun.("Invalid parameters") - end - {:noreply, state} - end + def handle_info({:irc, :text, m = %Nola.Message{account: account, text: "account"}}, state) do + account = Nola.Account.lookup(m.sender) - def handle_info({:irc, :text, m = %Nola.Message{account: account, text: "set-name "<>name}}, state) do - Nola.Account.update_account_name(account, name) - m.replyfun.("Name changed: #{name}") - {:noreply, state} - end + text = [ + "Account Id: #{account.id}", + "Authenticate to this account from another network: \"auth #{account.id} #{account.token}\" to the other bot!" + ] - def handle_info({:irc, :text, m = %Nola.Message{text: "disable-sms"}}, state) do - if Nola.Account.get_meta(m.account, "sms-number") do - Nola.Account.delete_meta(m.account, "sms-number") - m.replyfun.("SMS disabled.") - else - m.replyfun.("SMS already disabled.") - end - {:noreply, state} - end + m.replyfun.(text) + {:noreply, state} + end - def handle_info({:irc, :text, m = %Nola.Message{text: "web"}}, state) do - login_url = Nola.AuthToken.new_url(m.account.id, nil) - m.replyfun.("↪:" <> login_url) - {:noreply, state} - end + def handle_info({:irc, :text, m = %Nola.Message{sender: sender, text: "auth" <> _}}, state) do + # account = Nola.Account.lookup(m.sender) + case String.split(m.text, " ") do + ["auth", id, token] -> + join_account(m, id, token) - def handle_info({:irc, :text, m = %Nola.Message{text: "enable-sms"}}, state) do - code = String.downcase(EntropyString.small_id()) - Nola.Account.put_meta(m.account, "sms-validation-code", code) - Nola.Account.put_meta(m.account, "sms-validation-target", m.network) - number = Nola.Plugins.Sms.my_number() - text = "To enable or change your number for SMS messaging, please send:" - <> " \"enable #{code}\" to #{number}" - m.replyfun.(text) - {:noreply, state} + _ -> + m.replyfun.("Invalid parameters") end - def handle_info({:irc, :text, m = %Nola.Message{text: "enable-telegram"}}, state) do - code = String.downcase(EntropyString.small_id()) - Nola.Account.delete_meta(m.account, "telegram-id") - Nola.Account.put_meta(m.account, "telegram-validation-code", code) - Nola.Account.put_meta(m.account, "telegram-validation-target", m.network) - text = "To enable or change your number for telegram messaging, please open #{Nola.Telegram.my_path()} and send:" - <> " \"/enable #{code}\"" - m.replyfun.(text) - {:noreply, state} - end + {:noreply, state} + end + + def handle_info( + {:irc, :text, m = %Nola.Message{account: account, text: "set-name " <> name}}, + state + ) do + Nola.Account.update_account_name(account, name) + m.replyfun.("Name changed: #{name}") + {:noreply, state} + end - def handle_info({:irc, :text, m = %Nola.Message{text: "enable-untappd"}}, state) do - auth_url = Untappd.auth_url() - login_url = Nola.AuthToken.new_url(m.account.id, {:external_redirect, auth_url}) - m.replyfun.(["To link your Untappd account, open this URL:", login_url]) - {:noreply, state} + def handle_info({:irc, :text, m = %Nola.Message{text: "disable-sms"}}, state) do + if Nola.Account.get_meta(m.account, "sms-number") do + Nola.Account.delete_meta(m.account, "sms-number") + m.replyfun.("SMS disabled.") + else + m.replyfun.("SMS already disabled.") end - def handle_info({:irc, :text, m = %Nola.Message{text: "getmeta"<>_}}, state) do - result = case String.split(m.text, " ") do + {:noreply, state} + end + + def handle_info({:irc, :text, m = %Nola.Message{text: "web"}}, state) do + login_url = Nola.AuthToken.new_url(m.account.id, nil) + m.replyfun.("↪:" <> login_url) + {:noreply, state} + end + + def handle_info({:irc, :text, m = %Nola.Message{text: "enable-sms"}}, state) do + code = String.downcase(EntropyString.small_id()) + Nola.Account.put_meta(m.account, "sms-validation-code", code) + Nola.Account.put_meta(m.account, "sms-validation-target", m.network) + number = Nola.Plugins.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 = %Nola.Message{text: "enable-telegram"}}, state) do + code = String.downcase(EntropyString.small_id()) + Nola.Account.delete_meta(m.account, "telegram-id") + Nola.Account.put_meta(m.account, "telegram-validation-code", code) + Nola.Account.put_meta(m.account, "telegram-validation-target", m.network) + + text = + "To enable or change your number for telegram messaging, please open #{Nola.Telegram.my_path()} and send:" <> + " \"/enable #{code}\"" + + m.replyfun.(text) + {:noreply, state} + end + + def handle_info({:irc, :text, m = %Nola.Message{text: "enable-untappd"}}, state) do + auth_url = Untappd.auth_url() + login_url = Nola.AuthToken.new_url(m.account.id, {:external_redirect, auth_url}) + m.replyfun.(["To link your Untappd account, open this URL:", login_url]) + {:noreply, state} + end + + def handle_info({:irc, :text, m = %Nola.Message{text: "getmeta" <> _}}, state) do + result = + case String.split(m.text, " ") do ["getmeta"] -> for {k, v} <- Nola.Account.get_all_meta(m.account) do case k do - "u:"<>key -> "(user) #{key}: #{v}" + "u:" <> key -> "(user) #{key}: #{v}" key -> "#{key}: #{v}" end end + ["getmeta", key] -> value = Nola.Account.get_meta(m.account, key) - text = if value do - "#{key}: #{value}" - else - "#{key} is not defined" - end + + 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 = %Nola.Message{text: "setusermeta"<>_}}, state) do - result = case String.split(m.text, " ") do + m.replyfun.(result) + {:noreply, state} + end + + def handle_info({:irc, :text, m = %Nola.Message{text: "setusermeta" <> _}}, state) do + result = + case String.split(m.text, " ") do ["setusermeta", key, value] -> Nola.Account.put_user_meta(m.account, key, value) "ok" + _ -> "usage: setusermeta <key> <value>" end - m.replyfun.(result) - {:noreply, state} - end - def handle_info(_, state) do - {:noreply, state} - end + m.replyfun.(result) + {:noreply, state} + end - defp join_account(m, id, token) do - old_account = Nola.Account.lookup(m.sender) - new_account = Nola.Account.get(id) - if new_account && token == new_account.token do - case Nola.Account.merge_account(old_account.id, new_account.id) do - :ok -> - if old_account.id == new_account.id do - m.replyfun.("Already authenticated, but hello") - else - m.replyfun.("Accounts merged!") - end - _ -> m.replyfun.("Something failed :(") - end - else - m.replyfun.("Invalid token") + def handle_info(_, state) do + {:noreply, state} + end + + defp join_account(m, id, token) do + old_account = Nola.Account.lookup(m.sender) + new_account = Nola.Account.get(id) + + if new_account && token == new_account.token do + case Nola.Account.merge_account(old_account.id, new_account.id) do + :ok -> + if old_account.id == new_account.id do + m.replyfun.("Already authenticated, but hello") + else + m.replyfun.("Accounts merged!") + end + + _ -> + m.replyfun.("Something failed :(") end + else + m.replyfun.("Invalid token") end - end - +end diff --git a/lib/plugins/alcoolog.ex b/lib/plugins/alcoolog.ex index 69bd60c..de69b13 100644 --- a/lib/plugins/alcoolog.ex +++ b/lib/plugins/alcoolog.ex @@ -49,27 +49,40 @@ defmodule Nola.Plugins.Alcoolog do @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_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) + triggers = for(t <- @pubsub_triggers, do: "trigger:" <> t) + for sub <- @pubsub ++ triggers do {:ok, _} = Registry.register(Nola.PubSub, sub, plugin: __MODULE__) end - dets_filename = (Nola.data_path() <> "/" <> "alcoolisme.dets") |> String.to_charlist - {:ok, dets} = :dets.open_file(dets_filename, [{:type,:bag}]) - ets = :ets.new(__MODULE__.ETS, [:ordered_set, :named_table, :protected, {:read_concurrency, true}]) - dets_meta_filename = (Nola.data_path() <> "/" <> "alcoolisme_meta.dets") |> String.to_charlist - {:ok, meta} = :dets.open_file(dets_meta_filename, [{:type,:set}]) - traverse_fun = fn(obj, dets) -> + + 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() + 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) @@ -77,9 +90,11 @@ defmodule Nola.Plugins.Alcoolog do dets object = {nick, naive = %NaiveDateTime{}, volumes, active, cl, deg, name, comment, meta} -> - date = naive - |> DateTime.from_naive!("Etc/UTC") - |> DateTime.to_unix() + 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) @@ -94,6 +109,7 @@ defmodule Nola.Plugins.Alcoolog do dets end end + :dets.foldl(traverse_fun, dets, dets) :dets.sync(dets) state = %{dets: dets, meta: meta, ets: ets} @@ -101,59 +117,82 @@ defmodule Nola.Plugins.Alcoolog do end @eau ["santo", "santeau"] - def handle_info({:irc, :trigger, santeau, m = %Nola.Message{trigger: %Nola.Trigger{args: _, type: :bang}}}, state) when santeau in @eau do + def handle_info( + {:irc, :trigger, santeau, + m = %Nola.Message{trigger: %Nola.Trigger{args: _, type: :bang}}}, + state + ) + when santeau in @eau do Nola.Plugins.Txt.reply_random(m, "alcoolog.santo") {:noreply, state} end - def handle_info({:irc, :trigger, "soif", m = %Nola.Message{trigger: %Nola.Trigger{args: _, type: :bang}}}, state) do - now = DateTime.utc_now() - |> Timex.Timezone.convert("Europe/Paris") + def handle_info( + {:irc, :trigger, "soif", m = %Nola.Message{trigger: %Nola.Trigger{args: _, type: :bang}}}, + state + ) do + now = + DateTime.utc_now() + |> Timex.Timezone.convert("Europe/Paris") + apero = format_duration_from_now(%DateTime{now | hour: 18, minute: 0, second: 0}, false) day_of_week = Date.day_of_week(now) - {txt, apero?} = cond do - now.hour >= 0 && now.hour < 6 -> - {["apéro tardif ? Je dis OUI ! SANTAI !"], true} - now.hour >= 6 && now.hour < 12 -> - if day_of_week >= 6 do - {["de l'alcool pour le petit dej ? le week-end, pas de problème !"], true} - else - {["C'est quand même un peu tôt non ? Prochain apéro #{apero}"], false} - end - now.hour >= 12 && (now.hour < 14) -> - {["oui! c'est l'apéro de midi! (et apéro #{apero})", - "tu peux attendre #{apero} ou y aller, il est midi !" - ], true} - now.hour == 17 -> - {[ - "ÇA APPROCHE !!! Apéro #{apero}", - "BIENTÔT !!! Apéro #{apero}", - "achetez vite les teilles, apéro dans #{apero}!", - "préparez les teilles, apéro dans #{apero}!" - ], false} - now.hour >= 14 && now.hour < 18 -> - weekend = if day_of_week >= 6 do - " ... ou maintenant en fait, c'est le week-end!" - else - "" - end - {["tiens bon! apéro #{apero}#{weekend}", - "courage... apéro dans #{apero}#{weekend}", - "pas encore :'( apéro dans #{apero}#{weekend}" - ], false} - true -> - {[ - "C'EST L'HEURE DE L'APÉRO !!! SANTAIIIIIIIIIIII !!!!" - ], true} - end - txt = txt - |> Enum.shuffle() - |> Enum.random() + {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 @@ -161,99 +200,151 @@ defmodule Nola.Plugins.Alcoolog do {:noreply, state} end - def handle_info({:irc, :trigger, "sobrepour", m = %Nola.Message{trigger: %Nola.Trigger{args: args, type: :bang}}}, state) do + def handle_info( + {:irc, :trigger, "sobrepour", + m = %Nola.Message{trigger: %Nola.Trigger{args: args, type: :bang}}}, + state + ) do args = Enum.join(args, " ") {:ok, now} = DateTime.now("Europe/Paris", Tzdata.TimeZoneDatabase) - time = case args do - "demain " <> time -> - {h, m} = case String.split(time, [":", "h"]) do - [hour, ""] -> - IO.puts ("h #{inspect hour}") - {h, _} = Integer.parse(hour) - {h, 0} - [hour, min] when min != "" -> - {h, _} = Integer.parse(hour) - {m, _} = Integer.parse(min) - {h, m} - [hour] -> - IO.puts ("h #{inspect hour}") - {h, _} = Integer.parse(hour) - {h, 0} - _ -> {0, 0} - end - secs = ((60*60)*24) - day = DateTime.add(now, secs, :second, Tzdata.TimeZoneDatabase) - %DateTime{day | hour: h, minute: m, second: 0} - "après demain " <> time -> - secs = 2*((60*60)*24) - DateTime.add(now, secs, :second, Tzdata.TimeZoneDatabase) - datetime -> - case Timex.Parse.DateTime.Parser.parse(datetime, "{}") do - {:ok, dt} -> dt - _ -> nil - end - end + + 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) + duration = round(DateTime.diff(time, now) / 60.0) - IO.puts "diff #{inspect duration} sober in #{inspect stats.sober_in}" + 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") + 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 = %Nola.Message{trigger: %Nola.Trigger{args: [], type: :plus}}}, state) do + def handle_info( + {:irc, :trigger, "alcoolog", + m = %Nola.Message{trigger: %Nola.Trigger{args: [], type: :plus}}}, + state + ) do {:ok, token} = Nola.Token.new({:alcoolog, :index, m.sender.network, m.channel}) - url = NolaWeb.Router.Helpers.alcoolog_url(NolaWeb.Endpoint, :index, m.network, NolaWeb.format_chan(m.channel), token) + + 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 = %Nola.Message{trigger: %Nola.Trigger{args: [], type: :bang}}}, state) do - url = NolaWeb.Router.Helpers.alcoolog_url(NolaWeb.Endpoint, :index, m.network, NolaWeb.format_chan(m.channel)) + def handle_info( + {:irc, :trigger, "alcoolog", + m = %Nola.Message{trigger: %Nola.Trigger{args: [], type: :bang}}}, + state + ) do + url = + NolaWeb.Router.Helpers.alcoolog_url( + NolaWeb.Endpoint, + :index, + m.network, + NolaWeb.format_chan(m.channel) + ) + m.replyfun.("-> #{url}") {:noreply, state} end - def handle_info({:irc, :trigger, "alcool", m = %Nola.Message{trigger: %Nola.Trigger{args: args = [cl, deg], type: :bang}}}, state) do + def handle_info( + {:irc, :trigger, "alcool", + m = %Nola.Message{trigger: %Nola.Trigger{args: args = [cl, deg], type: :bang}}}, + state + ) do {cl, _} = Util.float_paparse(cl) {deg, _} = Util.float_paparse(deg) points = Alcool.units(cl, deg) meta = get_user_meta(state, m.account.id) k = if meta.sex, do: 0.7, else: 0.6 weight = meta.weight - gl = (10*points)/(k*weight) - duration = round(gl/((meta.loss_factor/100)/60))+30 - sober_in_s = if duration > 0 do + 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 + 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})" + ) - 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 = %Nola.Message{trigger: %Nola.Trigger{args: [cl, deg | comment], type: :bang}}}, state) do + def handle_info( + {:irc, :trigger, "santai", + m = %Nola.Message{trigger: %Nola.Trigger{args: [cl, deg | comment], type: :bang}}}, + state + ) do santai(m, state, cl, deg, comment) {:noreply, state} end @@ -263,80 +354,138 @@ defmodule Nola.Plugins.Alcoolog do "{{message.sender.nick}}: et voilà la petite sœur !" ] - def handle_info({:irc, :trigger, "bis", m = %Nola.Message{trigger: %Nola.Trigger{args: args, type: :bang}}}, state) do + def handle_info( + {:irc, :trigger, "bis", + m = %Nola.Message{trigger: %Nola.Trigger{args: args, type: :bang}}}, + state + ) do handle_info({:irc, :trigger, "moar", m}, state) end - def handle_info({:irc, :trigger, "again", m = %Nola.Message{trigger: %Nola.Trigger{args: args, type: :bang}}}, state) do + + def handle_info( + {:irc, :trigger, "again", + m = %Nola.Message{trigger: %Nola.Trigger{args: args, type: :bang}}}, + state + ) do handle_info({:irc, :trigger, "moar", m}, state) end - def handle_info({:irc, :trigger, "moar", m = %Nola.Message{trigger: %Nola.Trigger{args: args, type: :bang}}}, state) do + def handle_info( + {:irc, :trigger, "moar", + m = %Nola.Message{trigger: %Nola.Trigger{args: args, type: :bang}}}, + state + ) do case get_statistics_for_nick(state, m.account.id) do {_, obj = {_, _date, _points, _active, cl, deg, _name, comment, _meta}} -> - cl = case args do - [cls] -> - case Util.float_paparse(cls) do - {cl, _} -> cl - _ -> cl - end - _ -> cl - end + cl = + case args do + [cls] -> + case Util.float_paparse(cls) do + {cl, _} -> cl + _ -> cl + end + + _ -> + cl + end + moar = @moar |> Enum.shuffle() |> Enum.random() |> Tmpl.render(m) |> m.replyfun.() santai(m, state, cl, deg, comment, auto_set: true) + {_, obj = {_, date, points, _last_active, type, descr}} -> case Regex.named_captures(~r/^(?<cl>\d+[.]\d+)cl\s+(?<deg>\d+[.]\d+)°$/, type) do - nil -> m.replyfun.("suce") + 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 + + _ -> + 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 + 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 + {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 - {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")} - _ -> + {: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 + 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") + 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) + + 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) @@ -344,87 +493,132 @@ defmodule Nola.Plugins.Alcoolog do 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() + + :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) + 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 + + 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 + sober = + nonow + |> DateTime.add(round(stats.sober_in * 60), :second) + |> Timex.Timezone.convert("Europe/Paris") - since_str = if stats.since && stats.since_min > 180 do - "(depuis: #{stats.since_s}) " - else - "" - end + at = + if nonow.day == sober.day do + {:ok, detail} = + Timex.Format.DateTime.Formatters.Default.lformat( + sober, + "aujourd'hui {h24}:{m}", + "fr" + ) - 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 + detail + else + {:ok, detail} = + Timex.Format.DateTime.Formatters.Default.lformat(sober, "{WDfull} {h24}:{m}", "fr") - meta = if beer_id do - Map.put(meta, "untappd:beer_id", beer_id) - else - meta + 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() -> + 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}") + 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 + + {: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}°)" + local_extra = + if auto_set do + if comment do + " #{comment} (#{cl}cl @ #{deg}°)" + else + "#{cl}cl @ #{deg}°" + end else - "#{cl}cl @ #{deg}°" + "" end - else - "" - end + m.replyfun.(msg.(m.sender.nick, local_extra)) - notify = Nola.Membership.notify_channels(m.account) -- [{m.network,m.channel}] + notify = Nola.Membership.notify_channels(m.account) -- [{m.network, m.channel}] + for {net, chan} <- notify do user = Nola.UserTrack.find_by_account(net, m.account) nick = if(user, do: user.nick, else: m.account.name) @@ -432,24 +626,26 @@ defmodule Nola.Plugins.Alcoolog do Nola.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 + miss = + cond do + points <= 0.6 -> :small + stats.active30m >= 2.9 && stats.active30m < 3 -> :miss3 + stats.active30m >= 1.9 && stats.active30m < 2 -> :miss2 + stats.active30m >= 0.9 && stats.active30m < 1 -> :miss1 + stats.active30m >= 0.45 && stats.active30m < 0.5 -> :miss05 + stats.active30m >= 0.20 && stats.active30m < 0.20 -> :miss025 + stats.active30m >= 3 && stats.active1h < 3.15 -> :small3 + stats.active30m >= 2 && stats.active1h < 2.15 -> :small2 + stats.active30m >= 1.5 && stats.active1h < 1.5 -> :small15 + stats.active30m >= 1 && stats.active1h < 1.15 -> :small1 + stats.active30m >= 0.5 && stats.active1h <= 0.51 -> :small05 + stats.active30m >= 0.25 && stats.active30m <= 0.255 -> :small025 + true -> nil + end if miss do miss = Nola.Plugins.Txt.random("alcoolog.#{to_string(miss)}") + if miss do for {net, chan} <- Nola.Membership.notify_channels(m.account) do user = Nola.UserTrack.find_by_account(net, m.account) @@ -461,45 +657,59 @@ defmodule Nola.Plugins.Alcoolog do end end - def handle_info({:irc, :trigger, "santai", m = %Nola.Message{trigger: %Nola.Trigger{args: _, type: :bang}}}, state) do + def handle_info( + {:irc, :trigger, "santai", + m = %Nola.Message{trigger: %Nola.Trigger{args: _, type: :bang}}}, + state + ) do m.replyfun.("!santai <cl> <degrés> [commentaire]") {:noreply, state} end def get_all_stats() do Nola.Account.all_accounts() - |> Enum.map(fn(account) -> {account.id, get_full_statistics(account.id)} end) - |> Enum.filter(fn({_nick, status}) -> status && (status.active > 0 || status.active30m > 0) end) - |> Enum.sort_by(fn({_, status}) -> status.active end, &>/2) + |> Enum.map(fn account -> {account.id, get_full_statistics(account.id)} end) + |> Enum.filter(fn {_nick, status} -> + status && (status.active > 0 || status.active30m > 0) + end) + |> Enum.sort_by(fn {_, status} -> status.active end, &>/2) end def get_channel_statistics(account, network, nil) do Nola.Membership.expanded_members_or_friends(account, network, nil) - |> Enum.map(fn({account, _, nick}) -> {nick, get_full_statistics(account.id)} end) - |> Enum.filter(fn({_nick, status}) -> status && (status.active > 0 || status.active30m > 0) end) - |> Enum.sort_by(fn({_, status}) -> status.active end, &>/2) + |> Enum.map(fn {account, _, nick} -> {nick, get_full_statistics(account.id)} end) + |> Enum.filter(fn {_nick, status} -> + status && (status.active > 0 || status.active30m > 0) + end) + |> Enum.sort_by(fn {_, status} -> status.active end, &>/2) end def get_channel_statistics(_, network, channel), do: get_channel_statistics(network, channel) def get_channel_statistics(network, channel) do Nola.Membership.expanded_members(network, channel) - |> Enum.map(fn({account, _, nick}) -> {nick, get_full_statistics(account.id)} end) - |> Enum.filter(fn({_nick, status}) -> status && (status.active > 0 || status.active30m > 0) end) - |> Enum.sort_by(fn({_, status}) -> status.active end, &>/2) + |> Enum.map(fn {account, _, nick} -> {nick, get_full_statistics(account.id)} end) + |> Enum.filter(fn {_nick, status} -> + status && (status.active > 0 || status.active30m > 0) + end) + |> Enum.sort_by(fn {_, status} -> status.active end, &>/2) end @spec since() :: %{Nola.Account.id() => DateTime.t()} @doc "Returns the last time the user was at 0 g/l" def since() do - :ets.foldr(fn({{acct, timestamp_or_date}, _vol, current, _cl, _deg, _name, _comment, _m}, acc) -> - if !Map.get(acc, acct) && current == 0 do - date = Util.to_date_time(timestamp_or_date) - Map.put(acc, acct, date) - else - acc - end - end, %{}, __MODULE__.ETS) + :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 @@ -508,129 +718,204 @@ defmodule Nola.Plugins.Alcoolog do 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}} -> + {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 + trend = + if rising do + "▲" + else + "▼" + 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 + 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 + 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) + 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, + %{ + 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, + 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, + 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 + + _ -> + nil end end - def handle_info({:irc, :trigger, "sobre", m = %Nola.Message{trigger: %Nola.Trigger{args: args, type: :dot}}}, state) do - nicks = Nola.Membership.expanded_members_or_friends(m.account, m.network, m.channel) - |> Enum.map(fn({account, _, nick}) -> {nick, get_full_statistics(state, account.id)} end) - |> Enum.filter(fn({_nick, status}) -> status && status.sober_in && status.sober_in > 0 end) - |> Enum.sort_by(fn({_, status}) -> status.sober_in end, &</2) - |> Enum.map(fn({nick, stats}) -> - now = DateTime.utc_now() - sober = now |> DateTime.add(round(stats.sober_in*60), :second) - |> Timex.Timezone.convert("Europe/Paris") - at = if now.day == sober.day do - {:ok, detail} = Timex.Format.DateTime.Formatters.Default.lformat(sober, "aujourd'hui {h24}:{m}", "fr") - detail - else - {:ok, detail} = Timex.Format.DateTime.Formatters.Default.lformat(sober, "{WDfull} {h24}:{m}", "fr") - detail - end - "#{nick} sobre #{at} (dans #{stats.sober_in_s})" - end) - |> Enum.intersperse(", ") - |> Enum.join("") - |> (fn(line) -> - case line do - "" -> "tout le monde est sobre......." - line -> line - end - end).() - |> m.replyfun.() + def handle_info( + {:irc, :trigger, "sobre", + m = %Nola.Message{trigger: %Nola.Trigger{args: args, type: :dot}}}, + state + ) do + nicks = + Nola.Membership.expanded_members_or_friends(m.account, m.network, m.channel) + |> Enum.map(fn {account, _, nick} -> {nick, get_full_statistics(state, account.id)} end) + |> Enum.filter(fn {_nick, status} -> status && status.sober_in && status.sober_in > 0 end) + |> Enum.sort_by(fn {_, status} -> status.sober_in end, &</2) + |> Enum.map(fn {nick, stats} -> + now = DateTime.utc_now() + + sober = + now + |> DateTime.add(round(stats.sober_in * 60), :second) + |> Timex.Timezone.convert("Europe/Paris") + + at = + if now.day == sober.day do + {:ok, detail} = + Timex.Format.DateTime.Formatters.Default.lformat( + sober, + "aujourd'hui {h24}:{m}", + "fr" + ) + + detail + else + {:ok, detail} = + Timex.Format.DateTime.Formatters.Default.lformat(sober, "{WDfull} {h24}:{m}", "fr") + + detail + end + + "#{nick} sobre #{at} (dans #{stats.sober_in_s})" + end) + |> Enum.intersperse(", ") + |> Enum.join("") + |> (fn line -> + case line do + "" -> "tout le monde est sobre......." + line -> line + end + end).() + |> m.replyfun.() + {:noreply, state} end - def handle_info({:irc, :trigger, "sobre", m = %Nola.Message{trigger: %Nola.Trigger{args: args, type: :bang}}}, state) do - account = case args do - [nick] -> Nola.Account.find_always_by_nick(m.network, m.channel, nick) - [] -> m.account - end + def handle_info( + {:irc, :trigger, "sobre", + m = %Nola.Message{trigger: %Nola.Trigger{args: args, type: :bang}}}, + state + ) do + account = + case args do + [nick] -> Nola.Account.find_always_by_nick(m.network, m.channel, nick) + [] -> m.account + end if account do user = Nola.UserTrack.find_by_account(m.network, account) nick = if(user, do: user.nick, else: account.name) stats = get_full_statistics(state, account.id) + if stats && stats.sober_in > 0 do now = DateTime.utc_now() - sober = now |> DateTime.add(round(stats.sober_in*60), :second) - |> Timex.Timezone.convert("Europe/Paris") - at = if now.day == sober.day do - {:ok, detail} = Timex.Format.DateTime.Formatters.Default.lformat(sober, "aujourd'hui {h24}:{m}", "fr") + + 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") + {: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 !") @@ -638,61 +923,85 @@ defmodule Nola.Plugins.Alcoolog do else m.replyfun.("inconnu") end + {:noreply, state} end - def handle_info({:irc, :trigger, "alcoolisme", m = %Nola.Message{trigger: %Nola.Trigger{args: [], type: :dot}}}, state) do - nicks = Nola.Membership.expanded_members_or_friends(m.account, m.network, m.channel) - |> Enum.map(fn({account, _, nick}) -> {nick, get_full_statistics(state, account.id)} end) - |> Enum.filter(fn({_nick, status}) -> status && (status.active > 0 || status.active30m > 0) end) - |> Enum.sort_by(fn({_, status}) -> status.active end, &>/2) - |> Enum.map(fn({nick, status}) -> - trend_symbol = if status.active_drinks > 1 do - Enum.join(for(_ <- 1..status.active_drinks, do: status.trend_symbol)) - else - status.trend_symbol - end - since_str = if status.since_min > 180 do - "depuis: #{status.since_s} | " + def handle_info( + {:irc, :trigger, "alcoolisme", + m = %Nola.Message{trigger: %Nola.Trigger{args: [], type: :dot}}}, + state + ) do + nicks = + Nola.Membership.expanded_members_or_friends(m.account, m.network, m.channel) + |> Enum.map(fn {account, _, nick} -> {nick, get_full_statistics(state, account.id)} end) + |> Enum.filter(fn {_nick, status} -> + status && (status.active > 0 || status.active30m > 0) + end) + |> Enum.sort_by(fn {_, status} -> status.active end, &>/2) + |> Enum.map(fn {nick, status} -> + trend_symbol = + if status.active_drinks > 1 do + Enum.join(for(_ <- 1..status.active_drinks, do: status.trend_symbol)) + else + status.trend_symbol + end + + since_str = + if status.since_min > 180 do + "depuis: #{status.since_s} | " + else + "" + end + + "#{nick} #{status.user_status} #{trend_symbol} #{Float.round(status.active, 4)} g/l [#{since_str}sobre dans: #{status.sober_in_s}]" + end) + |> Enum.intersperse(", ") + |> Enum.join("") + + msg = + if nicks == "" do + "wtf?!?! personne n'a bu!" else - "" + nicks end - "#{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 = %Nola.Message{trigger: %Nola.Trigger{args: [time], type: :dot}}}, state) do - time = case time do - "semaine" -> 7 - string -> - case Integer.parse(string) do - {time, "j"} -> time - {time, "J"} -> time - _ -> nil - end - end + def handle_info( + {:irc, :trigger, "alcoolisme", + m = %Nola.Message{trigger: %Nola.Trigger{args: [time], type: :dot}}}, + state + ) do + time = + case time do + "semaine" -> + 7 + + string -> + case Integer.parse(string) do + {time, "j"} -> time + {time, "J"} -> time + _ -> nil + end + end if time do - aday = time*((24 * 60)*60) + aday = time * (24 * 60 * 60) now = DateTime.utc_now() - before = now - |> DateTime.add(-aday, :second) - |> DateTime.to_unix(:millisecond) + + 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 @@ -701,30 +1010,37 @@ defmodule Nola.Plugins.Alcoolog do end def user_over_time(state, account, count) do - delay = count*((24 * 60)*60) + 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"}, :_, :_, :_, :_, :_, :_, :_}, + + 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() + :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) @@ -733,164 +1049,219 @@ defmodule Nola.Plugins.Alcoolog do def user_over_time_gl(account, count) do state = data_state() meta = get_user_meta(state, account.id) - delay = count*((24 * 60)*60) + 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"}, :_, :_, :_, :_, :_, :_, :_}, + + 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) + |> 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() - 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) + 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}}], [:"$_"]} + # 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} + + # tuple ets: {{nick, date}, volumes, current, nom, commentaire} members = Nola.Membership.members_or_friends(m.account, m.network, m.channel) - drinks = :ets.select(state.ets, match) - |> Enum.filter(fn({{account, _}, _, _, _, _, _, _, _}) -> Enum.member?(members, account) end) - |> Enum.sort_by(fn({{_, ts}, _, _, _, _, _, _, _}) -> ts end, &>/2) - top = Enum.reduce(drinks, %{}, fn({{nick, _}, vol, _, _, _, _, _, _}, acc) -> - all = Map.get(acc, nick, 0) - Map.put(acc, nick, all + vol) - end) - |> Enum.sort_by(fn({_nick, count}) -> count end, &>/2) - |> Enum.map(fn({nick, count}) -> - account = Nola.Account.get(nick) - user = Nola.UserTrack.find_by_account(m.network, account) - nick = if(user, do: user.nick, else: account.name) - "#{nick}: #{Float.round(count, 4)}" - end) - |> Enum.intersperse(", ") + drinks = + :ets.select(state.ets, match) + |> Enum.filter(fn {{account, _}, _, _, _, _, _, _, _} -> Enum.member?(members, account) end) + |> Enum.sort_by(fn {{_, ts}, _, _, _, _, _, _, _} -> ts end, &>/2) + + top = + Enum.reduce(drinks, %{}, fn {{nick, _}, vol, _, _, _, _, _, _}, acc -> + all = Map.get(acc, nick, 0) + Map.put(acc, nick, all + vol) + end) + |> Enum.sort_by(fn {_nick, count} -> count end, &>/2) + |> Enum.map(fn {nick, count} -> + account = Nola.Account.get(nick) + user = Nola.UserTrack.find_by_account(m.network, account) + nick = if(user, do: user.nick, else: account.name) + "#{nick}: #{Float.round(count, 4)}" + end) + |> Enum.intersperse(", ") m.replyfun.("sur #{j} jours: #{top}") {:noreply, state} end - def handle_info({:irc, :trigger, "alcoolisme", m = %Nola.Message{trigger: %Nola.Trigger{args: [], type: :plus}}}, state) do + def handle_info( + {:irc, :trigger, "alcoolisme", + m = %Nola.Message{trigger: %Nola.Trigger{args: [], type: :plus}}}, + state + ) do meta = get_user_meta(state, m.account.id) hf = if meta.sex, do: "h", else: "f" - m.replyfun.("+alcoolisme sexe: #{hf} poids: #{meta.weight} facteur de perte: #{meta.loss_factor}") + + m.replyfun.( + "+alcoolisme sexe: #{hf} poids: #{meta.weight} facteur de perte: #{meta.loss_factor}" + ) + {:noreply, state} end - def handle_info({:irc, :trigger, "alcoolisme", m = %Nola.Message{trigger: %Nola.Trigger{args: [h, weight | rest], type: :plus}}}, state) do - h = case h do - "h" -> true - "f" -> false - _ -> nil - end + def handle_info( + {:irc, :trigger, "alcoolisme", + m = %Nola.Message{trigger: %Nola.Trigger{args: [h, weight | rest], type: :plus}}}, + state + ) do + h = + case h do + "h" -> true + "f" -> false + _ -> nil + end - weight = case Util.float_paparse(weight) do + weight = + case Util.float_paparse(weight) do {weight, _} -> weight _ -> nil end - {factor} = case rest do + {factor} = + case rest do [factor] -> case Util.float_paparse(factor) do {float, _} -> {float} _ -> {@default_user_meta.loss_factor} end - _ -> {@default_user_meta.loss_factor} + + _ -> + {@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 + 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 = %Nola.Message{trigger: %Nola.Trigger{args: args, type: :minus}}}, state) do + def handle_info( + {:irc, :trigger, "santai", + m = %Nola.Message{trigger: %Nola.Trigger{args: args, type: :minus}}}, + state + ) do case get_statistics_for_nick(state, m.account.id) do {_, obj = {_, date, points, _last_active, _cl, _deg, type, descr, _meta}} -> :dets.delete_object(state.dets, obj) :ets.delete(state.ets, {m.account.id, date}) m.replyfun.("supprimé: #{m.sender.nick} #{points} #{type} #{descr}") Nola.Plugins.Txt.reply_random(m, "alcoolog.delete") - notify = Nola.Membership.notify_channels(m.account) -- [{m.network,m.channel}] + notify = Nola.Membership.notify_channels(m.account) -- [{m.network, m.channel}] + for {net, chan} <- notify do user = Nola.UserTrack.find_by_account(net, m.account) nick = if(user, do: user.nick, else: m.account.name) - Nola.Irc.Connection.broadcast_message(net, chan, "#{nick} -santai #{points} #{type} #{descr}") + + Nola.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 = %Nola.Message{trigger: %Nola.Trigger{args: args, type: :bang}}}, + state + ) do + {account, duration} = + case args do + [nick | rest] -> {Nola.Account.find_always_by_nick(m.network, m.channel, nick), rest} + [] -> {m.account, []} + end - def handle_info({:irc, :trigger, "alcoolisme", m = %Nola.Message{trigger: %Nola.Trigger{args: args, type: :bang}}}, state) do - {account, duration} = case args do - [nick | rest] -> {Nola.Account.find_always_by_nick(m.network, m.channel, nick), rest} - [] -> {m.account, []} - end if account do - duration = case duration do - ["semaine"] -> 7 - [j] -> - case Integer.parse(j) do - {j, "j"} -> j - _ -> nil - end - _ -> nil - end + duration = + case duration do + ["semaine"] -> + 7 + + [j] -> + case Integer.parse(j) do + {j, "j"} -> j + _ -> nil + end + + _ -> + nil + end + user = Nola.UserTrack.find_by_account(m.network, account) nick = if(user, do: user.nick, else: account.name) + if duration do if duration > 90 do m.replyfun.("trop gros, ça rentrera pas") else # duration stats - stats = user_over_time(state, account, duration) - |> Enum.sort_by(fn({k,_v}) -> k end, {:asc, Date}) - |> Enum.map(fn({date, count}) -> - "#{date.day}: #{Float.round(count, 2)}" - end) - |> Enum.intersperse(", ") - |> Enum.join("") + 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 :/") @@ -900,47 +1271,67 @@ defmodule Nola.Plugins.Alcoolog do 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: "") + 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 + 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}") + + 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 @@ -951,7 +1342,13 @@ defmodule Nola.Plugins.Alcoolog do end end - defp rename_object_owner(table, ets, object = {old_id, date, volume, current, cl, deg, name, comment, meta}, old_id, new_id) do + 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}) @@ -960,58 +1357,75 @@ defmodule Nola.Plugins.Alcoolog do # 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) + # 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)}") + 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) -> + + 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}") + Logger.debug("#{__MODULE__}: unhandled info #{inspect(t)}") {:noreply, state} end def nick_history(account) do spec = [ - {{{:"$1", :_}, :_, :_, :_, :_, :_, :_, :_}, - [{:==, :"$1", {:const, account.id}}], - [:"$_"]} + {{{:"$1", :_}, :_, :_, :_, :_, :_, :_, :_}, [{:==, :"$1", {:const, account.id}}], [:"$_"]} ] + :ets.select(data_state().ets, spec) end defp get_statistics_for_nick(state, account_id) do - qvc = :dets.lookup(state.dets, account_id) - |> Enum.sort_by(fn({_, ts, _, _, _, _, _, _, _}) -> ts end, &</2) - count = Enum.reduce(qvc, 0, fn({_nick, _ts, points, _active, _cl, _deg, _type, _descr, _meta}, acc) -> acc + (points||0) end) + qvc = + :dets.lookup(state.dets, account_id) + |> Enum.sort_by(fn {_, ts, _, _, _, _, _, _, _} -> ts end, &</2) + + count = + Enum.reduce(qvc, 0, fn {_nick, _ts, points, _active, _cl, _deg, _type, _descr, _meta}, + acc -> + acc + (points || 0) + end) + last = List.last(qvc) || nil {count, last} end @@ -1022,26 +1436,34 @@ defmodule Nola.Plugins.Alcoolog do 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))}" + "+#{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)) + 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") + 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 @@ -1056,10 +1478,12 @@ defmodule Nola.Plugins.Alcoolog 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 @@ -1077,12 +1501,15 @@ defmodule Nola.Plugins.Alcoolog do defp user_stats(state = %{ets: ets}, account_id) do meta = get_user_meta(state, account_id) - aday = (10 * 60)*60 + 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) + + 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"}, :_, :_, :_, :_, :_, :_, :_}, [ @@ -1090,43 +1517,57 @@ defmodule Nola.Plugins.Alcoolog do {:"=:=", {:const, account_id}, :"$1"} ], [:"$_"]} ] - # tuple ets: {{nick, date}, volumes, current, nom, commentaire} + + # 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) + 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) + 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_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)} + + 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 + aday = 24 * 7 * 60 * 60 + + now = + if now do + now + else + DateTime.utc_now() + end + + before = 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) + |> DateTime.add(-aday, :second) + |> DateTime.to_unix(:millisecond) + + # match = :ets.fun2ms(fn(obj = {{^nick, date}, _, _, _, _}) when date > before -> obj end) match = [ {{{:"$1", :"$2"}, :_, :_, :_, :_, :_, :_, :_}, [ @@ -1134,59 +1575,77 @@ defmodule Nola.Plugins.Alcoolog do {:"=:=", {:const, account_id}, :"$1"} ], [:"$_"]} ] - # tuple ets: {{nick, date}, volumes, current, nom, commentaire} - drinks = :ets.select(ets, match) - |> Enum.sort_by(fn({{_, date}, _, _, _, _, _, _, _}) -> date end, &</2) + + # tuple ets: {{nick, date}, volumes, current, nom, commentaire} + drinks = + :ets.select(ets, match) + |> Enum.sort_by(fn {{_, date}, _, _, _, _, _, _, _} -> date end, &</2) + # {date, single_peak} - {all, last_drink_at, gl, active_drinks} = Enum.reduce(drinks, {0.0, nil, [], 0}, fn({{_, date}, volume, _, _, _, _, _, _}, {all, last_at, acc, active_drinks}) -> - k = if meta.sex, do: 0.7, else: 0.6 - weight = meta.weight - peak = (10*volume)/(k*weight) - date = case date do - ts when is_integer(ts) -> DateTime.from_unix!(ts, :millisecond) - date = %NaiveDateTime{} -> DateTime.from_naive!(date, "Etc/UTC") - date = %DateTime{} -> date - end - last_at = last_at || date - mins_since = round(DateTime.diff(now, date)/60.0) - #IO.puts "Drink: #{inspect({date, volume})} - mins since: #{inspect mins_since} - last drink at #{inspect last_at}" - # Apply loss since `last_at` on `all` - # - all = if last_at do - mins_since_last = round(DateTime.diff(date, last_at)/60.0) - loss = ((meta.loss_factor/100)/60)*(mins_since_last) - #IO.puts "Applying last drink loss: from #{all}, loss of #{inspect loss} (mins since #{inspect mins_since_first})" - cond do - (all-loss) > 0 -> all - loss - true -> 0.0 + {all, last_drink_at, gl, active_drinks} = + Enum.reduce(drinks, {0.0, nil, [], 0}, fn {{_, date}, volume, _, _, _, _, _, _}, + {all, last_at, acc, active_drinks} -> + k = if meta.sex, do: 0.7, else: 0.6 + weight = meta.weight + peak = 10 * volume / (k * weight) + + date = + case date do + ts when is_integer(ts) -> DateTime.from_unix!(ts, :millisecond) + date = %NaiveDateTime{} -> DateTime.from_naive!(date, "Etc/UTC") + date = %DateTime{} -> date + end + + last_at = last_at || date + mins_since = round(DateTime.diff(now, date) / 60.0) + + # IO.puts "Drink: #{inspect({date, volume})} - mins since: #{inspect mins_since} - last drink at #{inspect last_at}" + # Apply loss since `last_at` on `all` + # + all = + if last_at do + mins_since_last = round(DateTime.diff(date, last_at) / 60.0) + loss = meta.loss_factor / 100 / 60 * mins_since_last + + # IO.puts "Applying last drink loss: from #{all}, loss of #{inspect loss} (mins since #{inspect mins_since_first})" + cond do + all - loss > 0 -> all - loss + true -> 0.0 + end + else + all + end + + # IO.puts "Applying last drink current before drink: #{inspect all}" + if mins_since < 30 do + per_min = peak / 30.0 + current = per_min * mins_since + + # IO.puts "Applying current drink 30m: from #{peak}, loss of #{inspect per_min}/min (mins since #{inspect mins_since})" + {all + current, date, [{date, current} | acc], active_drinks + 1} + else + {all + peak, date, [{date, peak} | acc], active_drinks} end + end) + + # IO.puts "last drink #{inspect last_drink_at}" + mins_since_last = + if last_drink_at do + round(DateTime.diff(now, last_drink_at) / 60.0) else - all + 0 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} + + # 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 + peak, date, [{date, peak} | acc], active_drinks} + all 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" + + # IO.puts "\n LEVEL #{inspect level}\n\n\n\n" cond do level < 0 -> {0.0, 0} true -> {level, active_drinks} @@ -1194,23 +1653,31 @@ defmodule Nola.Plugins.Alcoolog do end defp format_duration_from_now(date, with_detail \\ true) do - date = if is_integer(date) do - date = DateTime.from_unix!(date, :millisecond) + 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") - 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) + 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 = + if mins_since > 0 do + "il y a " + else + "dans " + end + word <> ago <> if(with_detail, do: " #{detail}", else: "") else "maintenant #{detail}" @@ -1218,12 +1685,12 @@ defmodule Nola.Plugins.Alcoolog do 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 + 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 f172d85..452f56c 100644 --- a/lib/plugins/alcoolog_announcer.ex +++ b/lib/plugins/alcoolog_announcer.ex @@ -27,19 +27,21 @@ defmodule Nola.Plugins.AlcoologAnnouncer do 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}]) + 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)}}} - ], [:"$_"]} + {{:"$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 @@ -49,25 +51,30 @@ defmodule Nola.Plugins.AlcoologAnnouncer do {: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}} + dets_filename = (Nola.data_path() <> "/" <> "alcoologlog.dets") |> String.to_charlist() + {:ok, dets} = :dets.open_file(dets_filename, [{:type, :bag}]) + + # :ets.new(__MODULE__.ETS, [:ordered_set, :named_table, :protected, {:read_concurrency, true}]) + ets = nil + # , {:continue, :traverse}} + {:ok, {stats, now(), dets, ets}} end def handle_continue(:traverse, state = {_, _, dets, ets}) do - traverse_fun = fn(obj, dets) -> + 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}") + IO.puts("ok #{inspect(obj)}") dets + {nick, ts, value} -> - :ets.insert(ets, { {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") @@ -75,166 +82,210 @@ defmodule Nola.Plugins.AlcoologAnnouncer do end def alcohol_reached(old, new, level) do - (old.active < level && new.active >= level) && (new.active5m >= level) - end + 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 - + 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() + apero = + Enum.shuffle(@apero) + |> Enum.random() case apero do {:timed, list} -> - spawn(fn() -> + spawn(fn -> for line <- list do Nola.Irc.Connection.broadcast_message("evolu.net", "#dmz", line) :timer.sleep(:timer.seconds(5)) end end) + string -> Nola.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 + # 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 - 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 + {acct, event} 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 + 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}") + # IO.puts("#{acct}: #{message}") account = Nola.Account.get(acct) + for {net, chan} <- Nola.Membership.notify_channels(account) do user = Nola.UserTrack.find_by_account(net, account) nick = if(user, do: user.nick, else: account.name) @@ -245,8 +296,8 @@ defmodule Nola.Plugins.AlcoologAnnouncer do timer() - #IO.puts "tick stats ok" - {:noreply, {stats,now,dets,ets}} + # IO.puts "tick stats ok" + {:noreply, {stats, now, dets, ets}} end def handle_info(_, state) do @@ -265,5 +316,4 @@ defmodule Nola.Plugins.AlcoologAnnouncer do 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 97aaa05..7fb285b 100644 --- a/lib/plugins/base.ex +++ b/lib/plugins/base.ex @@ -1,5 +1,4 @@ defmodule Nola.Plugins.Base do - def irc_doc, do: nil def start_link() do @@ -17,85 +16,96 @@ defmodule Nola.Plugins.Base do end def handle_info({:irc, :trigger, "plugins", msg = %{trigger: %{type: :bang, args: []}}}, _) do - enabled_string = Nola.Plugins.enabled() - |> Enum.map(fn(string_or_module) -> - case string_or_module do - string when is_binary(string) -> string - module when is_atom(module) -> - module - |> Macro.underscore() - |> String.split("/", parts: :infinity) - |> List.last() + enabled_string = + Nola.Plugins.enabled() + |> Enum.map(fn string_or_module -> + case string_or_module do + string when is_binary(string) -> + string + + module when is_atom(module) -> + module + |> Macro.underscore() + |> String.split("/", parts: :infinity) + |> List.last() end - end) - |> Enum.sort() - |> Enum.join(", ") + end) + |> Enum.sort() + |> 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 + pid when is_pid(pid) <- GenServer.whereis(module) do m.replyfun.("loaded, active: #{inspect(pid)}") else - false -> m.replyfun.("not loaded") + false -> + m.replyfun.("not loaded") + nil -> - msg = case Nola.Plugins.get(module) do - :disabled -> "disabled" - {_, false, _} -> "disabled" - _ -> "not active" - end + 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 + {: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 + {: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 + :ok <- GenServer.stop(pid) do Nola.Plugins.switch(module, false) m.replyfun.("stopped: #{inspect(pid)}") else false -> m.replyfun.("not loaded") nil -> m.replyfun.("not active") end + {:noreply, nil} end @@ -106,7 +116,14 @@ defmodule Nola.Plugins.Base do 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)) + url = + NolaWeb.Router.Helpers.irc_url( + NolaWeb.Endpoint, + :index, + m.network, + NolaWeb.format_chan(m.channel) + ) + m.replyfun.("-> #{url}") {:noreply, nil} end @@ -115,7 +132,10 @@ defmodule Nola.Plugins.Base 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() + + 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) @@ -123,22 +143,24 @@ defmodule Nola.Plugins.Base do if message.channel do message.replyfun.([ - <<"🤖 ", 2, "#{brand}", 2, " v", 2, "#{ver}", 2, "! My owner is #{Nola.brand(:owner)} and help is at #{url}">>, + <<"🤖 ", 2, "#{brand}", 2, " v", 2, "#{ver}", 2, + "! My owner is #{Nola.brand(:owner)} and help is at #{url}">> ]) else message.replyfun.([ - <<"🤖 I am a robot running ", 2, "#{brand}", 2, " version ", 2, "#{ver}", 2, " — source: #{Nola.source_url()}">>, + <<"🤖 I am a robot running ", 2, "#{brand}", 2, " version ", 2, "#{ver}", 2, + " — source: #{Nola.source_url()}">>, "Source code: #{Nola.source_url()}", "🦾 Elixir #{elixir_ver} #{otp_ver} on #{system}", "🙋🏻 Owner: #{owner}", - "🌍 Web interface: #{url}" + "🌍 Web interface: #{url}" ]) end + {: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 025a250..5b1ea9a 100644 --- a/lib/plugins/boursorama.ex +++ b/lib/plugins/boursorama.ex @@ -1,5 +1,4 @@ defmodule Nola.Plugins.Boursorama do - def irc_doc() do """ # bourses @@ -25,25 +24,34 @@ defmodule Nola.Plugins.Boursorama do {:ok, nil} end - def handle_info({:irc, :trigger, cac, m = %Nola.Message{trigger: %Nola.Trigger{type: :bang}}}, state) when cac in ["cac40", "caca40"] do + def handle_info( + {:irc, :trigger, cac, m = %Nola.Message{trigger: %Nola.Trigger{type: :bang}}}, + state + ) + when cac in ["cac40", "caca40"] do case HTTPoison.get(@cac40_url, [], []) do {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> - html = Floki.parse(body) + {:ok, html} = Floki.parse_document(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 + + 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}") @@ -54,5 +62,4 @@ defmodule Nola.Plugins.Boursorama do m.replyfun.("caca40: erreur http") end end - end diff --git a/lib/plugins/buffer.ex b/lib/plugins/buffer.ex index 42a435e..5f848ef 100644 --- a/lib/plugins/buffer.ex +++ b/lib/plugins/buffer.ex @@ -6,7 +6,12 @@ defmodule Nola.Plugins.Buffer do 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 + + spec = + fun do + {{n, c, _}, m} when n == ^network and (c == ^channel or is_nil(c)) -> m + end + :ets.select(@table, spec, limit) end @@ -18,6 +23,7 @@ defmodule Nola.Plugins.Buffer do for e <- ~w(messages triggers events outputs) do {:ok, _} = Registry.register(Nola.PubSub, e, plugin: __MODULE__) end + {:ok, :ets.new(@table, [:named_table, :ordered_set, :protected])} end @@ -34,11 +40,11 @@ defmodule Nola.Plugins.Buffer do defp ts(nil), do: ts(NaiveDateTime.utc_now()) defp ts(naive = %NaiveDateTime{}) do - ts = naive - |> DateTime.from_naive!("Etc/UTC") - |> DateTime.to_unix() + 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 2ff6cb4..20442ce 100644 --- a/lib/plugins/calc.ex +++ b/lib/plugins/calc.ex @@ -12,20 +12,27 @@ defmodule Nola.Plugins.Calc do end def init(_) do - {:ok, _} = Registry.register(Nola.PubSub, "trigger:calc", [plugin: __MODULE__]) + {:ok, _} = Registry.register(Nola.PubSub, "trigger:calc", plugin: __MODULE__) {:ok, nil} end - def handle_info({:irc, :trigger, "calc", message = %Nola.Message{trigger: %Nola.Trigger{type: :bang, args: expr_list}}}, state) do + def handle_info( + {:irc, :trigger, "calc", + message = %Nola.Message{trigger: %Nola.Trigger{type: :bang, args: expr_list}}}, + state + ) do expr = Enum.join(expr_list, " ") - result = try do - case Abacus.eval(expr) do - {:ok, result} -> result - error -> inspect(error) + + 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 - rescue - error -> if(error[:message], do: "#{error.message}", else: "erreur") - end + message.replyfun.("#{message.sender.nick}: #{expr} = #{result}") {:noreply, state} end @@ -33,5 +40,4 @@ defmodule Nola.Plugins.Calc do def handle_info(msg, state) do {:noreply, state} end - end diff --git a/lib/plugins/coronavirus.ex b/lib/plugins/coronavirus.ex index afd8a33..db9f646 100644 --- a/lib/plugins/coronavirus.ex +++ b/lib/plugins/coronavirus.ex @@ -1,6 +1,7 @@ defmodule Nola.Plugins.Coronavirus do require Logger NimbleCSV.define(CovidCsv, separator: ",", escape: "\"") + @moduledoc """ # Corona Virus @@ -19,7 +20,7 @@ defmodule Nola.Plugins.Coronavirus do end def init(_) do - {:ok, _} = Registry.register(Nola.PubSub, "trigger:coronavirus", [plugin: __MODULE__]) + {:ok, _} = Registry.register(Nola.PubSub, "trigger:coronavirus", plugin: __MODULE__) {:ok, nil, {:continue, :init}} :ignore end @@ -38,53 +39,87 @@ defmodule Nola.Plugins.Coronavirus do {:noreply, %{data: data}} end - def handle_info({:irc, :trigger, "coronavirus", m = %Nola.Message{trigger: %{type: :bang, args: args}}}, state) when args in [ - [], ["morts"], ["confirmés"], ["soignés"], ["malades"], ["n"], ["nmorts"], ["nsoignés"], ["nconfirmés"]] do - {field, name} = case args do - ["confirmés"] -> {:confirmed, "confirmés"} - ["morts"] -> {:deaths, "morts"} - ["soignés"] -> {:recovered, "soignés"} - ["nmorts"] -> {:new_deaths, "nouveaux morts"} - ["nconfirmés"] -> {:new_confirmed, "nouveaux confirmés"} - ["n"] -> {:new_current, "nouveaux malades"} - ["nsoignés"] -> {:new_recovered, "nouveaux soignés"} - _ -> {:current, "malades"} - end - IO.puts("FIELD #{inspect field}") + def handle_info( + {:irc, :trigger, "coronavirus", m = %Nola.Message{trigger: %{type: :bang, args: args}}}, + state + ) + when args in [ + [], + ["morts"], + ["confirmés"], + ["soignés"], + ["malades"], + ["n"], + ["nmorts"], + ["nsoignés"], + ["nconfirmés"] + ] do + {field, name} = + case args do + ["confirmés"] -> {:confirmed, "confirmés"} + ["morts"] -> {:deaths, "morts"} + ["soignés"] -> {:recovered, "soignés"} + ["nmorts"] -> {:new_deaths, "nouveaux morts"} + ["nconfirmés"] -> {:new_confirmed, "nouveaux confirmés"} + ["n"] -> {:new_current, "nouveaux malades"} + ["nsoignés"] -> {:new_recovered, "nouveaux soignés"} + _ -> {:current, "malades"} + end + + IO.puts("FIELD #{inspect(field)}") field_evol = String.to_atom("new_#{field}") - sorted = state.data - |> Enum.filter(fn({_, %{region: region}}) -> region == true end) - |> Enum.map(fn({location, data}) -> {location, Map.get(data, field, 0), Map.get(data, field_evol, 0)} end) - |> Enum.sort_by(fn({_,count,_}) -> count end, &>=/2) - |> Enum.take(10) - |> Enum.with_index() - |> Enum.map(fn({{location, count, evol}, index}) -> - ev = if String.starts_with?(name, "nouveaux") do - "" - else - " (#{Util.plusminus(evol)})" - end - "##{index+1}: #{location} #{count}#{ev}" - end) - |> Enum.intersperse(" - ") - |> Enum.join() + + 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 = %Nola.Message{trigger: %{type: :bang, args: location}}}, state) do + def handle_info( + {:irc, :trigger, "coronavirus", + m = %Nola.Message{trigger: %{type: :bang, args: location}}}, + state + ) do location = Enum.join(location, " ") |> String.downcase() + if data = Map.get(state.data, location) do - m.replyfun.("coronavirus: #{location}: " - <> "#{data.current} malades (#{Util.plusminus(data.new_current)}), " - <> "#{data.confirmed} confirmés (#{Util.plusminus(data.new_confirmed)}), " - <> "#{data.deaths} morts (#{Util.plusminus(data.new_deaths)}), " - <> "#{data.recovered} soignés (#{Util.plusminus(data.new_recovered)}) (@ #{data.update})") + 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 = %Nola.Message{trigger: %{type: :query, args: location}}}, state) do + def handle_info( + {:irc, :trigger, "coronavirus", + m = %Nola.Message{trigger: %{type: :query, args: location}}}, + state + ) do m.replyfun.("https://github.com/CSSEGISandData/COVID-19") {:noreply, state} end @@ -93,80 +128,125 @@ defmodule Nola.Plugins.Coronavirus do # 2. Fetch yesterday if no results defp fetch_data(current_data, date \\ nil) do now = Date.utc_today() - url = fn(date) -> + + 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) + 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}" + 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, + 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 } - 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) + 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 + } - acc = if state && state != "" do - Map.put(acc, state, entry) - else + 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 + end) - other -> - Logger.info("Coronavirus line failed: #{inspect line}") - acc - end - end) - Logger.info "Updated coronavirus database" + Logger.info("Updated coronavirus database") {data, :timer.minutes(60)} + {:ok, %HTTPoison.Response{status_code: 404}} -> - Logger.debug "Corona 404 #{cur_url}" + 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}" + 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 b50733b..0d3f5bd 100644 --- a/lib/plugins/correction.ex +++ b/lib/plugins/correction.ex @@ -6,13 +6,14 @@ defmodule Nola.Plugins.Correction do """ def irc_doc, do: @moduledoc + def start_link() do GenServer.start_link(__MODULE__, [], name: __MODULE__) end def init(_) do - {:ok, _} = Registry.register(Nola.PubSub, "messages", [plugin: __MODULE__]) - {:ok, _} = Registry.register(Nola.PubSub, "triggers", [plugin: __MODULE__]) + {:ok, _} = Registry.register(Nola.PubSub, "messages", plugin: __MODULE__) + {:ok, _} = Registry.register(Nola.PubSub, "triggers", plugin: __MODULE__) {:ok, %{}} end @@ -27,33 +28,40 @@ defmodule Nola.Plugins.Correction do 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) + 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") + + _ -> + 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 + 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 dcf7d0b..be0d6f4 100644 --- a/lib/plugins/dice.ex +++ b/lib/plugins/dice.ex @@ -21,23 +21,24 @@ defmodule Nola.Plugins.Dice do end def init([]) do - {:ok, _} = Registry.register(Nola.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) -> + 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 + {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) @@ -49,18 +50,20 @@ defmodule Nola.Plugins.Dice do end defp roll(state, message, faces, 1) when faces > 0 do - random = :crypto.rand_uniform(1, faces+1) + 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, 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 3ecc2fb..68afc48 100644 --- a/lib/plugins/finance.ex +++ b/lib/plugins/finance.ex @@ -27,25 +27,31 @@ defmodule Nola.Plugins.Finance do @crypto_list "http://www.alphavantage.co/digital_currency_list/" HTTPoison.start() - load_currency = fn(url) -> + + 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) + |> Enum.map(fn line -> + [symbol, name] = + line + |> String.strip() + |> String.split(",", parts: 2) + {symbol, name} end) - |> Enum.into(Map.new) + |> 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 @@ -58,92 +64,135 @@ defmodule Nola.Plugins.Finance do {:ok, nil} end + def handle_info( + {:irc, :trigger, "stocks", message = %{trigger: %{type: :query, args: args = search}}}, + state + ) do + search(search, message) + {:noreply, state} + end - def handle_info({:irc, :trigger, "stocks", message = %{trigger: %{type: :query, args: args = search}}}, state) do + defp search(search, message) do search = Enum.join(search, "%20") - url = "https://www.alphavantage.co/query?function=SYMBOL_SEARCH&keywords=#{search}&apikey=#{api_key()}" + + 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) + IO.inspect(data) + if error = Map.get(data, "Error Message") do - Logger.error("AlphaVantage API invalid request #{url} - #{inspect error}") + 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 + 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}" + 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})") + 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()}" + 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) + IO.inspect(data) + + case data do + %{"Error Message" => error} -> + Logger.error("AlphaVantage API invalid request #{url} - #{inspect(error)}") + message.replyfun.("stocks: error: #{error}") + + %{"Global Quote" => data = %{"01. symbol" => _}} -> + 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) + + _ -> + message.replyfun.("stocks: unknown symbol: #{symbol}") + search([symbol], message) end + {:ok, resp = %HTTPoison.Response{status_code: code}} -> - Logger.error "AlphaVantage API error: #{code} #{url} - #{inspect resp}" + 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})") + 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()}" - 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}") + Logger.error("AlphaVantage API invalid request #{url} - #{inspect(error)}") message.replyfun.("forex: requête invalide") else data = Map.get(data, "Realtime Currency Exchange Rate") @@ -151,34 +200,47 @@ defmodule Nola.Plugins.Finance do 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})") + 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}" + 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})") + 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 + 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(", ") + + 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 @@ -186,5 +248,4 @@ defmodule Nola.Plugins.Finance 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 deleted file mode 100644 index 8ee8c10..0000000 --- a/lib/plugins/gpt.ex +++ /dev/null @@ -1,355 +0,0 @@ -defmodule Nola.Plugins.Gpt do - require Logger - import Nola.Plugins.TempRefHelper - - def irc_doc() do - """ - # OpenAI GPT - - Uses OpenAI's GPT-3 API to bring natural language prompts to your IRC channel. - - _prompts_ are pre-defined prompts and parameters defined in the bot' CouchDB. - - _Runs_ (results of the inference of a _prompt_) are also stored in CouchDB and - may be resumed. - - * **!gpt** list GPT prompts - * **!gpt `[prompt]` `<prompt or args>`** run a prompt - * **+gpt `[short ref|run id]` `<prompt or args>`** continue a prompt - * **?gpt offensive `<content>`** is content offensive ? - * **?gpt show `[short ref|run id]`** run information and web link - * **?gpt `[prompt]`** prompt information and web link - """ - end - - @couch_db "bot-plugin-openai-prompts" - @couch_run_db "bot-plugin-gpt-history" - @trigger "gpt" - - def start_link() do - GenServer.start_link(__MODULE__, [], name: __MODULE__) - end - - defstruct [:temprefs] - - def get_result(id) do - Couch.get(@couch_run_db, id) - end - - def get_prompt(id) do - Couch.get(@couch_db, id) - end - - def init(_) do - regopts = [plugin: __MODULE__] - {:ok, _} = Registry.register(Nola.PubSub, "trigger:#{@trigger}", regopts) - {:ok, %__MODULE__{temprefs: new_temp_refs()}} - end - - def handle_info({:irc, :trigger, @trigger, m = %Nola.Message{trigger: %Nola.Trigger{type: :bang, args: [prompt | args]}}}, state) do - case Couch.get(@couch_db, prompt) do - {:ok, prompt} -> {:noreply, prompt(m, prompt, Enum.join(args, " "), state)} - {:error, :not_found} -> - m.replyfun.("gpt: prompt '#{prompt}' does not exists") - {:noreply, state} - error -> - Logger.info("gpt: prompt load error: #{inspect error}") - m.replyfun.("gpt: database error") - {:noreply, state} - end - end - - def handle_info({:irc, :trigger, @trigger, m = %Nola.Message{trigger: %Nola.Trigger{type: :bang, args: []}}}, state) do - case Couch.get(@couch_db, "_all_docs") do - {:ok, %{"rows" => []}} -> m.replyfun.("gpt: no prompts available") - {:ok, %{"rows" => prompts}} -> - prompts = prompts |> Enum.map(fn(prompt) -> Map.get(prompt, "id") end) |> Enum.join(", ") - m.replyfun.("gpt: prompts: #{prompts}") - error -> - Logger.info("gpt: prompt load error: #{inspect error}") - m.replyfun.("gpt: database error") - end - {:noreply, state} - end - - def handle_info({:irc, :trigger, @trigger, m = %Nola.Message{trigger: %Nola.Trigger{type: :plus, args: [ref_or_id | args]}}}, state) do - id = lookup_temp_ref(ref_or_id, state.temprefs, ref_or_id) - case Couch.get(@couch_run_db, id) do - {:ok, run} -> - Logger.debug("+gpt run: #{inspect run}") - {:noreply, continue_prompt(m, run, Enum.join(args, " "), state)} - {:error, :not_found} -> - m.replyfun.("gpt: ref or id not found or expired: #{inspect ref_or_id} (if using short ref, try using full id)") - {:noreply, state} - error -> - Logger.info("+gpt: run load error: #{inspect error}") - m.replyfun.("gpt: database error") - {:noreply, state} - end - end - - def handle_info({:irc, :trigger, @trigger, m = %Nola.Message{trigger: %Nola.Trigger{type: :query, args: ["offensive" | text]}}}, state) do - text = Enum.join(text, " ") - {moderate?, moderation} = moderation(text, m.account.id) - reply = cond do - moderate? -> "⚠️ #{Enum.join(moderation, ", ")}" - !moderate? && moderation -> "👍" - !moderate? -> "☠️ error" - end - m.replyfun.(reply) - {:noreply, state} - end - - def handle_info({:irc, :trigger, @trigger, m = %Nola.Message{trigger: %Nola.Trigger{type: :query, args: ["show", ref_or_id]}}}, state) do - id = lookup_temp_ref(ref_or_id, state.temprefs, ref_or_id) - url = if m.channel do - NolaWeb.Router.Helpers.gpt_url(NolaWeb.Endpoint, :result, m.network, NolaWeb.format_chan(m.channel), id) - else - NolaWeb.Router.Helpers.gpt_url(NolaWeb.Endpoint, :result, id) - end - m.replyfun.("→ #{url}") - {:noreply, state} - end - - def handle_info({:irc, :trigger, @trigger, m = %Nola.Message{trigger: %Nola.Trigger{type: :query, args: [prompt]}}}, state) do - url = if m.channel do - NolaWeb.Router.Helpers.gpt_url(NolaWeb.Endpoint, :prompt, m.network, NolaWeb.format_chan(m.channel), prompt) - else - NolaWeb.Router.Helpers.gpt_url(NolaWeb.Endpoint, :prompt, prompt) - end - m.replyfun.("→ #{url}") - {:noreply, state} - end - - def handle_info(info, state) do - Logger.debug("gpt: unhandled info: #{inspect info}") - {:noreply, state} - end - - defp continue_prompt(msg, run, content, state) do - prompt_id = Map.get(run, "prompt_id") - prompt_rev = Map.get(run, "prompt_rev") - - original_prompt = case Couch.get(@couch_db, prompt_id, rev: prompt_rev) do - {:ok, prompt} -> prompt - _ -> nil - end - - if original_prompt do - continue_prompt = %{"_id" => prompt_id, - "_rev" => prompt_rev, - "type" => Map.get(original_prompt, "type"), - "parent_run_id" => Map.get(run, "_id"), - "openai_params" => Map.get(run, "request") |> Map.delete("prompt")} - - continue_prompt = case original_prompt do - %{"continue_prompt" => prompt_string} when is_binary(prompt_string) -> - 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}) - %{"messages" => _} -> - continue_prompt - |> Map.put("prompt", "{{content}}") - |> Map.put("prompt_format", "liquid") - |> Map.put("messages", Map.get(run, "messages")) - _ -> - 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 - - # Chat prompt - # "prompt" is the template for the initial user message - # "messages" is original messages to be put before the initial user one - defp prompt(msg, prompt = %{"type" => "chat", "prompt" => prompt_template, "messages" => messages}, content, state) do - Logger.debug("gpt_plugin:prompt/4 (chat) #{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 - - messages = Enum.map(messages, fn(%{"role" => role, "content" => text}) -> - text = case Map.get(prompt, "prompt_format", "liquid") do - "liquid" -> Tmpl.render(text, msg, Map.get(prompt, "prompt_liquid_variables", %{})) - "norender" -> text - end - %{"role" => role, "content" => text} - end) ++ [%{"role" => "user", "content" => prompt_text}] - - args = Map.get(prompt, "openai_params") - |> Map.put_new("model", "gpt-3.5-turbo") - |> Map.put("messages", messages) - |> 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/chat/completions", args) do - {:ok, %{"choices" => [%{"message" => %{"content" => 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, - "messages" => messages ++ [%{"role" => "assistant", "content" => text}], - "message_at" => msg.at, - "reply_at" => DateTime.utc_now(), - "gpt_id" => gpt_id, - "gpt_at" => created, - "gpt_usage" => usage, - "type" => "chat", - "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 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/helpers/temp_ref.ex b/lib/plugins/helpers/temp_ref.ex index 160169d..f4407d8 100644 --- a/lib/plugins/helpers/temp_ref.ex +++ b/lib/plugins/helpers/temp_ref.ex @@ -30,7 +30,7 @@ defmodule Nola.Plugins.TempRefHelper do length = Keyword.get(options, :length, 3) for _ <- 1..length, into: "", do: <<Enum.random('bcdfghjkmpqtrvwxy2346789')>> end - + def increase(options) do Keyword.put(options, :length, Keyword.get(options, :length, 3) + 1) end @@ -42,8 +42,13 @@ defmodule Nola.Plugins.TempRefHelper do max: Keyword.get(options, :max, []), expire: Keyword.get(options, :expire, :infinity), build_fun: Keyword.get(options, :build_fun, &__MODULE__.SimpleAlphaNumericBuilder.build/1), - build_increase_fun: Keyword.get(options, :build_increase_fun, &__MODULE__.SimpleAlphaNumericBuilder.increase/1), - build_options: Keyword.get(options, :build_options, [length: 3]) + build_increase_fun: + Keyword.get( + options, + :build_increase_fun, + &__MODULE__.SimpleAlphaNumericBuilder.increase/1 + ), + build_options: Keyword.get(options, :build_options, length: 3) } end @@ -58,6 +63,7 @@ defmodule Nola.Plugins.TempRefHelper do def put_temp_ref(data, state = %__MODULE__{}) do state = janitor_refs(state) key = new_nonexisting_key(state) + if key do ref = {key, DateTime.utc_now(), data} {key, %__MODULE__{state | refs: [ref | state.refs]}} @@ -78,18 +84,19 @@ defmodule Nola.Plugins.TempRefHelper do end defp new_nonexisting_key(state = %__MODULE__{refs: refs}, i \\ 1) do - build_options = if rem(i, 5) == 0 do - state.build_increase_fun.(state.build_options) - else - state.build_options - end - + build_options = + if rem(i, 5) == 0 do + state.build_increase_fun.(state.build_options) + else + state.build_options + end + key = state.build_fun.(state.build_options) + if !List.keymember?(refs, key, 0) do key else new_nonexisting_key(state, i + 1) end end - end diff --git a/lib/plugins/image.ex b/lib/plugins/image.ex deleted file mode 100644 index 446cb49..0000000 --- a/lib/plugins/image.ex +++ /dev/null @@ -1,246 +0,0 @@ -defmodule Nola.Plugins.Image do - require Logger - import Nola.Plugins.TempRefHelper - - def irc_doc() do - """ - # Image Generation - - * **`!d2 [-n 1..10] [-g 256, 512, 1024] <prompt>`** generate image(s) using OpenAI Dall-E 2 - * **`!sd [options] <prompt>`** generate image(s) using Stable Diffusion models (see below) - - ## !sd - - * `-m X` (sd2) Model (sd2: Stable Diffusion v2, sd1: Stable Diffusion v1.5, any3: Anything v3, any4: Anything v4, oj: OpenJourney) - * `-w X, -h X` (512) width and height. (128, 256, 384, 448, 512, 576, 640, 704, 768) - * `-n 1..10` (1) number of images to generate - * `-s X` (null) Seed - * `-S 0..500` (50) denoising steps - * `-X X` (KLMS) scheduler (DDIM, K_EULER, DPMSolverMultistep, K_EULER_ANCESTRAL, PNDM, KLMS) - * `-g 1..20` (7.5) guidance scale - * `-P 0.0..1.0` (0.8) prompt strength - """ - end - - def start_link() do - GenServer.start_link(__MODULE__, [], name: __MODULE__) - end - - defstruct [:temprefs] - - def init(_) do - regopts = [plugin: __MODULE__] - {:ok, _} = Registry.register(Nola.PubSub, "trigger:d2", regopts) - {:ok, _} = Registry.register(Nola.PubSub, "trigger:sd", regopts) - {:ok, %__MODULE__{temprefs: new_temp_refs()}} - end - - def handle_info({:irc, :trigger, "sd", msg = %Nola.Message{trigger: %Nola.Trigger{type: :bang, args: args}}}, state) do - {:noreply, case OptionParser.parse(args, aliases: [m: :model], strict: [model: :string]) do - {_, [], _} -> - msg.replyfun.("#{msg.sender.nick}: sd: missing prompt") - state - {opts, prompt, _} -> - process_sd(Keyword.get(opts, :model, "sd2"), Enum.join(prompt, " "), msg, state) - end} - end - - def handle_info({:irc, :trigger, "d2", msg = %Nola.Message{trigger: %Nola.Trigger{type: :bang, args: args}}}, state) do - opts = OptionParser.parse(args, - aliases: [n: :n, g: :geometry], - strict: [n: :integer, geometry: :integer] - ) - case opts do - {_opts, [], _} -> - msg.replyfun.("#{msg.sender.nick}: d2: missing prompt") - {:noreply, state} - {opts, prompts, _} -> - prompt = Enum.join(prompts, " ") - geom = Keyword.get(opts, :geometry, 256) - request = %{ - "prompt" => prompt, - "n" => Keyword.get(opts, :n, 1), - "size" => "#{geom}x#{geom}", - "response_format" => "b64_json", - "user" => msg.account.id, - } - - id = FlakeId.get() - - state = case OpenAi.post("/v1/images/generations", request) do - {:ok, %{"data" => data}} -> - urls = for {%{"b64_json" => b64}, idx} <- Enum.with_index(data) do - with {:ok, body} <- Base.decode64(b64), - <<smol_body::binary-size(20), _::binary>> = body, - {:ok, magic} <- GenMagic.Pool.perform(Nola.GenMagic, {:bytes, smol_body}), - bucket = Application.get_env(:nola, :s3, []) |> Keyword.get(:bucket), - s3path = "#{msg.account.id}/iD2#{id}#{idx}.png", - s3req = ExAws.S3.put_object(bucket, s3path, body, acl: :public_read, content_type: magic.mime_type), - {:ok, _} <- ExAws.request(s3req), - path = NolaWeb.Router.Helpers.url(NolaWeb.Endpoint) <> "/files/#{s3path}" - do - {:ok, path} - end - end - - urls = for {:ok, path} <- urls, do: path - msg.replyfun.("#{msg.sender.nick}: #{Enum.join(urls, " ")}") - state - {:error, atom} when is_atom(atom) -> - Logger.error("dalle2: #{inspect atom}") - msg.replyfun.("#{msg.sender.nick}: dalle2: ☠️ #{to_string(atom)}") - state - error -> - Logger.error("dalle2: #{inspect error}") - msg.replyfun.("#{msg.sender.nick}: dalle2: ☠️ ") - state - end - {:noreply, state} - end - end - - defp process_sd(model, prompt, msg, state) do - {general_opts, _, _} = OptionParser.parse(msg.trigger.args, - aliases: [n: :number, w: :width, h: :height], - strict: [number: :integer, width: :integer, height: :integer] - ) - - general_opts = general_opts - |> Keyword.put_new(:number, 1) - - case sd_model(model, prompt, general_opts, msg.trigger.args) do - {:ok, env} -> - base_url = "https://api.runpod.ai/v1/#{env.name}" - {headers, options} = runpod_headers(env, state) - result = with {:ok, json} <- Poison.encode(%{"input" => env.request}), - {:ok, %HTTPoison.Response{status_code: 200, body: body}} <- HTTPoison.post("#{base_url}/run", json, headers, options), - {:ok, %{"id" => id} = data} <- Poison.decode(body) do - Logger.debug("runpod: started job #{id}: #{inspect data}") - spawn(fn() -> runpod_result_loop("#{base_url}/status/#{id}", env, msg, state) end) - :ok - else - {:ok, %HTTPoison.Response{status_code: code}} -> {:error, Plug.Conn.Status.reason_atom(code)} - {:error, %HTTPoison.Error{reason: reason}} -> {:error, reason} - end - - case result do - {:error, reason} -> - Logger.error("runpod: http error for #{base_url}/run: #{inspect reason}") - msg.replyfun.("#{msg.sender.nick}: sd: runpod failed: #{inspect reason}") - _ -> :ok - end - {:error, error} -> - msg.replyfun.("#{msg.sender.nick}: sd: #{error}") - end - - state - end - - defp runpod_result_loop(url, env, msg, state) do - Logger.debug("runpod_result_loop: new") - {headers, options} = runpod_headers(env, state) - with {:ok, %HTTPoison.Response{status_code: 200, body: body}} <- HTTPoison.get(url, headers ++ [{"content-type", "application/json"}], options), - {:ok, %{"status" => "COMPLETED"} = data} <- Poison.decode(body) do - id = FlakeId.get() - tasks = for {%{"image" => url, "seed" => seed}, idx} <- Enum.with_index(Map.get(data, "output", [])) do - Task.async(fn() -> -with {:ok, %HTTPoison.Response{status_code: 200, body: body}} <- HTTPoison.get(url, [], options), -bucket = Application.get_env(:nola, :s3, []) |> Keyword.get(:bucket), -s3path = "#{msg.account.id}/iR#{env.nick}#{id}#{idx}-#{seed}.png", -s3req = ExAws.S3.put_object(bucket, s3path, body, acl: :public_read, content_type: "image/png"), -{:ok, _} <- ExAws.request(s3req), -path = NolaWeb.Router.Helpers.url(NolaWeb.Endpoint) <> "/files/#{s3path}" -do - {:ok, path} -else - error -> - Logger.error("runpod_result: error while uploading #{url}: #{inspect error}") - {:error, error} -end - end) - end - |> Task.yield_many(5000) - |> Enum.map(fn {task, res} -> - res || Task.shutdown(task, :brutal_kill) - end) - - results = for({:ok, {:ok, url}} <- tasks, do: url) - - msg.replyfun.("#{msg.sender.nick}: #{Enum.join(results, " ")}") - else - {:ok, %{"status" => "FAILED"} = data} -> - Logger.error("runpod_result_loop: job FAILED: #{inspect data}") - msg.replyfun.("#{msg.sender.nick}: sd: job failed: #{Map.get(data, "error", "error")}") - {:ok, %{"status" => _} = data} -> - Logger.debug("runpod_result_loop: not completed: #{inspect data}") - :timer.sleep(:timer.seconds(1)) - runpod_result_loop(url, env, msg, state) - {:ok, %HTTPoison.Response{status_code: 403}} -> - msg.replyfun.("#{msg.sender.nick}: sd: runpod failure: unauthorized") - error -> - Logger.warning("image: sd: runpod http error: #{inspect error}") - :timer.sleep(:timer.seconds(2)) - runpod_result_loop(url, env, msg, state) - end - end - - defp runpod_headers(_env, _state) do - config = Application.get_env(:nola, :runpod, []) - headers = [{"user-agent", "nola.lol bot, href@random.sh"}, - {"authorization", "Bearer " <> Keyword.get(config, :key, "unset-api-key")}] - options = [timeout: :timer.seconds(180), recv_timeout: :timer.seconds(180)] - {headers, options} - end - - defp sd_model(name, _, general_opts, opts) when name in ~w(sd2 sd1 oj any any4) do - {opts, prompt, _} = OptionParser.parse(opts, [ - aliases: [P: :strength, s: :seed, S: :steps, g: :guidance, X: :scheduler, q: :negative], - strict: [strength: :float, steps: :integer, guidance: :float, scheduler: :string, seed: :integer, negative: :keep] - ]) - opts = general_opts ++ opts - prompt = Enum.join(prompt, " ") - - negative = case Keyword.get_values(opts, :negative) do - [] -> nil - list -> Enum.join(list, " ") - end - - full_name = case name do - "sd2" -> "stable-diffusion-v2" - "sd1" -> "stable-diffusion-v1" - "oj" -> "sd-openjourney" - "any" -> "sd-anything-v3" - "any4" -> "sd-anything-v4" - end - - default_scheduler = case name do - "sd2" -> "KLMS" - _ -> "K-LMS" - end - - request = %{ - "prompt" => prompt, - "num_outputs" => general_opts[:number], - "width" => opts[:width] || 512, - "height" => opts[:height] || 512, - "prompt_strength" => opts[:strength] || 0.8, - "num_inference_steps" => opts[:steps] || 30, - "guidance_scale" => opts[:guidance] || 7.5, - "scheduler" => opts[:scheduler] || default_scheduler, - "seed" => opts[:seed] || :rand.uniform(100_000_00) - } - - request = if negative do - Map.put(request, "negative_prompt", negative) - else - request - end - - {:ok, %{name: full_name, nick: name, request: request}} - end - - defp sd_model(name, _, _, _) do - {:error, "unsupported model: \"#{name}\""} - end - -end diff --git a/lib/plugins/kick_roulette.ex b/lib/plugins/kick_roulette.ex index b25b46d..2194775 100644 --- a/lib/plugins/kick_roulette.ex +++ b/lib/plugins/kick_roulette.ex @@ -6,27 +6,28 @@ defmodule Nola.Plugins.KickRoulette do """ def irc_doc, do: @moduledoc + def start_link() do GenServer.start_link(__MODULE__, [], name: __MODULE__) end def init([]) do - {:ok, _} = Registry.register(Nola.PubSub, "trigger: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 + def handle_info({:irc, :trigger, "kick", message = %{trigger: %{type: :bang, args: _}}}, _) do if 5 == :crypto.rand_uniform(1, 6) do - spawn(fn() -> + 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 b7d0a92..0d513c4 100644 --- a/lib/plugins/last_fm.ex +++ b/lib/plugins/last_fm.ex @@ -23,34 +23,55 @@ defmodule Nola.Plugins.LastFm do def init([]) do regopts = [plugin: __MODULE__] for t <- @pubsub_topics, do: {:ok, _} = Registry.register(Nola.PubSub, t, regopts) - dets_filename = (Nola.data_path() <> "/" <> "lastfm.dets") |> String.to_charlist + 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 + 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}\".") + + 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 + 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 + 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 @@ -59,18 +80,24 @@ defmodule Nola.Plugins.LastFm do {:noreply, state} end - def handle_info({:irc, :trigger, _, message = %{trigger: %{type: :bang, args: [nick_or_user]}}}, state) do + 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 = Nola.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) + 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 @@ -84,40 +111,50 @@ defmodule Nola.Plugins.LastFm 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 = Nola.Account.get(nick_or_user) || Nola.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 + id_or_user = + if account = + Nola.Account.get(nick_or_user) || + Nola.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 = Nola.Account.get(id_or_user) do - user = Nola.UserTrack.find_by_account(message.network, account) - if(user, do: user.nick, else: account.name) - else - username - end + + user = + if account = Nola.Account.get(id_or_user) do + user = Nola.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 @@ -125,32 +162,57 @@ defmodule Nola.Plugins.LastFm do 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 + + 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)}"} + {: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}" + Logger.error("Lastfm http error: #{inspect(error)}") :error end end - defp fetch_track(user, %{"recenttracks" => %{"track" => [ t = %{"name" => name, "artist" => %{"name" => artist}} | _]}}) do + + 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) + + 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}" + Logger.error("Lastfm http error: #{inspect(error)}") :error end end - defp format_now_playing(%{"recenttracks" => %{"track" => [track = %{"@attr" => %{"nowplaying" => "true"}} | _]}}, et) do + defp format_now_playing( + %{"recenttracks" => %{"track" => [track = %{"@attr" => %{"nowplaying" => "true"}} | _]}}, + et + ) do format_track(true, track, et) end @@ -173,15 +235,16 @@ defmodule Nola.Plugins.LastFm do 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(", ") + + 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 4c4261f..0dca6ae 100644 --- a/lib/plugins/link.ex +++ b/lib/plugins/link.ex @@ -37,57 +37,53 @@ defmodule Nola.Plugins.Link do def short_irc_doc, do: false def irc_doc, do: @ircdoc require Logger + alias __MODULE__.Quirks + alias __MODULE__.Store + alias __MODULE__.Scraper 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 + @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(Nola.PubSub, "messages", [plugin: __MODULE__]) - #{:ok, _} = Registry.register(Nola.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) -> + |> 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) + if Store.inhibit_link?(word, {message.network, message.channel}) do + Logger.debug("link inhibited #{word}") + else + handle_link(word, uri, message) + end end end end) + {:noreply, state} end @@ -99,6 +95,48 @@ defmodule Nola.Plugins.Link do :ok end + def handle_link(url, uri, message) do + spawn(fn -> + :timer.kill_after(:timer.seconds(30)) + + store = Store.get_link(url) + + case store || expand_link([uri]) do + {:ok, uris, text} = save -> + text = + case uris do + [uri] -> + text + + [luri | _] -> + if luri.host == uri.host && luri.path == uri.path do + text + else + ["-> #{URI.to_string(luri)}", text] + end + end + + case text do + lines when is_list(lines) -> + for text <- lines, do: message.replyfun.(text) + if !store, do: Store.insert_link(url, save) + Store.witness_link(url, {message.network, message.channel}) + + text when is_binary(text) -> + message.replyfun.(text) + if !store, do: Store.insert_link(url, save) + Store.witness_link(url, {message.network, message.channel}) + + nil -> + nil + end + + _ -> + nil + end + end) + end + # 1. Match the first valid handler # 2. Try to run the handler # 3. If :error or crash, default link. @@ -110,17 +148,24 @@ defmodule Nola.Plugins.Link 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) + 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 -> + module = Module.concat([module]) + + case module.match(uri, opts) do + {true, params} -> + Logger.debug("link: will expand with #{inspect(module)} for #{inspect(uri)}") + {:halt, {module, params, opts}} + + false -> + {:cont, acc} + end + end) + run_expand(acc, handler) end @@ -128,21 +173,27 @@ defmodule Nola.Plugins.Link do expand_default(acc) end - def run_expand(acc=[uri|_], {module, params, opts}) do - Logger.debug("link: expanding #{inspect uri} with #{inspect module}") + def run_expand(acc = [uri | _], {module, params, opts}) do case module.expand(uri, params, opts) do - {:ok, data} -> {:ok, acc, data} - :error -> expand_default(acc) - :skip -> nil + {:ok, data} -> + Logger.debug("link: expanded #{inspect(uri)} with #{inspect(module)}") + {:ok, acc, data} + + :error -> + Logger.error("Error expanding URL #{uri} with #{inspect(module)}") + expand_default(acc) + + :skip -> + nil end rescue e -> - Logger.error("link: rescued #{inspect uri} with #{inspect module}: #{inspect 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}}") + Logger.error("link: catched #{inspect(uri)} with #{inspect(module)}: #{inspect({e, b})}") expand_default(acc) end @@ -155,40 +206,48 @@ defmodule Nola.Plugins.Link do 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) + 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} + 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 - rescue - e -> - Logger.error(inspect(e)) - {:cont, false} - catch - e, b -> - Logger.error(inspect({b})) - {:cont, false} - end - end) + end) cond do handler != false and length <= 30_000_000 -> case get_body(url, 30_000_000, client, handler, <<>>) do - {:ok, _} = ok -> ok + {:ok, _} = ok -> + ok + :error -> {:ok, "file: #{content_type}, size: #{human_size(length)}"} end - #String.starts_with?(content_type, "text/html") && length <= 30_000_000 -> + + # String.starts_with?(content_type, "text/html") && length <= 30_000_000 -> # get_body(url, 30_000_000, client, <<>>) true -> :hackney.close(client) @@ -197,62 +256,94 @@ defmodule Nola.Plugins.Link do 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) + 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 + defp get_req(url, {:ok, status, headers, client}) do + Logger.error("Error fetching URL #{url} = #{status}") :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 + 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 >>) + 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 + body = + case mode do + :body -> + acc + + :file -> + {:ok, tmpfile} = Plug.Upload.random_file("linkplugin") + File.write!(tmpfile, acc) + tmpfile + end + + Logger.debug("expanding body with #{inspect(handler)}: #{inspect(body)}") handler.post_expand(url, body, params, opts) + {:error, reason} -> - {:ok, "failed to fetch body: #{inspect 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] + + uri = Quirks.uri(uri) + + headers = [ + {"user-agent", Quirks.user_agent(uri.host)} + ] + + proxy = Keyword.get(Application.get_env(:nola, __MODULE__, []), :proxy, nil) + options = [follow_redirect: false, max_body_length: 30_000_000, proxy: proxy] + url = URI.to_string(uri) + 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} when status in [400, 403] -> + Logger.warning("Was denied to fetch URL, using scraper #{url} = #{status}") + retry_expand_with_scraper(acc, url) + {:error, status, _headers} -> - text = Plug.Conn.Status.reason_phrase(status) - {:ok, acc, "Error: HTTP #{text} (#{status})"} + Logger.error("Error fetching URL #{url} = #{status}") + {:ok, acc, nil} + {:error, {:tls_alert, {:handshake_failure, err}}} -> - {:ok, acc, "TLS Error: #{to_string(err)}"} + Logger.error("Error fetching URL #{url} = TLS Error: #{to_string(err)}") + {:ok, acc, nil} + + {:error, :timeout} -> + Logger.error("Error fetching URL #{url} = timeout") + retry_expand_with_scraper(acc, url) + {:error, reason} -> - {:ok, acc, "Error: #{to_string(reason)}"} + Logger.error("Error fetching URL #{url} = #{to_string(reason)}") + {:ok, acc, nil} end end @@ -261,6 +352,27 @@ defmodule Nola.Plugins.Link do {:ok, [uri], "-> #{URI.to_string(uri)}"} end + # Last resort: scrape the page + # We'll be mostly calling this when 403 or 500 or timeout because site blocks us. + # An external service will scrape the page for us and return the body. + # We'll call directly the HTML handler on the result. + defp retry_expand_with_scraper(acc, url) do + Logger.info("Attempting scraper") + handlers = Keyword.get(Application.get_env(:nola, __MODULE__), :handlers) + Logger.info("Attempting scraper #{inspect(handlers)}") + + with true <- Keyword.has_key?(handlers, :"Nola.Plugins.Link.HTML"), + {:ok, body, _meta} <- Scraper.get(url), + {:ok, text} <- __MODULE__.HTML.post_expand(url, body, nil, nil) do + {:ok, acc, text} + else + error -> + Logger.debug("Attempt with scraper failed: #{inspect(error)}") + # We give up here. We don't return anything (the acc from caller `expand default` + # does not matter anymore) and I see returning error messages as useless. + {:ok, acc, nil} + end + end defp human_size(bytes) do bytes diff --git a/lib/plugins/link/data/quirks_rewrite_host.txt b/lib/plugins/link/data/quirks_rewrite_host.txt new file mode 100644 index 0000000..0533ce7 --- /dev/null +++ b/lib/plugins/link/data/quirks_rewrite_host.txt @@ -0,0 +1,27 @@ +# list of host rewrites +# fixups: https://gist.github.com/Lexedia/bbbde4dbbf628b0bfe8476a96a977a8f + +x.com:fxtwitter.com +twitter.com:fxtwitter.com +vxtwitter.com:fxtwitter.com +girlcockx.com:fxtwitter.com +stupidpenisx.com:fxtwitter.com + +bsky.app:xsky.app + +instagram.com:ddinstagram.com + +tumblr.com:tpmblr.com + +# reddit: new reddit don't have titles for pages we don't handle in the reddit module +# fallback to old. which has nice titles +reddit.com:old.reddit.com + +pixiv.net:phixiv.net + +threads.net:fixthreads.net + +tiktok.com:tnktok.com +vm.tiktok.com:vm.tnktok.com + +twitch.tv:fxtwitch.tv diff --git a/lib/plugins/link/data/quirks_telegram_bot_user_agent.txt b/lib/plugins/link/data/quirks_telegram_bot_user_agent.txt new file mode 100644 index 0000000..3008272 --- /dev/null +++ b/lib/plugins/link/data/quirks_telegram_bot_user_agent.txt @@ -0,0 +1,30 @@ +# list of domains which needs a telegram bot user agent +# to return proper og:* meta tags +# www. subdomain is added automatically + +x.com +vxtwitter.com +fxtwitter.com +fixupx.com + +facebook.com + +instagram.com +xnstagram.com +ddinstagram.com + +tpmblr.com + +reddit.com +old.reddit.com + +xsky.app + +phixiv.net + +fixthreads.net + +tnktok.com +vm.tnktok.com + +fxtwitch.tv diff --git a/lib/plugins/link/github.ex b/lib/plugins/link/github.ex index 0069a40..fcd76a0 100644 --- a/lib/plugins/link/github.ex +++ b/lib/plugins/link/github.ex @@ -3,11 +3,10 @@ defmodule Nola.Plugins.Link.Github do @impl true def match(uri = %URI{host: "github.com", path: path}, _) do - case String.split(path, "/") do - ["", user, repo] -> - {true, %{user: user, repo: repo, path: "#{user}/#{repo}"}} - _ -> - false + with ["", user, repo] <- String.split(path, "/") do + {true, %{user: user, repo: repo, path: "#{user}/#{repo}"}} + else + _ -> false end end @@ -18,32 +17,66 @@ defmodule Nola.Plugins.Link.Github do @impl true def expand(_uri, %{user: user, repo: repo}, _opts) do - case HTTPoison.get("https://api.github.com/repos/#{user}/#{repo}") do - {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> - {:ok, json} = Jason.decode(body) - src = json["source"]["full_name"] - disabled = if(json["disabled"], do: " (disabled)", else: "") - archived = if(json["archived"], do: " (archived)", else: "") - fork = if src && src != json["full_name"] do - " (⑂ #{json["source"]["full_name"]})" - else - "" - end - start = "#{json["full_name"]}#{disabled}#{archived}#{fork} - #{json["description"]}" - tags = for(t <- json["topics"]||[], do: "##{t}") |> Enum.intersperse(", ") |> Enum.join("") - lang = if(json["language"], do: "#{json["language"]} - ", else: "") - issues = if(json["open_issues_count"], do: "#{json["open_issues_count"]} issues - ", else: "") - last_push = if at = json["pushed_at"] do - {:ok, date, _} = DateTime.from_iso8601(at) - " - last pushed #{DateTime.to_string(date)}" - else - "" - end - network = "#{lang}#{issues}#{json["stargazers_count"]} stars - #{json["subscribers_count"]} watchers - #{json["forks_count"]} forks#{last_push}" - {:ok, [start, tags, network]} - other -> - :error + with {:ok, response} <- HTTPoison.get("https://api.github.com/repos/#{user}/#{repo}"), + {:ok, json} <- Jason.decode(response.body) do + info = %{ + full_name: json["full_name"], + disabled: json["disabled"], + archived: json["archived"], + source: json["source"], + description: json["description"], + topics: json["topics"], + language: json["language"], + open_issues_count: json["open_issues_count"], + pushed_at: json["pushed_at"], + stargazers_count: json["stargazers_count"], + subscribers_count: json["subscribers_count"], + forks_count: json["forks_count"] + } + + start = build_start(info) + tags = build_tags(info) + network = build_network(info) + + {:ok, [start, tags, network]} + else + _ -> :error end end + defp build_start(info) do + parts = + [] + |> maybe_add(info.disabled, " (disabled)") + |> maybe_add(info.archived, " (archived)") + |> maybe_add( + info.source && info.source["full_name"] != info.full_name, + " (⑂ #{info.source["full_name"]})" + ) + + "#{info.full_name}#{parts} - #{info.description}" + end + + defp build_tags(info) do + for(t <- info.topics || [], do: "##{t}") |> Enum.intersperse(", ") |> Enum.join("") + end + + defp build_network(info) do + lang = (info.language && "#{info.language} - ") || "" + issues = (info.open_issues_count && "#{info.open_issues_count} issues - ") || "" + + last_push = + if at = info.pushed_at do + {:ok, date, _} = DateTime.from_iso8601(at) + " - last pushed #{DateTime.to_string(date)}" + else + "" + end + + "#{lang}#{issues}#{info.stargazers_count} stars - #{info.subscribers_count} watchers - #{info.forks_count} forks#{last_push}" + end + + defp maybe_add(acc, condition, value) do + if condition, do: acc ++ [value], else: acc + end end diff --git a/lib/plugins/link/html.ex b/lib/plugins/link/html.ex index a941aac..7df1bfc 100644 --- a/lib/plugins/link/html.ex +++ b/lib/plugins/link/html.ex @@ -5,102 +5,154 @@ defmodule Nola.Plugins.Link.HTML do def match(_, _), do: false @impl true - def post_match(_url, "text/html"<>_, _header, _opts) do - {:body, nil} - end + def post_match(_url, "text/html" <> _, _header, _opts), do: {:body, nil} def post_match(_, _, _, _), do: false @impl true def post_expand(url, body, _params, _opts) do - html = Floki.parse(body) - title = collect_title(html) + {:ok, html} = Floki.parse_document(body) opengraph = collect_open_graph(html) - itemprops = collect_itemprops(html) - text = if Map.has_key?(opengraph, "title") && Map.has_key?(opengraph, "description") do - sitename = if sn = Map.get(opengraph, "site_name") do - "#{sn}" - else - "" - end - paywall? = if Map.get(opengraph, "article:content_tier", Map.get(itemprops, "article:content_tier", "free")) == "free" do - "" - else - "[paywall] " - end - section = if section = Map.get(opengraph, "article:section", Map.get(itemprops, "article:section", nil)) do - ": #{section}" - else - "" - end - date = case DateTime.from_iso8601(Map.get(opengraph, "article:published_time", Map.get(itemprops, "article:published_time", ""))) do - {:ok, date, _} -> - "#{Timex.format!(date, "%d/%m/%y", :strftime)}. " - _ -> - "" - end - uri = URI.parse(url) - prefix = "#{paywall?}#{Map.get(opengraph, "site_name", uri.host)}#{section}" - prefix = unless prefix == "" do - "#{prefix} — " + text = + if has_sufficient_opengraph_data?(opengraph) do + generate_text_from_opengraph(url, html, opengraph) else - "" + clean_text(collect_title(html)) end - [clean_text("#{prefix}#{Map.get(opengraph, "title")}")] ++ Nola.Irc.Message.splitlong(clean_text("#{date}#{Map.get(opengraph, "description")}")) - else - clean_text(title) - end + {:ok, text} end + defp has_sufficient_opengraph_data?(opengraph) do + Map.has_key?(opengraph, "title") && Map.has_key?(opengraph, "description") + end + + defp generate_text_from_opengraph(url, html, opengraph) do + itemprops = collect_itemprops(html) + prefix = collect_prefix_and_site_name(url, opengraph, itemprops) + title = Map.get(opengraph, "title") + description = collect_description(opengraph, itemprops, title, 400) + + [clean_text("#{prefix}#{title}")] ++ description + end + defp collect_title(html) do case Floki.find(html, "title") do - [{"title", [], [title]} | _] -> - String.trim(title) - _ -> - nil + [{"title", [], [title]} | _] -> String.trim(title) + _ -> "" end end defp collect_open_graph(html) do - Enum.reduce(Floki.find(html, "head meta"), %{}, fn(tag, acc) -> - case tag do - {"meta", values, []} -> - name = List.keyfind(values, "property", 0, {nil, nil}) |> elem(1) - content = List.keyfind(values, "content", 0, {nil, nil}) |> elem(1) - case name do - "og:" <> key -> - Map.put(acc, key, content) - "article:"<>_ -> - Map.put(acc, name, content) - _other -> acc - end - _other -> acc - end - end) + Floki.find(html, "head meta") + |> Enum.reverse() + |> Enum.reduce(%{}, &extract_meta_tag/2) + end + + defp extract_meta_tag({"meta", values, []}, acc) do + with {_, name} <- List.keyfind(values, "property", 0, {nil, nil}), + {_, content} <- List.keyfind(values, "content", 0, {nil, nil}), + true <- is_valid_meta_tag?(name) do + Map.put(acc, strip_prefix(name), content) + else + _ -> acc + end + end + + defp extract_meta_tag(_, acc), do: acc + + defp is_valid_meta_tag?(nil) do + false + end + + defp is_valid_meta_tag?(name) do + String.starts_with?(name, "og:") || String.starts_with?(name, "article:") end + defp strip_prefix("og:" <> key), do: key + defp strip_prefix(other), do: other + defp collect_itemprops(html) do - Enum.reduce(Floki.find(html, "[itemprop]"), %{}, fn(tag, acc) -> - case tag do - {"meta", values, []} -> - name = List.keyfind(values, "itemprop", 0, {nil, nil}) |> elem(1) - content = List.keyfind(values, "content", 0, {nil, nil}) |> elem(1) - case name do - "article:" <> key -> - Map.put(acc, name, content) - _other -> acc - end - _other -> acc - end - end) + Floki.find(html, "[itemprop]") + |> Enum.reduce(%{}, &extract_itemprop/2) + end + + defp extract_itemprop({"meta", values, []}, acc) do + with {_, name} <- List.keyfind(values, "itemprop", 0, {nil, nil}), + {_, content} <- List.keyfind(values, "content", 0, {nil, nil}), + true <- String.starts_with?(name, "article:") do + Map.put(acc, name, content) + else + _ -> acc + end + end + + defp extract_itemprop(_, acc), do: acc + + defp collect_prefix_and_site_name(url, opengraph, itemprops) do + uri = URI.parse(url) + site_name = Map.get(opengraph, "site_name", uri.host) + paywall_status = get_paywall_status(opengraph, itemprops) + section = get_section(opengraph, itemprops) + + prefix = "#{paywall_status}#{site_name}#{section}" + if prefix == "", do: "", else: "#{prefix} — " + end + + defp get_paywall_status(opengraph, itemprops) do + content_tier = + Map.get( + opengraph, + "article:content_tier", + Map.get(itemprops, "article:content_tier", "free") + ) + + if content_tier == "free", do: "", else: "[paywall] " + end + + defp get_section(opengraph, itemprops) do + section = Map.get(opengraph, "article:section", Map.get(itemprops, "article:section")) + if section, do: ": #{section}", else: "" + end + + defp collect_description(opengraph, itemprops, title, max_length) do + date = get_formatted_date(opengraph, itemprops) + description = transform_description(Map.get(opengraph, "description"), max_length) + + cond do + title == description -> date + String.jaro_distance(title, description) > 0.8 -> date + true -> Nola.Irc.Message.splitlong(clean_text("#{date}#{description}")) + end + end + + defp get_formatted_date(opengraph, itemprops) do + published_time = + Map.get( + opengraph, + "article:published_time", + Map.get(itemprops, "article:published_time", "") + ) + + case DateTime.from_iso8601(published_time) do + {:ok, date, _} -> "#{Timex.format!(date, "%d/%m/%y", :strftime)}. " + _ -> "" + end + end + + # TODO: Swap with AI description instead of truncating. + defp transform_description(nil, _), do: nil + + defp transform_description(string, length) when is_binary(string) do + if String.length(string) > length, do: "#{String.slice(string, 0..length)}…", else: string end defp clean_text(text) do text |> String.replace("\n", " ") + |> String.replace("<br>", " ") + |> String.replace("<br/>", " ") + |> String.replace("<br />", " ") |> HtmlEntities.decode() end - - end diff --git a/lib/plugins/link/image.ex b/lib/plugins/link/image.ex index cf3d9b0..2fb6862 100644 --- a/lib/plugins/link/image.ex +++ b/lib/plugins/link/image.ex @@ -6,7 +6,7 @@ defmodule Nola.Plugins.Link.Image do def match(_, _), do: false @impl true - def post_match(_url, "image/"<>_, _header, _opts) do + def post_match(_url, "image/" <> _, _header, _opts) do {:body, nil} end @@ -16,65 +16,77 @@ defmodule Nola.Plugins.Link.Image do def post_expand(_url, bytes, _, opts) do pil_process = Keyword.get(opts, :pil_process, {:pil, :"py@127.0.0.1"}) clip_ask_process = Keyword.get(opts, :clip_ask_process, {:clip_ask, :"py@127.0.0.1"}) - img2txt_process = Keyword.get(opts, :img2txt_process, {:image_to_text_vit_gpt2, :"py@127.0.0.1"}) - - tasks = [ - Task.async(fn -> - {:ok, pil} = GenServer.call(pil_process, {:run, bytes}) - pil = pil - |> Enum.map(fn({k, v}) -> {String.to_atom(to_string(k)), v} end) - pil - end), - Task.async(fn -> - {:ok, descr} = GenServer.call(img2txt_process, {:run, bytes}) - {:img2txt, to_string(descr)} - end), - Task.async(fn -> - {:ok, prompts} = GenServer.call(clip_ask_process, {:run, bytes}) - - prompts = prompts - |> Enum.sort_by(& elem(&1, 1), &>=/2) - |> Enum.take(3) - |> Enum.map(& to_string(elem(&1, 0))) - |> Enum.join(", ") - {:prompts, prompts} + + img2txt_process = + Keyword.get(opts, :img2txt_process, {:image_to_text_vit_gpt2, :"py@127.0.0.1"}) + + tasks = + [ + Task.async(fn -> + {:ok, pil} = GenServer.call(pil_process, {:run, bytes}) + + pil = + pil + |> Enum.map(fn {k, v} -> {String.to_atom(to_string(k)), v} end) + + pil + end), + Task.async(fn -> + {:ok, descr} = GenServer.call(img2txt_process, {:run, bytes}) + {:img2txt, to_string(descr)} + end), + Task.async(fn -> + {:ok, prompts} = GenServer.call(clip_ask_process, {:run, bytes}) + + prompts = + prompts + |> Enum.sort_by(&elem(&1, 1), &>=/2) + |> Enum.take(3) + |> Enum.map(&to_string(elem(&1, 0))) + |> Enum.join(", ") + + {:prompts, prompts} + end) + ] + |> Task.yield_many(5000) + |> Enum.map(fn {task, res} -> + res || Task.shutdown(task, :brutal_kill) end) - ] - |> Task.yield_many(5000) - |> Enum.map(fn {task, res} -> - res || Task.shutdown(task, :brutal_kill) - end) - results = Enum.into(List.flatten(for({:ok, value} <- tasks, do: value)), Map.new) + results = Enum.into(List.flatten(for({:ok, value} <- tasks, do: value)), Map.new()) img2txt = Map.get(results, :img2txt) prompts = Map.get(results, :prompts) - pil = if Map.get(results, :width) do - animated = if Map.get(results, :animated), do: " animated", else: "" - "#{Map.get(results, :width, 0)}x#{Map.get(results, :height, 0)}#{animated} — " - else - "" - end + pil = + if Map.get(results, :width) do + animated = if Map.get(results, :animated), do: " animated", else: "" + "#{Map.get(results, :width, 0)}x#{Map.get(results, :height, 0)}#{animated} — " + else + "" + end - descr = cond do - img2txt && prompts -> - "#{pil}#{prompts} — #{img2txt}" - img2txt -> - "#{pil}#{img2txt}" - prompts -> - "#{pil}#{prompts}" - pil != "" -> - "#{pil}" - true -> - nil - end + descr = + cond do + img2txt && prompts -> + "#{pil}#{prompts} — #{img2txt}" + + img2txt -> + "#{pil}#{img2txt}" + + prompts -> + "#{pil}#{prompts}" + + pil != "" -> + "#{pil}" + + true -> + nil + end if descr do {:ok, "image: #{descr}"} else :error end - end - end diff --git a/lib/plugins/link/img_debrid_link.ex b/lib/plugins/link/img_debrid_link.ex new file mode 100644 index 0000000..39a60a9 --- /dev/null +++ b/lib/plugins/link/img_debrid_link.ex @@ -0,0 +1,35 @@ +defmodule Nola.Plugins.Link.ImgDebridLink do + @behaviour Nola.Plugins.Link + + @impl true + def match(uri = %URI{host: host, path: path}, _opts) + when host in ["img.debrid-link.fr", "img.debrid-link.com"] do + case String.split(path, "/") do + ["", ids] -> {true, %{id: ids}} + _ -> false + end + end + + def match(_, _), do: false + + @impl true + def post_match(_, _, _, _), do: false + + @impl true + def expand(_uri, %{id: ids}, _opts) do + with {:ok, %HTTPoison.Response{status_code: 200, body: body}} <- + HTTPoison.get("https://img.debrid-link.com/api/#{ids}/infos", [], []), + {:ok, %{"success" => true, "value" => values}} <- Jason.decode(body) do + items = + for %{"name" => name, "url" => %{"direct" => direct_url}} <- values do + "#{name}: #{direct_url}" + end + + {:ok, items} + else + error -> + Logger.info("error: #{inspect(error)}") + :error + end + end +end diff --git a/lib/plugins/link/imgur.ex b/lib/plugins/link/imgur.ex index 9fe9354..49bbb7d 100644 --- a/lib/plugins/link/imgur.ex +++ b/lib/plugins/link/imgur.ex @@ -19,19 +19,24 @@ defmodule Nola.Plugins.Link.Imgur do def match(uri = %URI{host: "imgur.io"}, arg) do match(%URI{uri | host: "imgur.com"}, arg) end + def match(uri = %URI{host: "i.imgur.io"}, arg) do match(%URI{uri | host: "i.imgur.com"}, arg) end - def match(uri = %URI{host: "imgur.com", path: "/a/"<>album_id}, _) do + + def match(uri = %URI{host: "imgur.com", path: "/a/" <> album_id}, _) do {true, %{album_id: album_id}} end - def match(uri = %URI{host: "imgur.com", path: "/gallery/"<>album_id}, _) do + + def match(uri = %URI{host: "imgur.com", path: "/gallery/" <> album_id}, _) do {true, %{album_id: album_id}} end - def match(uri = %URI{host: "i.imgur.com", path: "/"<>image}, _) do + + def match(uri = %URI{host: "i.imgur.com", path: "/" <> image}, _) do [hash, _] = String.split(image, ".", parts: 2) {true, %{image_id: hash}} end + def match(_, _), do: false @impl true @@ -49,6 +54,7 @@ defmodule Nola.Plugins.Link.Imgur do client_id = Keyword.get(Application.get_env(:nola, :imgur, []), :client_id, "42") headers = [{"Authorization", "Client-ID #{client_id}"}] options = [] + case HTTPoison.get("https://api.imgur.com/3/image/#{image_id}", headers, options) do {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> {:ok, json} = Jason.decode(body) @@ -59,6 +65,7 @@ defmodule Nola.Plugins.Link.Imgur do width = Map.get(data, "width") size = Map.get(data, "size") {:ok, "image, #{width}x#{height}, #{size} bytes #{nsfw}#{title}"} + other -> :error end @@ -68,6 +75,7 @@ defmodule Nola.Plugins.Link.Imgur do client_id = Keyword.get(Application.get_env(:nola, :imgur, []), :client_id, "42") headers = [{"Authorization", "Client-ID #{client_id}"}] options = [] + case HTTPoison.get("https://api.imgur.com/3/album/#{album_id}", headers, options) do {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> {:ok, json} = Jason.decode(body) @@ -75,22 +83,31 @@ defmodule Nola.Plugins.Link.Imgur do title = data["title"] nsfw = data["nsfw"] nsfw = if nsfw, do: "(NSFW) - ", else: "" + if data["images_count"] == 1 do [image] = data["images"] - title = if title || data["title"] do - title = [title, data["title"]] |> Enum.filter(fn(x) -> x end) |> Enum.uniq() |> Enum.join(" — ") - "#{title} — " - else - "" - end + + title = + if title || data["title"] do + title = + [title, data["title"]] + |> Enum.filter(fn x -> x end) + |> Enum.uniq() + |> Enum.join(" — ") + + "#{title} — " + else + "" + end + {:ok, "#{nsfw}#{title}#{image["link"]}"} else title = if title, do: title, else: "Untitled album" {:ok, "#{nsfw}#{title} - #{data["images_count"]} images"} end + other -> :error end end - end diff --git a/lib/plugins/link/pdf.ex b/lib/plugins/link/pdf.ex index e91dcc2..bb14594 100644 --- a/lib/plugins/link/pdf.ex +++ b/lib/plugins/link/pdf.ex @@ -6,7 +6,7 @@ defmodule Nola.Plugins.Link.PDF do def match(_, _), do: false @impl true - def post_match(_url, "application/pdf"<>_, _header, _opts) do + def post_match(_url, "application/pdf" <> _, _header, _opts) do {:file, nil} end @@ -16,24 +16,32 @@ defmodule Nola.Plugins.Link.PDF do def post_expand(url, file, _, _) do case System.cmd("pdftitle", ["-p", file]) do {text, 0} -> - text = text - |> String.trim() + text = + text + |> String.trim() if text == "" do :error else basename = Path.basename(url, ".pdf") - text = "[#{basename}] " <> text - |> String.split("\n") + + text = + ("[#{basename}] " <> text) + |> String.split("\n") + {:ok, text} end + {_, 127} -> - Logger.error("dependency `pdftitle` is missing, please install it: `pip3 install pdftitle`.") + Logger.error( + "dependency `pdftitle` is missing, please install it: `pip3 install pdftitle`." + ) + :error + {error, code} -> - Logger.warn("command `pdftitle` exited with status code #{code}:\n#{inspect error}") + Logger.warn("command `pdftitle` exited with status code #{code}:\n#{inspect(error)}") :error end end - end diff --git a/lib/plugins/link/quirks.ex b/lib/plugins/link/quirks.ex new file mode 100644 index 0000000..6f46f6b --- /dev/null +++ b/lib/plugins/link/quirks.ex @@ -0,0 +1,45 @@ +defmodule Nola.Plugins.Link.Quirks do + @rewrite_hosts "./lib/plugins/link/data/quirks_rewrite_host.txt" + |> Util.read_file_list!() + |> Enum.map(fn line -> + [old, new] = String.split(line, ":") + {old, new} + end) + |> Enum.map(fn {old, new} -> [{old, new}, {"www.#{old}", new}] end) + |> List.flatten() + |> then(fn list -> + IO.puts("Link Quirks: rewrite_hosts: #{inspect(list)}") + list + end) + + for {old, new} <- @rewrite_hosts do + def uri(%URI{host: unquote(old)} = uri) do + %URI{uri | host: unquote(new)} + end + end + + def uri(url) do + url + end + + @telegram_bot_hosts "./lib/plugins/link/data/quirks_telegram_bot_user_agent.txt" + |> Util.read_file_list!() + |> Enum.map(fn h -> [h, "www.#{h}"] end) + |> List.flatten() + |> then(fn list -> + IO.puts("Link Quirks: telegram_bot_hosts: #{inspect(list)}") + list + end) + + def user_agent(host) when host in @telegram_bot_hosts do + "TelegramBot (like TwitterBot)" + end + + def user_agent(_host) do + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36" + end + + def list() do + [rewrite_hosts: @rewrite_hosts, telegram_bot_hosts: @telegram_bot_hosts] + end +end diff --git a/lib/plugins/link/redacted.ex b/lib/plugins/link/redacted.ex index a7cfe74..0c14520 100644 --- a/lib/plugins/link/redacted.ex +++ b/lib/plugins/link/redacted.ex @@ -2,7 +2,10 @@ defmodule Nola.Plugins.Link.Redacted do @behaviour Nola.Plugins.Link @impl true - def match(uri = %URI{host: "redacted.ch", path: "/torrent.php", query: query = "id="<>id}, _opts) do + def match( + uri = %URI{host: "redacted.ch", path: "/torrent.php", query: query = "id=" <> id}, + _opts + ) do %{"id" => id} = URI.decode_query(id) {true, %{torrent: id}} end @@ -14,5 +17,4 @@ defmodule Nola.Plugins.Link.Redacted do def expand(_uri, %{torrent: id}, _opts) do end - end diff --git a/lib/plugins/link/reddit.ex b/lib/plugins/link/reddit.ex index 016e025..2a6527d 100644 --- a/lib/plugins/link/reddit.ex +++ b/lib/plugins/link/reddit.ex @@ -6,14 +6,18 @@ defmodule Nola.Plugins.Link.Reddit do case String.split(path, "/") do ["", "r", sub, "comments", post_id, _slug] -> {true, %{mode: :post, path: path, sub: sub, post_id: post_id}} + ["", "r", sub, "comments", post_id, _slug, ""] -> {true, %{mode: :post, path: path, sub: sub, post_id: post_id}} + ["", "r", sub, ""] -> {true, %{mode: :sub, path: path, sub: sub}} + ["", "r", sub] -> {true, %{mode: :sub, path: path, sub: sub}} -# ["", "u", user] -> -# {true, %{mode: :user, path: path, user: user}} + + # ["", "u", user] -> + # {true, %{mode: :user, path: path, user: user}} _ -> false end @@ -33,87 +37,120 @@ defmodule Nola.Plugins.Link.Reddit do @impl true def expand(_, %{mode: :sub, sub: sub}, _opts) do url = "https://api.reddit.com/r/#{sub}/about" - case HTTPoison.get(url) do + + case HTTPoison.get(url, [], + proxy: Keyword.get(Application.get_env(:nola, __MODULE__, []), :proxy, nil) + ) do {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> - sr = Jason.decode!(body) - |> Map.get("data") - |> IO.inspect(limit: :infinity) - description = Map.get(sr, "public_description")||Map.get(sr, "description", "") - |> String.split("\n") - |> List.first() - name = if title = Map.get(sr, "title") do - Map.get(sr, "display_name_prefixed") <> ": " <> title - else - Map.get(sr, "display_name_prefixed") - end - nsfw = if Map.get(sr, "over18") do - "[NSFW] " - else - "" - end - quarantine = if Map.get(sr, "quarantine") do - "[Quarantined] " - else - "" - end - count = "#{Map.get(sr, "subscribers")} subscribers, #{Map.get(sr, "active_user_count")} active" + sr = + Jason.decode!(body) + |> Map.get("data") + |> IO.inspect(limit: :infinity) + + description = + Map.get(sr, "public_description") || + Map.get(sr, "description", "") + |> String.split("\n") + |> List.first() + + name = + if title = Map.get(sr, "title") do + Map.get(sr, "display_name_prefixed") <> ": " <> title + else + Map.get(sr, "display_name_prefixed") + end + + nsfw = + if Map.get(sr, "over18") do + "[NSFW] " + else + "" + end + + quarantine = + if Map.get(sr, "quarantine") do + "[Quarantined] " + else + "" + end + + count = + "#{Map.get(sr, "subscribers")} subscribers, #{Map.get(sr, "active_user_count")} active" + preview = "#{quarantine}#{nsfw}#{name} — #{description} (#{count})" {:ok, preview} + _ -> :error end end def expand(_uri, %{mode: :post, path: path, sub: sub, post_id: post_id}, _opts) do - case HTTPoison.get("https://api.reddit.com#{path}?sr_detail=true") do + case HTTPoison.get("https://api.reddit.com#{path}?sr_detail=true", [], + proxy: Keyword.get(Application.get_env(:nola, __MODULE__, []), :proxy, nil) + ) do {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> json = Jason.decode!(body) - op = List.first(json) - |> Map.get("data") - |> Map.get("children") - |> List.first() - |> Map.get("data") - |> IO.inspect(limit: :infinity) + + op = + List.first(json) + |> Map.get("data") + |> Map.get("children") + |> List.first() + |> Map.get("data") + |> IO.inspect(limit: :infinity) + sr = get_in(op, ["sr_detail", "display_name_prefixed"]) - {self?, url} = if Map.get(op, "selftext") == "" do - {false, Map.get(op, "url")} - else - {true, nil} - end + + {self?, url} = + if Map.get(op, "selftext") == "" do + {false, Map.get(op, "url")} + else + {true, nil} + end self_str = if(self?, do: "text", else: url) up = Map.get(op, "ups") down = Map.get(op, "downs") comments = Map.get(op, "num_comments") - nsfw = if Map.get(op, "over_18") do - "[NSFW] " - else - "" - end - state = cond do - Map.get(op, "hidden") -> "hidden" - Map.get(op, "archived") -> "archived" - Map.get(op, "locked") -> "locked" - Map.get(op, "quarantine") -> "quarantined" - Map.get(op, "removed_by") || Map.get(op, "removed_by_category") -> "removed" - Map.get(op, "banned_by") -> "banned" - Map.get(op, "pinned") -> "pinned" - Map.get(op, "stickied") -> "stickied" - true -> nil - end - flair = if flair = Map.get(op, "link_flair_text") do - "[#{flair}] " - else - "" - end + + nsfw = + if Map.get(op, "over_18") do + "[NSFW] " + else + "" + end + + state = + cond do + Map.get(op, "hidden") -> "hidden" + Map.get(op, "archived") -> "archived" + Map.get(op, "locked") -> "locked" + Map.get(op, "quarantine") -> "quarantined" + Map.get(op, "removed_by") || Map.get(op, "removed_by_category") -> "removed" + Map.get(op, "banned_by") -> "banned" + Map.get(op, "pinned") -> "pinned" + Map.get(op, "stickied") -> "stickied" + true -> nil + end + + flair = + if flair = Map.get(op, "link_flair_text") do + "[#{flair}] " + else + "" + end + title = "#{nsfw}#{sr}: #{flair}#{Map.get(op, "title")}" state_str = if(state, do: "#{state}, ") - content = "by u/#{Map.get(op, "author")} - #{state_str}#{up} up, #{down} down, #{comments} comments - #{self_str}" + + content = + "by u/#{Map.get(op, "author")} - #{state_str}#{up} up, #{comments} comments - #{self_str}" {:ok, [title, content]} + err -> :error end end - end diff --git a/lib/plugins/link/scraper.ex b/lib/plugins/link/scraper.ex new file mode 100644 index 0000000..c30ae5f --- /dev/null +++ b/lib/plugins/link/scraper.ex @@ -0,0 +1,66 @@ +defmodule Nola.Plugins.Link.Scraper do + defmodule UseScraper do + require Logger + + def get(url, config) do + base_url = Keyword.get(config, :base_url, "https://api.usescraper.com") + api_key = Keyword.get(config, :api_key, "unset api key") + options = Keyword.get(config, :http_options, []) + + headers = [ + {"user-agent", "nola, href@random.sh"}, + {"content-type", "application/json"}, + {"authorization", "Bearer " <> api_key} + ] + + Logger.debug("scraper: use_scraper: get: #{url}") + + with {:ok, json} <- Poison.encode(%{"url" => url, "format" => "html"}), + {:ok, %HTTPoison.Response{status_code: 200, body: body}} <- + HTTPoison.post("#{base_url}/scraper/scrape", json, headers, options), + {:ok, + %{ + "status" => "scraped", + "html" => body, + "meta" => meta = %{"fetchedUrlStatusCode" => 200} + }} <- Poison.decode(body) do + {:ok, body, meta} + else + {:ok, + %{ + "status" => "scraped", + "text" => body, + "meta" => meta = %{"fetchedUrlStatusCode" => code} + }} -> + Logger.error("scraper: use_scraper: scraper got http #{code} for #{url}") + status = Plug.Conn.Status.reason_atom(code) + {:error, status} + + {:ok, %{"status" => "failed"}} -> + Logger.error("scraper: use_scraper: scraper service failed for #{url}") + {:error, :scrape_failed} + + {:ok, %HTTPoison.Response{status_code: code, body: body}} -> + Logger.error("scraper: use_scraper: scraper service failed (http #{code}) for #{url}") + status = Plug.Conn.Status.reason_atom(code) + {:error, status} + + {:error, %HTTPoison.Error{reason: reason}} -> + Logger.error( + "scraper: use_scraper: scraper service failed (http #{inspect(reason)}) for #{url}" + ) + + {:error, reason} + end + end + end + + def get(url) do + config = Keyword.get(Application.get_env(:nola, Nola.Plugins.Link, []), :scraper) || [] + + case config[:service] do + "usescraper" -> UseScraper.get(url, config[:config] || []) + _ -> {:error, :scraping_disabled} + end + end +end diff --git a/lib/plugins/link/store.ex b/lib/plugins/link/store.ex new file mode 100644 index 0000000..4e2aa58 --- /dev/null +++ b/lib/plugins/link/store.ex @@ -0,0 +1,95 @@ +defmodule Nola.Plugins.Link.Store do + alias DialyxirVendored.Warnings.Apply + use GenServer + require Logger + require Record + import Ex2ms + + @type url() :: String.t() + + Record.defrecord(:link, link: nil, result: nil, at: nil) + @type link :: record(:link, link: url(), result: any(), at: nil) + + Record.defrecord(:link_seen, key: nil, at: nil) + + @doc "A `link_seen` record represents a link that has been seen at a specific time in a given context." + @type link_seen :: record(:link_seen, key: {url(), String.t()}, at: nil) + + def setup do + :ets.new(:links, [:set, :public, :named_table, keypos: 2]) + :ets.new(:links_witness, [:set, :public, :named_table, keypos: 2]) + end + + @spec insert_link(url(), any()) :: true + def insert_link(url, result) do + :ets.insert( + :links, + link(link: url, result: result, at: DateTime.utc_now() |> DateTime.to_unix()) + ) + end + + @spec get_link(url()) :: any() | nil + def get_link(url) do + case :ets.lookup(:links, url) do + [link(result: result)] -> result + [] -> nil + end + end + + @spec witness_link(url(), String.t()) :: boolean() + def inhibit_link?(url, key) do + case :ets.lookup(:links_witness, {url, key}) do + [_] -> true + [] -> false + end + end + + @spec witness_link(url(), String.t()) :: :ok | :inhibit + def witness_link(url, key) do + if inhibit_link?(url, key) do + :inhibit + else + :ets.insert( + :links_witness, + link_seen(key: {url, key}, at: DateTime.utc_now() |> DateTime.to_unix()) + ) + + :ok + end + end + + def start_link(), do: GenServer.start_link(__MODULE__, [], name: __MODULE__) + + @doc false + @impl true + def init(_) do + setup() + env = Keyword.fetch!(Application.get_env(:nola, Nola.Plugins.Link, []), :store) + :erlang.send_after(env[:interval], self(), :expire) + {:ok, nil} + end + + @doc false + @impl true + def handle_info(:expire, state) do + env = Keyword.fetch!(Application.get_env(:nola, Nola.Plugins.Link, []), :store) + :erlang.send_after(env[:interval], self(), :expire) + ttl = env[:ttl] / 1000 + inhibit = env[:inhibit] / 1000 + now = DateTime.utc_now() |> DateTime.to_unix() + + links_evicted = + :ets.select_delete(:links, [ + {{:_, :_, :_, :"$1"}, [{:<, :"$1", now - ttl}], [true]} + ]) + + witness_evicted = + :ets.select_delete(:links_witness, [ + {{:_, :_, :"$1"}, [{:<, :"$1", now - inhibit}], [true]} + ]) + + Logger.debug("evicted #{links_evicted} links and #{witness_evicted} witnesses") + + {:noreply, state} + end +end diff --git a/lib/plugins/link/twitter.ex b/lib/plugins/link/twitter.ex index 48e6bae..ac2efe7 100644 --- a/lib/plugins/link/twitter.ex +++ b/lib/plugins/link/twitter.ex @@ -22,12 +22,15 @@ defmodule Nola.Plugins.Link.Twitter do * `expand_quoted`: Add the quoted tweet instead of its URL. Default: true. """ - def match(uri = %URI{host: twitter, path: path}, _opts) when twitter in ["twitter.com", "m.twitter.com", "mobile.twitter.com"] do + def match(uri = %URI{host: twitter, path: path}, _opts) + when twitter in ["twitter.com", "m.twitter.com", "mobile.twitter.com"] do case String.split(path, "/", parts: 4) do ["", _username, "status", status_id] -> {status_id, _} = Integer.parse(status_id) {true, %{status_id: status_id}} - _ -> false + + _ -> + false end end @@ -62,56 +65,75 @@ defmodule Nola.Plugins.Link.Twitter do # Format tweet text text = expand_twitter_text(tweet, opts) - text = if tweet.quoted_status do - quote_url = link_tweet(tweet.quoted_status, opts, true) - String.replace(text, quote_url, "") - else - text - end + + text = + if tweet.quoted_status do + quote_url = link_tweet(tweet.quoted_status, opts, true) + String.replace(text, quote_url, "") + else + text + end + text = Nola.Irc.Message.splitlong(text) - reply_to = if tweet.in_reply_to_status_id do - reply_url = link_tweet({tweet.in_reply_to_screen_name, tweet.in_reply_to_status_id}, opts) - text = if tweet.in_reply_to_screen_name == tweet.user.screen_name, do: "continued from", else: "replying to" - <<3, 15, " ↪ ", text::binary, " ", reply_url::binary, 3>> - end + reply_to = + if tweet.in_reply_to_status_id do + reply_url = link_tweet({tweet.in_reply_to_screen_name, tweet.in_reply_to_status_id}, opts) - quoted = if tweet.quoted_status do - full_text = tweet.quoted_status - |> expand_twitter_text(opts) - |> Nola.Irc.Message.splitlong_with_prefix(">") + text = + if tweet.in_reply_to_screen_name == tweet.user.screen_name, + do: "continued from", + else: "replying to" - head = format_tweet_header(tweet.quoted_status, opts, details: false, prefix: "↓ quoting") + <<3, 15, " ↪ ", text::binary, " ", reply_url::binary, 3>> + end - [head | full_text] - else - [] - end + quoted = + if tweet.quoted_status do + full_text = + tweet.quoted_status + |> expand_twitter_text(opts) + |> Nola.Irc.Message.splitlong_with_prefix(">") + + head = format_tweet_header(tweet.quoted_status, opts, details: false, prefix: "↓ quoting") + + [head | full_text] + else + [] + end - #<<2, "#{tweet.user.name} (@#{tweet.user.screen_name})", 2, " ", 3, 61, "#{foot} #{nitter_link}", 3>>, reply_to] ++ text ++ quoted + # <<2, "#{tweet.user.name} (@#{tweet.user.screen_name})", 2, " ", 3, 61, "#{foot} #{nitter_link}", 3>>, reply_to] ++ text ++ quoted + + text = + ([head, reply_to | text] ++ quoted) + |> Enum.filter(& &1) - text = [head, reply_to | text] ++ quoted - |> Enum.filter(& &1) {:ok, text} end defp expand_twitter_text(tweet, _opts) do - text = Enum.reduce(tweet.entities.urls, tweet.full_text, fn(entity, text) -> - String.replace(text, entity.url, entity.expanded_url) - end) + text = + Enum.reduce(tweet.entities.urls, tweet.full_text, fn entity, text -> + String.replace(text, entity.url, entity.expanded_url) + end) + extended = tweet.extended_entities || %{media: []} - text = Enum.reduce(extended.media, text, fn(entity, text) -> - url = Enum.filter(extended.media, fn(e) -> entity.url == e.url end) - |> Enum.map(fn(e) -> - cond do - e.type == "video" -> e.expanded_url - true -> e.media_url_https - end + + text = + Enum.reduce(extended.media, text, fn entity, text -> + url = + Enum.filter(extended.media, fn e -> entity.url == e.url end) + |> Enum.map(fn e -> + cond do + e.type == "video" -> e.expanded_url + true -> e.media_url_https + end + end) + |> Enum.join(" ") + + String.replace(text, entity.url, url) end) - |> Enum.join(" ") - String.replace(text, entity.url, url) - end) - |> HtmlEntities.decode() + |> HtmlEntities.decode() end defp format_tweet_header(tweet, opts, format_opts \\ []) do @@ -134,25 +156,28 @@ defmodule Nola.Plugins.Link.Twitter do replies = if tweet.reply_count && tweet.reply_count > 0, do: "#{tweet.reply_count} Reps" dmcad = if tweet.withheld_copyright, do: <<3, 52, "DMCA", 3>> - withheld_local = if tweet.withheld_in_countries && length(tweet.withheld_in_countries) > 0 do - "Withheld in #{length(tweet.withheld_in_countries)} countries" - end + + withheld_local = + if tweet.withheld_in_countries && length(tweet.withheld_in_countries) > 0 do + "Withheld in #{length(tweet.withheld_in_countries)} countries" + end verified = if tweet.user.verified, do: <<3, 51, "✔", 3>> - meta = if details do - [verified, nsfw, formatted_time, dmcad, withheld_local, rts, qrts, likes, replies] - else - [verified, nsfw, formatted_time, dmcad, withheld_local] - end + meta = + if details do + [verified, nsfw, formatted_time, dmcad, withheld_local, rts, qrts, likes, replies] + else + [verified, nsfw, formatted_time, dmcad, withheld_local] + end - meta = meta - |> Enum.filter(& &1) - |> Enum.join(" - ") + meta = + meta + |> Enum.filter(& &1) + |> Enum.join(" - ") meta = <<3, 15, meta::binary, " → #{link}", 3>> <<author::binary, " — ", meta::binary>> end - end diff --git a/lib/plugins/link/youtube.ex b/lib/plugins/link/youtube.ex index 0114940..adf9337 100644 --- a/lib/plugins/link/youtube.ex +++ b/lib/plugins/link/youtube.ex @@ -17,11 +17,12 @@ defmodule Nola.Plugins.Link.YouTube do """ @impl true - def match(uri = %URI{host: yt, path: "/watch", query: "v="<>video_id}, _opts) when yt in ["youtube.com", "www.youtube.com"] do + def match(uri = %URI{host: yt, path: "/watch", query: "v=" <> video_id}, _opts) + when yt in ["youtube.com", "www.youtube.com"] do {true, %{video_id: video_id}} end - def match(%URI{host: "youtu.be", path: "/"<>video_id}, _opts) do + def match(%URI{host: "youtu.be", path: "/" <> video_id}, _opts) do {true, %{video_id: video_id}} end @@ -33,40 +34,58 @@ defmodule Nola.Plugins.Link.YouTube do @impl true def expand(_uri, %{video_id: video_id}, opts) do key = Application.get_env(:nola, :youtube)[:api_key] + 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}} -> case Jason.decode(body) do {:ok, json} -> item = List.first(json["items"]) + if item do 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) - - line = if host = Keyword.get(opts, :invidious) do - ["-> https://#{host}/watch?v=#{video_id}"] - else + + duration = + item["contentDetails"]["duration"] + |> String.replace("PT", "") + |> String.downcase() + + date = + snippet["publishedAt"] + |> DateTime.from_iso8601() + |> elem(1) + |> Timex.format("{relative}", :relative) + |> elem(1) + + line = + if host = Keyword.get(opts, :invidious) do + ["-> https://#{host}/watch?v=#{video_id}"] + else [] - end - {:ok, line ++ ["#{snippet["title"]}", "— #{duration} — uploaded by #{snippet["channelTitle"]} — #{date}" - <> " — #{item["statistics"]["viewCount"]} views, #{item["statistics"]["likeCount"]} likes"]} + end + + {:ok, + line ++ + [ + "#{snippet["title"]}", + "— #{duration} — uploaded by #{snippet["channelTitle"]} — #{date}" <> + " — #{item["statistics"]["viewCount"]} views, #{item["statistics"]["likeCount"]} likes" + ]} else :error end - _ -> :error + + _ -> + :error end end end - end diff --git a/lib/plugins/logger.ex b/lib/plugins/logger.ex index 46c2a5b..1418ddc 100644 --- a/lib/plugins/logger.ex +++ b/lib/plugins/logger.ex @@ -32,52 +32,53 @@ defmodule Nola.Plugins.Logger do end def handle_info(info, state) do - Logger.debug("logger_plugin: unhandled info: #{inspect info}") + Logger.debug("logger_plugin: unhandled info: #{inspect(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}") + Logger.debug("logger_plugin: saved: #{inspect(id)}") state + error -> - Logger.error("logger_plugin: save failed: #{inspect 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("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("logger_plugin: catched processing for #{inspect(entry)}: #{inspect(e)}") Logger.error(Exception.format(e, b, __STACKTRACE__)) state end def format_to_db(msg = %Nola.Message{id: id}) do - channel = cond do - msg.channel -> msg.channel - msg.account -> msg.account.id - msg.sender -> msg.sender.nick - true -> nil - end + channel = + cond do + msg.channel -> msg.channel + msg.account -> msg.account.id + msg.sender -> msg.sender.nick + true -> nil + end - id = [msg.network, channel, id] - |> Enum.filter(& &1) - |> Enum.join(":") + id = + [msg.network, channel, id] + |> Enum.filter(& &1) + |> Enum.join(":") - %{"_id" => id, + %{ + "_id" => id, "type" => "nola.message:v1", "object" => %Nola.Message{msg | meta: Map.delete(msg.meta, :from)} } end def format_to_db(anything) do - %{"_id" => FlakeId.get(), - "type" => "object", - "object" => anything} + %{"_id" => FlakeId.get(), "type" => "object", "object" => anything} end - end diff --git a/lib/plugins/preums.ex b/lib/plugins/preums.ex index 83d99cd..5891ab3 100644 --- a/lib/plugins/preums.ex +++ b/lib/plugins/preums.ex @@ -37,45 +37,47 @@ defmodule Nola.Plugins.Preums do 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) + :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) -> + 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 + 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 + fun = fn x = {{chan, date}, account_id, time, perfect, text}, acc -> + if (channel == nil and chan) or channel == chan do + incr_points = if NaiveDateTime.utc_now().year == time.year, do: 1, else: 0 {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}) + Map.put(acc, account_id, {count + 1, points + incr_points}) else acc end end + :dets.foldl(fun, %{}, dets) - |> Enum.sort_by(fn({_account_id, value}) -> elem(value, sort_elem) end, &>=/2) + |> 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 @@ -90,39 +92,51 @@ defmodule Nola.Plugins.Preums do {: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) -> + + 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} -> + {{net, {bork, chan}}, date} -> :dets.delete(table, key) - nick = if Nola.Account.get(nick) do - nick - else - if acct = Nola.Account.find_always_by_nick(net, nil, nick) do - acct.id - else + + nick = + if Nola.Account.get(nick) do nick + else + if acct = Nola.Account.find_always_by_nick(net, nil, nick) do + acct.id + else + nick + end end - end - :dets.insert(table, { { {net,chan}, date }, nick, now, perfect, text}) + + :dets.insert(table, {{{net, chan}, date}, nick, now, perfect, text}) + {{_net, nil}, _} -> :dets.delete(table, key) + {{net, chan}, date} -> if !Nola.Account.get(nick) do if acct = Nola.Account.find_always_by_nick(net, chan, nick) do :dets.delete(table, key) - :dets.insert(table, { { {net,chan}, date }, acct.id, now, perfect, text}) + :dets.insert(table, {{{net, chan}, date}, acct.id, now, perfect, text}) end end + _ -> - Logger.debug("DID NOT FIX: #{inspect key}") + Logger.debug("DID NOT FIX: #{inspect(key)}") end end) + {:ok, %{dets: dets}} end # Latest - def handle_info({:irc, :trigger, "preums", m = %Nola.Message{trigger: %Nola.Trigger{type: :bang}}}, state) do + def handle_info( + {:irc, :trigger, "preums", m = %Nola.Message{trigger: %Nola.Trigger{type: :bang}}}, + state + ) do channelkey = {m.network, m.channel} state = handle_preums(m, state) tz = timezone(channelkey) @@ -130,14 +144,16 @@ defmodule Nola.Plugins.Preums do 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 + + 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 - end if item do {_, account_id, date, _perfect, text} = item @@ -147,33 +163,45 @@ defmodule Nola.Plugins.Preums do 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 = %Nola.Message{trigger: %Nola.Trigger{type: :dot}}}, state) do + def handle_info( + {:irc, :trigger, "preums", m = %Nola.Message{trigger: %Nola.Trigger{type: :dot}}}, + state + ) do channel = {m.network, m.channel} state = handle_preums(m, state) - top = topnicks(state.dets, channel, sort_by: :score) - |> Enum.map(fn({account_id, {count, score}}) -> - account = Nola.Account.get(account_id) - user = Nola.UserTrack.find_by_account(m.network, account) - nick = if(user, do: user.nick, else: account.name) - "#{nick}: #{score} (#{count})" - end) - |> Enum.intersperse(", ") - |> Enum.join("") - msg = unless top == "" do - "top preums: #{top}" - else - "vous êtes tous nuls" - end + + top = + topnicks(state.dets, channel, sort_by: :score) + |> Enum.map(fn {account_id, {count, score}} -> + account = Nola.Account.get(account_id) + user = Nola.UserTrack.find_by_account(m.network, account) + nick = if(user, do: user.nick, else: account.name) + "#{nick}: #{score} (#{count})" + end) + |> Enum.intersperse(", ") + |> Enum.join("") + + msg = + unless top == "" do + "top preums: #{top}" + else + "vous êtes tous nuls" + end + m.replyfun.(msg) {:noreply, state} end # Help - def handle_info({:irc, :trigger, "preums", m = %Nola.Message{trigger: %Nola.Trigger{type: :query}}}, state) do + def handle_info( + {:irc, :trigger, "preums", m = %Nola.Message{trigger: %Nola.Trigger{type: :query}}}, + state + ) do state = handle_preums(m, state) msg = "!preums - preums du jour, .preums top preumseurs" m.replymsg.(msg) @@ -194,27 +222,33 @@ defmodule Nola.Plugins.Preums do # 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) -> + + 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 + 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) -> + + 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 @@ -222,7 +256,6 @@ defmodule Nola.Plugins.Preums 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}) @@ -247,20 +280,23 @@ defmodule Nola.Plugins.Preums do 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) + perfect? = Enum.any?([~r/preum(s|)/i], 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}") + Logger.error("#{__MODULE__} dets lookup failed: #{inspect(error)}") state end else @@ -271,6 +307,4 @@ defmodule Nola.Plugins.Preums do 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 6b3cc46..f530446 100644 --- a/lib/plugins/quatre_cent_vingt.ex +++ b/lib/plugins/quatre_cent_vingt.ex @@ -10,11 +10,15 @@ defmodule Nola.Plugins.QuatreCentVingt do """ @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!!"] + 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 [ @@ -23,7 +27,7 @@ defmodule Nola.Plugins.QuatreCentVingt do "~~o∞~~", "*\\o/*", "**\\o/**", - "*ô*", + "*ô*" ] @coeffs Range.new(1, 100) @@ -34,75 +38,102 @@ defmodule Nola.Plugins.QuatreCentVingt do def init(_) do for coeff <- @coeffs do - {:ok, _} = Registry.register(Nola.PubSub, "trigger:#{420*coeff}", [plugin: __MODULE__]) + {:ok, _} = Registry.register(Nola.PubSub, "trigger:#{420 * coeff}", plugin: __MODULE__) end - {:ok, _} = Registry.register(Nola.PubSub, "account", [plugin: __MODULE__]) - dets_filename = (Nola.data_path() <> "/420.dets") |> String.to_charlist - {:ok, dets} = :dets.open_file(dets_filename, [{:type,:bag},{:repair,:force}]) + + {:ok, _} = 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 = %Nola.Message{trigger: %Nola.Trigger{args: [], type: :bang}}}, dets) do + + def handle_info( + {:irc, :trigger, unquote(qvc), + m = %Nola.Message{trigger: %Nola.Trigger{args: [], type: :bang}}}, + dets + ) do {count, last} = get_statistics_for_nick(dets, m.account.id) count = count + unquote(coeff) text = achievement_text(count) - now = DateTime.to_unix(DateTime.utc_now())-1 # this is ugly + # this is ugly + now = DateTime.to_unix(DateTime.utc_now()) - 1 + 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 - "" + :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 = %Nola.Message{trigger: %Nola.Trigger{args: [nick], type: :bang}}}, dets) do + def handle_info( + {:irc, :trigger, "420", + m = %Nola.Message{trigger: %Nola.Trigger{args: [nick], type: :bang}}}, + dets + ) do account = Nola.Account.find_by_nick(m.network, nick) + if account do - text = case get_statistics_for_nick(dets, m.account.id) do - {0, _} -> "#{nick} n'a jamais !420 ... honte à lui." - {count, last} -> - last_s = format_relative_timestamp(last) - "#{nick} 420: total #{count}, le dernier #{last_s}" - end + 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) -> + + 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 + 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) -> + + 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 @@ -115,23 +146,26 @@ defmodule Nola.Plugins.QuatreCentVingt do :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") + 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) + 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 @@ -145,5 +179,4 @@ defmodule Nola.Plugins.QuatreCentVingt do emoji = Enum.random(@emojis) "#{emoji} [#{count}]" end - end diff --git a/lib/plugins/radio_france.ex b/lib/plugins/radio_france.ex index d95c54a..e9adc4e 100644 --- a/lib/plugins/radio_france.ex +++ b/lib/plugins/radio_france.ex @@ -23,27 +23,45 @@ defmodule Nola.Plugins.RadioFrance do regopts = [plugin: __MODULE__] {:ok, _} = Registry.register(Nola.PubSub, "trigger:radiofrance", regopts) {:ok, _} = Registry.register(Nola.PubSub, "trigger:rf", regopts) + for s <- @shortcuts do {:ok, _} = Registry.register(Nola.PubSub, "trigger:#{s}", regopts) end + {:ok, nil} end - def handle_info({:irc, :trigger, "rf", m = %Nola.Message{trigger: %Nola.Trigger{type: :bang}}}, state) do + def handle_info( + {:irc, :trigger, "rf", m = %Nola.Message{trigger: %Nola.Trigger{type: :bang}}}, + state + ) do handle_info({:irc, :trigger, "radiofrance", m}, state) end - def handle_info({:irc, :trigger, @trigger, m = %Nola.Message{trigger: %Nola.Trigger{type: :bang, args: []}}}, state) do + def handle_info( + {:irc, :trigger, @trigger, + m = %Nola.Message{trigger: %Nola.Trigger{type: :bang, args: []}}}, + state + ) do m.replyfun.("radiofrance: précisez la station!") {:noreply, state} end - def handle_info({:irc, :trigger, @trigger, m = %Nola.Message{trigger: %Nola.Trigger{type: :bang, args: args}}}, state) do + def handle_info( + {:irc, :trigger, @trigger, + m = %Nola.Message{trigger: %Nola.Trigger{type: :bang, args: args}}}, + state + ) do now(args_to_station(args), m) {:noreply, state} end - def handle_info({:irc, :trigger, trigger, m = %Nola.Message{trigger: %Nola.Trigger{type: :bang, args: args}}}, state) when trigger in @shortcuts do + def handle_info( + {:irc, :trigger, trigger, + m = %Nola.Message{trigger: %Nola.Trigger{type: :bang, args: args}}}, + state + ) + when trigger in @shortcuts do now(args_to_station([trigger | args]), m) {:noreply, state} end @@ -56,7 +74,7 @@ defmodule Nola.Plugins.RadioFrance do end def handle_info(info, state) do - Logger.debug("unhandled info: #{inspect info}") + Logger.debug("unhandled info: #{inspect(info)}") {:noreply, state} end @@ -67,7 +85,7 @@ defmodule Nola.Plugins.RadioFrance do 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"]) + 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"]) @@ -76,21 +94,25 @@ defmodule Nola.Plugins.RadioFrance do 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:" + 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: "suivi de:" + if next_song?, do: "🔜", else: "à suivre:" end - else - if next_song?, do: "🔜", else: "à suivre:" - end + m.replyfun.("#{next_prefix} #{next}") end @@ -117,9 +139,11 @@ defmodule Nola.Plugins.RadioFrance do 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 @@ -129,5 +153,4 @@ defmodule Nola.Plugins.RadioFrance do |> String.replace("france", "france ") |> String.replace("_", " ") end - end diff --git a/lib/plugins/say.ex b/lib/plugins/say.ex index 114ca64..3ccd0a4 100644 --- a/lib/plugins/say.ex +++ b/lib/plugins/say.ex @@ -1,5 +1,4 @@ defmodule Nola.Plugins.Say do - def irc_doc do """ # say @@ -25,31 +24,39 @@ defmodule Nola.Plugins.Say do {:ok, nil} end - def handle_info({:irc, :trigger, "say", m = %{trigger: %{type: :bang, args: [target | text]}}}, state) do + 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 + end - def handle_info({:irc, :trigger, "asay", m = %{trigger: %{type: :bang, args: [target | text]}}}, state) do + 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 + end - def handle_info({:irc, :text, m = %{text: "say "<>rest}}, state) do + 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 + 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 @@ -60,7 +67,9 @@ defmodule Nola.Plugins.Say do defp say_for(account, target, text, with_nick?) do for {net, chan} <- Nola.Membership.of_account(account) do chan2 = String.replace(chan, "#", "") - if (target == "#{net}/#{chan}" || target == "#{net}/#{chan2}" || target == chan || target == chan2) do + + if target == "#{net}/#{chan}" || target == "#{net}/#{chan2}" || target == chan || + target == chan2 do if with_nick? do Nola.Irc.send_message_as(account, net, chan, text) else @@ -69,5 +78,4 @@ defmodule Nola.Plugins.Say do end end end - end diff --git a/lib/plugins/script.ex b/lib/plugins/script.ex index c8d00a9..0a65627 100644 --- a/lib/plugins/script.ex +++ b/lib/plugins/script.ex @@ -23,20 +23,24 @@ defmodule Nola.Plugins.Script do end def init([]) do - {:ok, _} = Registry.register(Nola.PubSub, "trigger:script", [plugin: __MODULE__]) - dets_filename = (Nola.data_path() <> "/" <> "scripts.dets") |> String.to_charlist + {: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 + 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 + # prout + ["del", name] -> :ok + # stop + [name] -> :ok end end - end diff --git a/lib/plugins/seen.ex b/lib/plugins/seen.ex index 045702c..cff0928 100644 --- a/lib/plugins/seen.ex +++ b/lib/plugins/seen.ex @@ -6,6 +6,7 @@ defmodule Nola.Plugins.Seen do """ def irc_doc, do: @moduledoc + def start_link() do GenServer.start_link(__MODULE__, [], name: __MODULE__) end @@ -19,7 +20,11 @@ defmodule Nola.Plugins.Seen do {:ok, %{dets: dets}} end - def handle_info({:irc, :trigger, "seen", m = %Nola.Message{trigger: %Nola.Trigger{type: :bang, args: [nick]}}}, state) do + def handle_info( + {:irc, :trigger, "seen", + m = %Nola.Message{trigger: %Nola.Trigger{type: :bang, args: [nick]}}}, + state + ) do witness(m, state) m.replyfun.(last_seen(m.channel, nick, state)) {:noreply, state} @@ -43,17 +48,20 @@ defmodule Nola.Plugins.Seen do 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) + 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à..." + + 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 8dd15ad..713ac3f 100644 --- a/lib/plugins/sms.ex +++ b/lib/plugins/sms.ex @@ -8,9 +8,10 @@ defmodule Nola.Plugins.Sms do def irc_doc, do: @moduledoc require Logger - def incoming(from, "enable "<>key) do + def incoming(from, "enable " <> key) do key = String.trim(key) account = Nola.Account.find_meta_account("sms-validation-code", String.downcase(key)) + if account do net = Nola.Account.get_meta(account, "sms-validation-target") Nola.Account.put_meta(account, "sms-number", from) @@ -24,15 +25,21 @@ defmodule Nola.Plugins.Sms do def incoming(from, message) do account = Nola.Account.find_meta_account("sms-number", from) + if account do - reply_fun = fn(text) -> + reply_fun = fn text -> send_sms(from, text) end - trigger_text = if Enum.any?(Nola.Irc.Connection.triggers(), fn({trigger, _}) -> String.starts_with?(message, trigger) end) do - message - else - "!"<>message - end + + trigger_text = + if Enum.any?(Nola.Irc.Connection.triggers(), fn {trigger, _} -> + String.starts_with?(message, trigger) + end) do + message + else + "!" <> message + end + message = %Nola.Message{ id: FlakeId.get(), transport: :sms, @@ -44,7 +51,8 @@ defmodule Nola.Plugins.Sms do replyfun: reply_fun, trigger: Nola.Irc.Connection.extract_trigger(trigger_text) } - Logger.debug("converted sms to message: #{inspect message}") + + Logger.debug("converted sms to message: #{inspect(message)}") Nola.Irc.Connection.publish(message, ["messages:sms"]) message end @@ -69,53 +77,70 @@ defmodule Nola.Plugins.Sms do 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!() + + 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: 200}} -> + :ok + {:ok, %HTTPoison.Response{status_code: code} = resp} -> - Logger.error("SMS Error: #{inspect resp}") + Logger.error("SMS Error: #{inspect(resp)}") {:error, code} - {:error, error} -> {:error, error} + + {:error, error} -> + {:error, error} end end def init([]) do - {:ok, _} = Registry.register(Nola.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 = %Nola.Message{trigger: %Nola.Trigger{type: :bang, args: [nick | text]}}}, state) do - with \ - {:tree, false} <- {:tree, m.sender.nick == "Tree"}, - {_, %Nola.Account{} = account} <- {:account, Nola.Account.find_always_by_nick(m.network, m.channel, nick)}, - {_, number} when not is_nil(number) <- {:number, Nola.Account.get_meta(account, "sms-number")} - do + def handle_info( + {:irc, :trigger, "sms", + m = %Nola.Message{trigger: %Nola.Trigger{type: :bang, args: [nick | text]}}}, + state + ) do + with {:tree, false} <- {:tree, m.sender.nick == "Tree"}, + {_, %Nola.Account{} = account} <- + {:account, Nola.Account.find_always_by_nick(m.network, m.channel, nick)}, + {_, number} when not is_nil(number) <- + {:number, Nola.Account.get_meta(account, "sms-number")} do text = Enum.join(text, " ") - sender = if m.channel do - "#{m.channel} <#{m.sender.nick}> " - else - "<#{m.sender.nick}> " - end - case send_sms(number, sender<>text) do + + 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}") + {: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 @@ -125,19 +150,26 @@ defmodule Nola.Plugins.Sms do 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" + + 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!() + |> 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 + + error -> + error end end @@ -147,8 +179,13 @@ defmodule Nola.Plugins.Sms do 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}] + + 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 diff --git a/lib/plugins/tell.ex b/lib/plugins/tell.ex index b4d05dc..923c2ef 100644 --- a/lib/plugins/tell.ex +++ b/lib/plugins/tell.ex @@ -8,6 +8,7 @@ defmodule Nola.Plugins.Tell do """ def irc_doc, do: @moduledoc + def start_link() do GenServer.start_link(__MODULE__, [], name: __MODULE__) end @@ -24,7 +25,7 @@ defmodule Nola.Plugins.Tell do regopts = [plugin: __MODULE__] {:ok, _} = Registry.register(Nola.PubSub, "account", regopts) {:ok, _} = Registry.register(Nola.PubSub, "trigger:tell", regopts) - {:ok, dets} = :dets.open_file(dets(), [type: :bag]) + {:ok, dets} = :dets.open_file(dets(), type: :bag) {:ok, %{dets: dets}} end @@ -33,44 +34,59 @@ defmodule Nola.Plugins.Tell do {:noreply, state} end - def handle_info({:irc, :trigger, "tell", m = %Nola.Message{trigger: %Nola.Trigger{type: :bang, args: [target | message]}}}, state) do + def handle_info( + {:irc, :trigger, "tell", + m = %Nola.Message{trigger: %Nola.Trigger{type: :bang, args: [target | message]}}}, + state + ) do do_tell(state, m, target, message) {:noreply, state} end def handle_info({:account, network, channel, nick, account_id}, state) do messages = :dets.lookup(state.dets, {network, channel, account_id}) + if messages != [] do - strs = Enum.map(messages, fn({_, from, message, at}) -> - account = Nola.Account.get(from) - user = Nola.UserTrack.find_by_account(network, account) - fromnick = if user, do: user.nick, else: account.name - "#{nick}: <#{fromnick}> #{message}" - end) - Enum.each(strs, fn(s) -> Nola.Irc.Connection.broadcast_message(network, channel, s) end) + strs = + Enum.map(messages, fn {_, from, message, at} -> + account = Nola.Account.get(from) + user = Nola.UserTrack.find_by_account(network, account) + fromnick = if user, do: user.nick, else: account.name + "#{nick}: <#{fromnick}> #{message}" + end) + + Enum.each(strs, fn s -> Nola.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) -> + # :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 -> + {{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 + + _ -> + :ok end end) + {:noreply, state} end - def handle_info(info, state) do {:noreply, state} end @@ -83,16 +99,15 @@ defmodule Nola.Plugins.Tell do defp do_tell(state, m, nick_target, message) do target = Nola.Account.find_always_by_nick(m.network, m.channel, nick_target) message = Enum.join(message, " ") - with \ - {:target, %Nola.Account{} = target} <- {:target, target}, + + with {:target, %Nola.Account{} = target} <- {:target, target}, {:same, false} <- {:same, target.id == m.account.id}, - target_user = Nola.UserTrack.find_by_account(m.network, target), - target_nick = if(target_user, do: target_user.nick, else: target.name), - present? = if(target_user, do: Map.has_key?(target_user.last_active, m.channel)), + target_user = Nola.UserTrack.find_by_account(m.network, target), + target_nick = if(target_user, do: target_user.nick, else: target.name), + present? = if(target_user, do: Map.has_key?(target_user.last_active, m.channel)), {:absent, true, _} <- {:absent, !present?, target_nick}, - {:message, message} <- {:message, message} - do - obj = { {m.network, m.channel, target.id}, m.account.id, message, NaiveDateTime.utc_now()} + {: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 @@ -102,5 +117,4 @@ defmodule Nola.Plugins.Tell do {: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 3b431e9..c582e67 100644 --- a/lib/plugins/txt.ex +++ b/lib/plugins/txt.ex @@ -53,12 +53,21 @@ defmodule Nola.Plugins.Txt do end def init([]) do - dets_locks_filename = (Nola.data_path() <> "/" <> "txtlocks.dets") |> String.to_charlist + 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) + + markov_handler = + Keyword.get( + Application.get_env(:nola, __MODULE__, []), + :markov_handler, + Nola.Plugins.Txt.MarkovPyMarkovify + ) + {:ok, markov} = markov_handler.start_link() - {:ok, _} = Registry.register(Nola.PubSub, "triggers", [plugin: __MODULE__]) - {:ok, %__MODULE__{locks: locks, markov_handler: markov_handler, markov: markov, triggers: load()}} + {: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 @@ -69,7 +78,10 @@ defmodule Nola.Plugins.Txt do # ADMIN: RW/RO # - def handle_info({:irc, :trigger, "txtrw", msg = %{channel: channel, trigger: %{type: :plus}}}, state = %{rw: false}) do + 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}} @@ -78,7 +90,10 @@ defmodule Nola.Plugins.Txt do end end - def handle_info({:irc, :trigger, "txtrw", msg = %{channel: channel, trigger: %{type: :minus}}}, state = %{rw: true}) do + 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}} @@ -91,26 +106,30 @@ defmodule Nola.Plugins.Txt do # 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 + 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 + 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 @@ -119,26 +138,26 @@ defmodule Nola.Plugins.Txt do # 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 + total = + Enum.reduce(state.triggers, 0, fn {_, data}, acc -> + acc + Enum.count(data) + 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" + link = + NolaWeb.Router.Helpers.irc_url( + NolaWeb.Endpoint, + :txt, + msg.network, + NolaWeb.format_chan(msg.channel) + ) + + total = "#{Enum.count(state.triggers)} fichiers, #{to_string(total)} lignes: #{link}" ro = if !state.rw, do: " (lecture seule activée)", else: "" - (detail<>total<>ro) + (total <> ro) |> msg.replyfun.() + {:noreply, state} end @@ -147,48 +166,53 @@ defmodule Nola.Plugins.Txt do # 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] + result = + Enum.reduce(state.triggers, [], fn {trigger, data}, acc -> + Enum.reduce(data, acc, fn {l, _}, acc -> + [{trigger, l} | acc] + end) end) - end) - |> Enum.shuffle() + |> 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) + 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) - |> Enum.shuffle() - end) if result do {source, line} = result msg.replyfun.(["#{source}: " | line]) end + {:noreply, state} end @@ -200,11 +224,15 @@ defmodule Nola.Plugins.Txt do end def with_stateful_results(key, initfun) do - pid = case :global.whereis_name(key) do - :undefined -> - start_stateful_results(key, initfun.()) - pid -> pid - end + 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 @@ -214,19 +242,24 @@ defmodule Nola.Plugins.Txt do def start_stateful_results(key, list) do me = self() - {pid, _} = spawn_monitor(fn() -> - Process.monitor(me) - stateful_results(me, list) - end) + + {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 @@ -246,10 +279,11 @@ defmodule Nola.Plugins.Txt do :get -> send(owner, {:stateful_results, line}) stateful_results(owner, rest) + {:DOWN, _ref, :process, ^owner, _} -> :ok - after - @stateful_results_expire -> :ok + after + @stateful_results_expire -> :ok end end @@ -261,20 +295,28 @@ defmodule Nola.Plugins.Txt do case state.markov_handler.sentence(state.markov) do {:ok, line} -> msg.replyfun.(line) + error -> - Logger.error "Txt Markov error: "<>inspect 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 + 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 + Logger.error("Txt Markov error: " <> inspect(error)) end + {:noreply, state} end @@ -282,12 +324,13 @@ defmodule Nola.Plugins.Txt do # 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 + 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 @@ -301,23 +344,35 @@ defmodule Nola.Plugins.Txt do 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 + 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 @@ -325,18 +380,20 @@ defmodule Nola.Plugins.Txt do # 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 + 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}") + Logger.debug("txt add failed: #{inspect(error)}") {:noreply, state} end end @@ -346,13 +403,11 @@ defmodule Nola.Plugins.Txt do # 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) + 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) @@ -363,7 +418,7 @@ defmodule Nola.Plugins.Txt do end end - def handle_info(:reload_markov, state=%__MODULE__{triggers: triggers, markov: markov}) do + def handle_info(:reload_markov, state = %__MODULE__{triggers: triggers, markov: markov}) do state.markov_handler.reload(state.triggers, state.markov) {:noreply, state} end @@ -382,41 +437,48 @@ defmodule Nola.Plugins.Txt 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 + 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.with_index - Map.put(m, key, data) - end) - |> Enum.sort - |> Enum.into(Map.new) + |> 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", []) + 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 @@ -429,30 +491,36 @@ defmodule Nola.Plugins.Txt do 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 + 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 + + 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) + 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 @@ -466,50 +534,57 @@ defmodule Nola.Plugins.Txt 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) + 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}} + {:error, {:jaro, string, idx + 1}} else - File.write!(directory() <> "/" <> trigger <> ".txt", content<>"\n", [:append]) - idx = Enum.count(triggers[trigger])+1 + File.write!(directory() <> "/" <> trigger <> ".txt", content <> "\n", [:append]) + idx = Enum.count(triggers[trigger]) + 1 {:ok, idx} end else {:error, :notxt} end - _ -> {:error, :badarg} + + _ -> + {: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] = + 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 + + (prefix <> line) |> String.split("\\\\") - |> Enum.map(fn(line) -> + |> Enum.map(fn line -> String.split(line, "\\\\\\\\") end) |> List.flatten() - |> Enum.map(fn(line) -> + |> Enum.map(fn line -> String.trim(line) |> Tmpl.render(msg) end) @@ -521,10 +596,13 @@ defmodule Nola.Plugins.Txt do defp can_write?(%{rw: rw?, locks: locks}, msg = %{channel: nil, sender: sender}, trigger) do admin? = Nola.Irc.admin?(sender) - locked? = case :dets.lookup(locks, trigger) do - [{trigger}] -> true - _ -> false - end + + locked? = + case :dets.lookup(locks, trigger) do + [{trigger}] -> true + _ -> false + end + unlocked? = if rw? == false, do: false, else: !locked? can? = unlocked? || admin? @@ -533,16 +611,24 @@ defmodule Nola.Plugins.Txt 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 + defp can_write?( + state = %__MODULE__{rw: rw?, locks: locks}, + msg = %{channel: channel, sender: sender}, + trigger + ) do admin? = Nola.Irc.admin?(sender) operator? = Nola.UserTrack.operator?(msg.network, channel, sender.nick) - locked? = case :dets.lookup(locks, trigger) do - [{trigger}] -> true - _ -> false - end + + locked? = + case :dets.lookup(locks, trigger) do + [{trigger}] -> true + _ -> false + end + unlocked? = if rw? == false, do: false, else: !locked? can? = admin? || operator? || unlocked? @@ -550,7 +636,7 @@ defmodule Nola.Plugins.Txt 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/txt/markov.ex b/lib/plugins/txt/markov.ex index b47666c..2b3d210 100644 --- a/lib/plugins/txt/markov.ex +++ b/lib/plugins/txt/markov.ex @@ -1,9 +1,7 @@ defmodule Nola.Plugins.Txt.Markov do - @type state :: any() @callback start_link() :: {:ok, state()} - @callback reload(content :: Map.t, state()) :: any() - @callback sentence(state()) :: {:ok, String.t} | {:error, String.t} - @callback complete_sentence(state()) :: {:ok, String.t} | {:error, String.t} - + @callback reload(content :: Map.t(), state()) :: any() + @callback sentence(state()) :: {:ok, String.t()} | {:error, String.t()} + @callback complete_sentence(state()) :: {:ok, String.t()} | {:error, String.t()} end diff --git a/lib/plugins/txt/markov_native.ex b/lib/plugins/txt/markov_native.ex deleted file mode 100644 index aa6b454..0000000 --- a/lib/plugins/txt/markov_native.ex +++ /dev/null @@ -1,33 +0,0 @@ -defmodule Nola.Plugins.Txt.MarkovNative do - @behaviour Nola.Plugins.Txt.Markov - - def start_link() do - ExChain.MarkovModel.start_link() - end - - def reload(data, markov) do - data = data - |> Enum.map(fn({_, data}) -> - for {line, _idx} <- data, do: line - end) - |> List.flatten - - ExChain.MarkovModel.populate_model(markov, data) - :ok - end - - def sentence(markov) do - case ExChain.SentenceGenerator.create_filtered_sentence(markov) do - {:ok, line, _, _} -> {:ok, line} - error -> error - end - end - - def complete_sentence(sentence, markov) do - case ExChain.SentenceGenerator.complete_sentence(markov, sentence) do - {line, _} -> {:ok, line} - error -> error - end - end - -end diff --git a/lib/plugins/txt/markov_py_markovify.ex b/lib/plugins/txt/markov_py_markovify.ex index f79ed47..47ff0a7 100644 --- a/lib/plugins/txt/markov_py_markovify.ex +++ b/lib/plugins/txt/markov_py_markovify.ex @@ -1,5 +1,4 @@ defmodule Nola.Plugins.Txt.MarkovPyMarkovify do - def start_link() do {:ok, nil} end @@ -19,7 +18,8 @@ defmodule Nola.Plugins.Txt.MarkovPyMarkovify do defp run(args \\ []) do {binary, script} = script() args = [script, Path.expand(Nola.Plugins.Txt.directory()) | args] - IO.puts "Args #{inspect args}" + IO.puts("Args #{inspect(args)}") + case MuonTrap.cmd(binary, args) do {response, 0} -> response {response, code} -> "error #{code}: #{response}" @@ -28,12 +28,11 @@ defmodule Nola.Plugins.Txt.MarkovPyMarkovify do defp script() do default_script = to_string(:code.priv_dir(:nola)) <> "/irc/txt/markovify.py" - env = Application.get_env(:nola, Nola.Plugins.Txt, []) - |> Keyword.get(:py_markovify, []) + + env = + Application.get_env(:nola, Nola.Plugins.Txt, []) + |> Keyword.get(:py_markovify, []) {Keyword.get(env, :python, "python3"), Keyword.get(env, :script, default_script)} end - - - end diff --git a/lib/plugins/untappd.ex b/lib/plugins/untappd.ex index e409172..5a4c070 100644 --- a/lib/plugins/untappd.ex +++ b/lib/plugins/untappd.ex @@ -1,5 +1,4 @@ defmodule Nola.Plugins.Untappd do - def irc_doc() do """ # [Untappd](https://untappd.com) @@ -18,49 +17,67 @@ defmodule Nola.Plugins.Untappd do end def init(_) do - {:ok, _} = Registry.register(Nola.PubSub, "trigger:beer", [plugin: __MODULE__]) + {:ok, _} = Registry.register(Nola.PubSub, "trigger:beer", plugin: __MODULE__) {:ok, %{}} end - def handle_info({:irc, :trigger, _, m = %Nola.Message{trigger: %{type: :bang, args: args}}}, state) do + def handle_info( + {:irc, :trigger, _, m = %Nola.Message{trigger: %{type: :bang, args: args}}}, + state + ) do case Untappd.search_beer(Enum.join(args, " "), limit: 1) do {:ok, %{"response" => %{"beers" => %{"count" => count, "items" => [result | _]}}}} -> %{"beer" => beer, "brewery" => brewery} = result - description = Map.get(beer, "beer_description") - |> String.replace("\n", " ") - |> String.replace("\r", " ") - |> String.trim() - beer_s = "#{Map.get(brewery, "brewery_name")}: #{Map.get(beer, "beer_name")} - #{Map.get(beer, "beer_abv")}°" + + 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(", ") + + 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 = %Nola.Message{trigger: %{type: :query, args: args}}}, state) do + def handle_info( + {:irc, :trigger, _, m = %Nola.Message{trigger: %{type: :query, args: args}}}, + state + ) do case Untappd.search_beer(Enum.join(args, " ")) do {:ok, %{"response" => %{"beers" => %{"count" => count, "items" => results}}}} -> - beers = for %{"beer" => beer, "brewery" => brewery} <- results do - "#{Map.get(brewery, "brewery_name")}: #{Map.get(beer, "beer_name")} - #{Map.get(beer, "beer_abv")}°" - end - |> Enum.intersperse(", ") - |> Enum.join("") + 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 e7c7420..1a9881c 100644 --- a/lib/plugins/user_mention.ex +++ b/lib/plugins/user_mention.ex @@ -19,11 +19,24 @@ defmodule Nola.Plugins.UserMention do {:ok, nil} end - def handle_info({:irc, :trigger, nick, message = %Nola.Message{sender: sender, account: account, network: network, channel: channel, trigger: %Nola.Trigger{type: :at, args: content}}}, state) do - nick = nick - |> String.trim(":") - |> String.trim(",") + def handle_info( + {:irc, :trigger, nick, + message = %Nola.Message{ + sender: sender, + account: account, + network: network, + channel: channel, + trigger: %Nola.Trigger{type: :at, args: content} + }}, + state + ) do + nick = + nick + |> String.trim(":") + |> String.trim(",") + target = Nola.Account.find_always_by_nick(network, channel, nick) + if target do telegram = Nola.Account.get_meta(target, "telegram-id") sms = Nola.Account.get_meta(target, "sms-number") @@ -31,22 +44,27 @@ defmodule Nola.Plugins.UserMention do cond do telegram -> - Nola.Telegram.send_message(telegram, "`#{channel}` <**#{sender.nick}**> #{Enum.join(content, " ")}") + 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") + false 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 47b14da..0bcbee7 100644 --- a/lib/plugins/wikipedia.ex +++ b/lib/plugins/wikipedia.ex @@ -15,15 +15,24 @@ defmodule Nola.Plugins.Wikipedia do end def init(_) do - {:ok, _} = Registry.register(Nola.PubSub, "trigger:wp", [plugin: __MODULE__]) + {:ok, _} = Registry.register(Nola.PubSub, "trigger:wp", plugin: __MODULE__) {:ok, nil} end - def handle_info({:irc, :trigger, _, message = %Nola.Message{trigger: %Nola.Trigger{type: :bang, args: []}}}, state) do + def handle_info( + {:irc, :trigger, _, + message = %Nola.Message{trigger: %Nola.Trigger{type: :bang, args: []}}}, + state + ) do irc_random(message) {:noreply, state} end - def handle_info({:irc, :trigger, _, message = %Nola.Message{trigger: %Nola.Trigger{type: :bang, args: args}}}, state) do + + def handle_info( + {:irc, :trigger, _, + message = %Nola.Message{trigger: %Nola.Trigger{type: :bang, args: args}}}, + state + ) do irc_search(Enum.join(args, " "), message) {:noreply, state} end @@ -33,19 +42,22 @@ defmodule Nola.Plugins.Wikipedia do 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, + "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 @@ -58,6 +70,7 @@ defmodule Nola.Plugins.Wikipedia do "grnnamespace" => 0, "prop" => "info" } + case query_wikipedia(params) do {:ok, %{"query" => %{"pages" => map = %{}}}} -> [{_, item}] = Map.to_list(map) @@ -65,6 +78,7 @@ defmodule Nola.Plugins.Wikipedia do url = "https://fr.wikipedia.org/wiki/" <> String.replace(title, " ", "_") msg = "Wikipédia: #{title} — #{url}" message.replyfun.(msg) + _ -> nil end @@ -72,19 +86,23 @@ defmodule Nola.Plugins.Wikipedia do defp query_wikipedia(params) do url = "https://fr.wikipedia.org/w/api.php" - params = params - |> Map.put("format", "json") - |> Map.put("utf8", "") + + 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: 200, body: body}} -> + Jason.decode(body) + {:ok, %HTTPoison.Response{status_code: 400, body: body}} -> - Logger.error "Wikipedia HTTP 400: #{inspect body}" + Logger.error("Wikipedia HTTP 400: #{inspect(body)}") {:error, "http 400"} + error -> - Logger.error "Wikipedia http error: #{inspect 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 120af16..f9d5a5e 100644 --- a/lib/plugins/wolfram_alpha.ex +++ b/lib/plugins/wolfram_alpha.ex @@ -15,33 +15,44 @@ defmodule Nola.Plugins.WolframAlpha do end def init(_) do - {:ok, _} = Registry.register(Nola.PubSub, "trigger:wa", [plugin: __MODULE__]) + {:ok, _} = Registry.register(Nola.PubSub, "trigger:wa", plugin: __MODULE__) {:ok, nil} end - def handle_info({:irc, :trigger, _, m = %Nola.Message{trigger: %Nola.Trigger{type: :bang, args: query}}}, state) do + def handle_info( + {:irc, :trigger, _, m = %Nola.Message{trigger: %Nola.Trigger{type: :bang, args: query}}}, + state + ) do query = Enum.join(query, " ") + params = %{ "appid" => Keyword.get(Application.get_env(:nola, :wolframalpha, []), :app_id, "NO_APP_ID"), "units" => "metric", "i" => query } + url = "https://www.wolframalpha.com/input/?i=" <> URI.encode(query) - case HTTPoison.get("http://api.wolframalpha.com/v1/result", [], [params: params]) do + + 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 + 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 39bf03d..5e36301 100644 --- a/lib/plugins/youtube.ex +++ b/lib/plugins/youtube.ex @@ -16,11 +16,17 @@ defmodule Nola.Plugins.YouTube do end def init([]) do - for t <- ["trigger:yt", "trigger:youtube"], do: {:ok, _} = Registry.register(Nola.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 = %Nola.Message{trigger: %Nola.Trigger{type: :bang, args: args}}}, state) do + def handle_info( + {:irc, :trigger, _, + message = %Nola.Message{trigger: %Nola.Trigger{type: :bang, args: args}}}, + state + ) do irc_search(Enum.join(args, " "), message) {:noreply, state} end @@ -34,71 +40,91 @@ defmodule Nola.Plugins.YouTube 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" + + 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) + message.replyfun.("Erreur YouTube: " <> error) + _ -> nil end end defp search(query) do - query = query - |> String.strip + 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, + "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}" + Logger.error("YouTube HTTP #{code}: #{inspect(body)}") {:error, "http #{code}"} + error -> - Logger.error "YouTube http error: #{inspect 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}" + Logger.error("YouTube HTTP #{code}: #{inspect(body)}") {:error, "http #{code}"} + error -> - Logger.error "YouTube http error: #{inspect error}" + Logger.error("YouTube http error: #{inspect(error)}") :error end end - end diff --git a/lib/telegram.ex b/lib/telegram.ex index b161b63..289b913 100644 --- a/lib/telegram.ex +++ b/lib/telegram.ex @@ -34,42 +34,62 @@ defmodule Nola.Telegram do {:ok, %{room_state: room_state}} end - def handle_update(%{"message" => m = %{"chat" => %{"type" => "private"}, "text" => text = "/start"<>_}}, _token, state) do - text = "*Welcome to beautte!*\n\nQuery the bot on IRC and say \"enable-telegram\" to continue." + def handle_update( + %{"message" => m = %{"chat" => %{"type" => "private"}, "text" => text = "/start" <> _}}, + _token, + state + ) do + text = + "*Welcome to beautte!*\n\nQuery the bot on IRC and say \"enable-telegram\" to continue." + send_message(m["chat"]["id"], text) {:ok, %{account: nil}} end - def handle_update(%{"message" => m = %{"chat" => %{"type" => "private"}, "text" => text = "/enable"<>_}}, _token, state) do - key = case String.split(text, " ") do - ["/enable", key | _] -> key - _ -> "nil" - end + def handle_update( + %{"message" => m = %{"chat" => %{"type" => "private"}, "text" => text = "/enable" <> _}}, + _token, + state + ) do + key = + case String.split(text, " ") do + ["/enable", key | _] -> key + _ -> "nil" + end - #Handled message "1247435154:AAGnSSCnySn0RuVxy_SUcDEoOX_rbF6vdq0" %{"message" => + # Handled message "1247435154:AAGnSSCnySn0RuVxy_SUcDEoOX_rbF6vdq0" %{"message" => # %{"chat" => %{"first_name" => "J", "id" => 2075406, "type" => "private", "username" => "ahref"}, # "date" => 1591027272, "entities" => # [%{"length" => 7, "offset" => 0, "type" => "bot_command"}], # "from" => %{"first_name" => "J", "id" => 2075406, "is_bot" => false, "language_code" => "en", "username" => "ahref"}, # "message_id" => 11, "text" => "/enable salope"}, "update_id" => 764148578} account = Nola.Account.find_meta_account("telegram-validation-code", String.downcase(key)) - text = if account do - net = Nola.Account.get_meta(account, "telegram-validation-target") - Nola.Account.put_meta(account, "telegram-id", m["chat"]["id"]) - Nola.Account.put_meta(account, "telegram-username", m["chat"]["username"]) - Nola.Account.put_meta(account, "telegram-username", m["chat"]["username"]) - Nola.Account.delete_meta(account, "telegram-validation-code") - Nola.Account.delete_meta(account, "telegram-validation-target") - Nola.Irc.Connection.broadcast_message(net, account, "Telegram #{m["chat"]["username"]} account added!") - "Yay! Linked to account **#{account.name}**." - else - "Token invalid" - end + + text = + if account do + net = Nola.Account.get_meta(account, "telegram-validation-target") + Nola.Account.put_meta(account, "telegram-id", m["chat"]["id"]) + Nola.Account.put_meta(account, "telegram-username", m["chat"]["username"]) + Nola.Account.put_meta(account, "telegram-username", m["chat"]["username"]) + Nola.Account.delete_meta(account, "telegram-validation-code") + Nola.Account.delete_meta(account, "telegram-validation-target") + + Nola.Irc.Connection.broadcast_message( + net, + account, + "Telegram #{m["chat"]["username"]} account added!" + ) + + "Yay! Linked to account **#{account.name}**." + else + "Token invalid" + end + send_message(m["chat"]["id"], text) {:ok, %{account: account.id}} end - #[debug] Unhandled update: %{"message" => + # [debug] Unhandled update: %{"message" => # %{"chat" => %{"first_name" => "J", "id" => 2075406, "type" => "private", "username" => "ahref"}, # "date" => 1591096015, # "from" => %{"first_name" => "J", "id" => 2075406, "is_bot" => false, "language_code" => "en", "username" => "ahref"}, @@ -87,7 +107,7 @@ defmodule Nola.Telegram do end end - #[debug] Unhandled update: %{"callback_query" => + # [debug] Unhandled update: %{"callback_query" => # %{ # "chat_instance" => "-7948978714441865930", "data" => "evolu.net/#dmz", # "from" => %{"first_name" => "J", "id" => 2075406, "is_bot" => false, "language_code" => "en", "username" => "ahref"}, @@ -101,79 +121,139 @@ defmodule Nola.Telegram do # } # , "update_id" => 218161568} - #def handle_update(t, %{"callback_query" => cb = %{"data" => "resend", "id" => id, "message" => m = %{"message_id" => m_id, "chat" => %{"id" => chat_id}, "reply_to_message" => op}}}) do - #end + # def handle_update(t, %{"callback_query" => cb = %{"data" => "resend", "id" => id, "message" => m = %{"message_id" => m_id, "chat" => %{"id" => chat_id}, "reply_to_message" => op}}}) do + # end - def handle_update(%{"callback_query" => cb = %{"data" => "start-upload:"<>target, "id" => id, "message" => m = %{"message_id" => m_id, "chat" => %{"id" => chat_id}, "reply_to_message" => op}}}, t, state) do + def handle_update( + %{ + "callback_query" => + cb = %{ + "data" => "start-upload:" <> target, + "id" => id, + "message" => + m = %{ + "message_id" => m_id, + "chat" => %{"id" => chat_id}, + "reply_to_message" => op + } + } + }, + t, + state + ) do account = Nola.Account.find_meta_account("telegram-id", chat_id) + if account do - target = case String.split(target, "/") do - ["everywhere"] -> Nola.Membership.of_account(account) - [net, chan] -> [{net, chan}] - end - Telegram.Api.request(t, "editMessageText", chat_id: chat_id, message_id: m_id, text: "Processing...", reply_markup: %{}) - - {content, type} = cond do - op["photo"] -> {op["photo"], ""} - op["voice"] -> {op["voice"], " a voice message"} - op["video"] -> {op["video"], ""} - op["document"] -> {op["document"], ""} - op["animation"] -> {op["animation"], ""} - end + target = + case String.split(target, "/") do + ["everywhere"] -> Nola.Membership.of_account(account) + [net, chan] -> [{net, chan}] + end + + Telegram.Api.request(t, "editMessageText", + chat_id: chat_id, + message_id: m_id, + text: "Processing...", + reply_markup: %{} + ) + + {content, type} = + cond do + op["photo"] -> {op["photo"], ""} + op["voice"] -> {op["voice"], " a voice message"} + op["video"] -> {op["video"], ""} + op["document"] -> {op["document"], ""} + op["animation"] -> {op["animation"], ""} + end + + file = + if is_list(content) && Enum.count(content) > 1 do + Enum.sort_by(content, fn p -> p["file_size"] end, &>=/2) + |> List.first() + else + content + end - file = if is_list(content) && Enum.count(content) > 1 do - Enum.sort_by(content, fn(p) -> p["file_size"] end, &>=/2) - |> List.first() - else - content - end file_id = file["file_id"] file_unique_id = file["file_unique_id"] - text = if(op["caption"], do: ": "<> op["caption"] <> "", else: "") - resend = %{"inline_keyboard" => [ [%{"text" => "re-share", "callback_data" => "resend"}] ]} - spawn(fn() -> - with \ - {:ok, file} <- Telegram.Api.request(t, "getFile", file_id: file_id), - path = "https://api.telegram.org/file/bot#{t}/#{file["file_path"]}", - {:ok, %HTTPoison.Response{status_code: 200, body: body}} <- HTTPoison.get(path), - <<smol_body::binary-size(20), _::binary>> = body, - {:ok, magic} <- GenMagic.Pool.perform(Nola.GenMagic, {:bytes, smol_body}), - bucket = Application.get_env(:nola, :s3, []) |> Keyword.get(:bucket), - ext = Path.extname(file["file_path"]), - s3path = "#{account.id}/#{file_unique_id}#{ext}", - Telegram.Api.request(t, "editMessageText", chat_id: chat_id, message_id: m_id, text: "*Uploading...*", reply_markup: %{}, parse_mode: "MarkdownV2"), - s3req = ExAws.S3.put_object(bucket, s3path, body, acl: :public_read, content_type: magic.mime_type), - {:ok, _} <- ExAws.request(s3req) - do + text = if(op["caption"], do: ": " <> op["caption"] <> "", else: "") + resend = %{"inline_keyboard" => [[%{"text" => "re-share", "callback_data" => "resend"}]]} + + spawn(fn -> + with {:ok, file} <- Telegram.Api.request(t, "getFile", file_id: file_id), + path = "https://api.telegram.org/file/bot#{t}/#{file["file_path"]}", + {:ok, %HTTPoison.Response{status_code: 200, body: body}} <- HTTPoison.get(path), + <<smol_body::binary-size(20), _::binary>> = body, + {:ok, magic} <- GenMagic.Pool.perform(Nola.GenMagic, {:bytes, smol_body}), + bucket = Application.get_env(:nola, :s3, []) |> Keyword.get(:bucket), + ext = Path.extname(file["file_path"]), + s3path = "#{account.id}/#{file_unique_id}#{ext}", + Telegram.Api.request(t, "editMessageText", + chat_id: chat_id, + message_id: m_id, + text: "*Uploading...*", + reply_markup: %{}, + parse_mode: "MarkdownV2" + ), + s3req = + ExAws.S3.put_object(bucket, s3path, body, + acl: :public_read, + content_type: magic.mime_type + ), + {:ok, _} <- ExAws.request(s3req) do path = NolaWeb.Router.Helpers.url(NolaWeb.Endpoint) <> "/files/#{s3path}" - sent = for {net, chan} <- target do - txt = "sent#{type}#{text} #{path}" - Nola.Irc.send_message_as(account, net, chan, txt) - "#{net}/#{chan}" - end + + sent = + for {net, chan} <- target do + txt = "sent#{type}#{text} #{path}" + Nola.Irc.send_message_as(account, net, chan, txt) + "#{net}/#{chan}" + end + if caption = op["caption"], do: as_irc_message(chat_id, caption, account) text = "Sent on " <> Enum.join(sent, ", ") <> " !" - Telegram.Api.request(t, "editMessageText", chat_id: chat_id, message_id: m_id, text: "_Sent!_", reply_markup: %{}, parse_mode: "MarkdownV2") + + Telegram.Api.request(t, "editMessageText", + chat_id: chat_id, + message_id: m_id, + text: "_Sent!_", + reply_markup: %{}, + parse_mode: "MarkdownV2" + ) else error -> - Telegram.Api.request(t, "editMessageText", chat_id: chat_id, message_id: m_id, text: "Something failed.", reply_markup: %{}, parse_mode: "MarkdownV2") - Logger.error("Failed upload from Telegram: #{inspect error}") + Telegram.Api.request(t, "editMessageText", + chat_id: chat_id, + message_id: m_id, + text: "Something failed.", + reply_markup: %{}, + parse_mode: "MarkdownV2" + ) + + Logger.error("Failed upload from Telegram: #{inspect(error)}") end end) end + {:ok, state} end - def handle_update(%{"message" => m = %{"chat" => %{"id" => id, "type" => "private"}, "text" => text}}, _, state) do + def handle_update( + %{"message" => m = %{"chat" => %{"id" => id, "type" => "private"}, "text" => text}}, + _, + state + ) do account = Nola.Account.find_meta_account("telegram-id", id) + if account do as_irc_message(id, text, account) end + {:ok, state} end def handle_update(m, _, state) do - Logger.debug("Unhandled update: #{inspect m}") + Logger.debug("Unhandled update: #{inspect(m)}") {:ok, state} end @@ -188,49 +268,80 @@ defmodule Nola.Telegram do end defp as_irc_message(id, text, account) do - reply_fun = fn(text) -> send_message(id, text) end - trigger_text = cond do - String.starts_with?(text, "/") -> - "/"<>text = text - "!"<>text - Enum.any?(Nola.Irc.Connection.triggers(), fn({trigger, _}) -> String.starts_with?(text, trigger) end) -> - text - true -> - "!"<>text - end - message = %Nola.Message{ - id: FlakeId.get(), - transport: :telegram, + reply_fun = fn text -> send_message(id, text) end + + trigger_text = + cond do + String.starts_with?(text, "/") -> + "/" <> text = text + "!" <> text + + Enum.any?(Nola.Irc.Connection.triggers(), fn {trigger, _} -> + String.starts_with?(text, trigger) + end) -> + text + + true -> + "!" <> text + end + + message = %Nola.Message{ + id: FlakeId.get(), + transport: :telegram, network: "telegram", channel: nil, text: text, account: account, sender: %ExIRC.SenderInfo{nick: account.name}, replyfun: reply_fun, - trigger: Nola.Irc.Connection.extract_trigger(trigger_text), - at: nil + trigger: Nola.Irc.Connection.extract_trigger(trigger_text), + at: nil } - Nola.Irc.Connection.publish(message, ["messages:private", "messages:telegram", "telegram/#{account.id}:messages"]) + + Nola.Irc.Connection.publish(message, [ + "messages:private", + "messages:telegram", + "telegram/#{account.id}:messages" + ]) + message end - defp start_upload(_type, %{"message" => m = %{"chat" => %{"id" => id, "type" => "private"}}}, token, state) do + defp start_upload( + _type, + %{"message" => m = %{"chat" => %{"id" => id, "type" => "private"}}}, + token, + state + ) do account = Nola.Account.find_meta_account("telegram-id", id) + if account do text = if(m["text"], do: m["text"], else: nil) - targets = Nola.Membership.of_account(account) - |> Enum.map(fn({net, chan}) -> "#{net}/#{chan}" end) - |> Enum.map(fn(i) -> %{"text" => i, "callback_data" => "start-upload:#{i}"} end) - kb = if Enum.count(targets) > 1 do - [%{"text" => "everywhere", "callback_data" => "start-upload:everywhere"}] ++ targets - else - targets - end - |> Enum.chunk_every(2) + + targets = + Nola.Membership.of_account(account) + |> Enum.map(fn {net, chan} -> "#{net}/#{chan}" end) + |> Enum.map(fn i -> %{"text" => i, "callback_data" => "start-upload:#{i}"} end) + + kb = + if Enum.count(targets) > 1 do + [%{"text" => "everywhere", "callback_data" => "start-upload:everywhere"}] ++ targets + else + targets + end + |> Enum.chunk_every(2) + keyboard = %{"inline_keyboard" => kb} - Telegram.Api.request(token, "sendMessage", chat_id: id, text: "Where should I send this file?", reply_markup: keyboard, reply_to_message_id: m["message_id"], parse_mode: "MarkdownV2") + + Telegram.Api.request(token, "sendMessage", + chat_id: id, + text: "Where should I send this file?", + reply_markup: keyboard, + reply_to_message_id: m["message_id"], + parse_mode: "MarkdownV2" + ) end + {:ok, state} end - end diff --git a/lib/telegram/room.ex b/lib/telegram/room.ex index 9db551b..05c3eeb 100644 --- a/lib/telegram/room.ex +++ b/lib/telegram/room.ex @@ -7,7 +7,7 @@ defmodule Nola.TelegramRoom do def rooms(), do: rooms(:with_docs) - @spec rooms(:with_docs | :ids) :: [Map.t | integer( )] + @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)} @@ -33,9 +33,13 @@ defmodule Nola.TelegramRoom do def after_start() do {:ok, rooms} = rooms(:ids) + for id <- rooms do - spawn(fn() -> - Telegram.Bot.ChatBot.Chat.Session.Supervisor.start_child(Nola.Telegram, Integer.parse(id) |> elem(0)) + spawn(fn -> + Telegram.Bot.ChatBot.Chat.Session.Supervisor.start_child( + Nola.Telegram, + Integer.parse(id) |> elem(0) + ) end) end end @@ -45,34 +49,50 @@ defmodule Nola.TelegramRoom 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(to_string(id)) do - {:ok, tg_room = %{"network" => _net, "channel" => _chan}} -> tg_room - _ -> - [net, chan] = String.split(chat["title"], "/", parts: 2) - {net, chan} = case Nola.Irc.Connection.get_network(net, chan) do - %Nola.Irc.Connection{} -> {net, chan} - _ -> {nil, nil} - end - {:ok, _, _} = Couch.post(@couch, %{"_id" => id, "network" => net, "channel" => chan}) - {:ok, tg_room} = room(to_string(id)) - tg_room - end + + tg_room = + case room(to_string(id)) do + {:ok, tg_room = %{"network" => _net, "channel" => _chan}} -> + tg_room + + _ -> + [net, chan] = String.split(chat["title"], "/", parts: 2) + + {net, chan} = + case Nola.Irc.Connection.get_network(net, chan) do + %Nola.Irc.Connection{} -> {net, chan} + _ -> {nil, nil} + end + + {:ok, _, _} = Couch.post(@couch, %{"_id" => id, "network" => net, "channel" => chan}) + {:ok, tg_room} = room(to_string(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 + Logger.info("Starting ChatBot for room #{id} \"#{chat["title"]}\" #{inspect(tg_room)}") + + irc_plumbed = + if net && chan do {:ok, _} = Registry.register(Nola.PubSub, "#{net}/#{chan}:messages", plugin: __MODULE__) {:ok, _} = Registry.register(Nola.PubSub, "#{net}/#{chan}:triggers", plugin: __MODULE__) {:ok, _} = Registry.register(Nola.PubSub, "#{net}/#{chan}:outputs", plugin: __MODULE__) true - else - Logger.warn("Did not found telegram match for #{id} \"#{chat["title"]}\"") - false - end + 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) + Logger.error("telegram_room: bad id (not room id)", + transport: :telegram, + id: id, + telegram_room_id: id + ) + :ignore end @@ -82,15 +102,18 @@ defmodule Nola.TelegramRoom do 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(" ") + + name = + [first_name, last_name] + |> Enum.filter(& &1) + |> Enum.join(" ") username = Map.get(from, "username", first_name) - account = username - |> Nola.Account.new_account() - |> Nola.Account.update_account_name(name) + account = + username + |> Nola.Account.new_account() + |> Nola.Account.update_account_name(name) Nola.Account.put_meta(account, "telegram-id", user_id) @@ -99,17 +122,33 @@ defmodule Nola.TelegramRoom do end end - def handle_update(%{"message" => %{"from" => from = %{"id" => user_id}, "text" => text}}, _token, state) do + def handle_update( + %{"message" => %{"from" => from = %{"id" => user_id}, "text" => text}}, + _token, + state + ) do account = find_or_create_meta_account(from, state) - #connection = Nola.Irc.Connection.get_network(state.net) + # connection = Nola.Irc.Connection.get_network(state.net) Nola.Irc.send_message_as(account, state.net, state.chan, text, true, origin: __MODULE__) {:ok, state} end - def handle_update(data = %{"message" => %{"from" => from = %{"id" => user_id}, "location" => %{"latitude" => lat, "longitude" => lon}}}, _token, state) do + 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 = Nola.Irc.Connection.get_network(state.net) - Nola.Irc.send_message_as(account, state.net, state.chan, "@ #{lat}, #{lon}", true, origin: __MODULE__) + # connection = Nola.Irc.Connection.get_network(state.net) + Nola.Irc.send_message_as(account, state.net, state.chan, "@ #{lat}, #{lon}", true, + origin: __MODULE__ + ) + {:ok, state} end @@ -133,62 +172,76 @@ defmodule Nola.TelegramRoom do 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}") + 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 + 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 + {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 = + if is_list(content) && Enum.count(content) > 1 do + Enum.sort_by(content, fn p -> p["file_size"] end, &>=/2) + |> List.first() + else + content + end file_id = file["file_id"] file_unique_id = file["file_unique_id"] text = if(m["caption"], do: m["caption"] <> " ", else: "") - spawn(fn() -> - with \ - {:ok, file} <- Telegram.Api.request(token, "getFile", file_id: file_id), - path = "https://api.telegram.org/file/bot#{token}/#{file["file_path"]}", - {:ok, %HTTPoison.Response{status_code: 200, body: body}} <- HTTPoison.get(path), - <<smol_body::binary-size(20), _::binary>> = body, - {:ok, magic} <- GenMagic.Pool.perform(Nola.GenMagic, {:bytes, smol_body}), - bucket = Application.get_env(:nola, :s3, []) |> Keyword.get(:bucket), - ext = Path.extname(file["file_path"]), - s3path = "#{account.id}/#{file_unique_id}#{ext}", - s3req = ExAws.S3.put_object(bucket, s3path, body, acl: :public_read, content_type: magic.mime_type), - {:ok, _} <- ExAws.request(s3req) - do + spawn(fn -> + with {:ok, file} <- Telegram.Api.request(token, "getFile", file_id: file_id), + path = "https://api.telegram.org/file/bot#{token}/#{file["file_path"]}", + {:ok, %HTTPoison.Response{status_code: 200, body: body}} <- HTTPoison.get(path), + <<smol_body::binary-size(20), _::binary>> = body, + {:ok, magic} <- GenMagic.Pool.perform(Nola.GenMagic, {:bytes, smol_body}), + bucket = Application.get_env(:nola, :s3, []) |> Keyword.get(:bucket), + ext = Path.extname(file["file_path"]), + s3path = "#{account.id}/#{file_unique_id}#{ext}", + s3req = + ExAws.S3.put_object(bucket, s3path, body, + acl: :public_read, + content_type: magic.mime_type + ), + {:ok, _} <- ExAws.request(s3req) do path = NolaWeb.Router.Helpers.url(NolaWeb.Endpoint) <> "/files/#{s3path}" txt = "#{type}: #{text}#{path}" - #connection = Nola.Irc.Connection.get_network(state.net) + # connection = Nola.Irc.Connection.get_network(state.net) Nola.Irc.send_message_as(account, state.net, state.chan, txt, true, origin: __MODULE__) else error -> - Telegram.Api.request(token, "sendMessage", chat_id: chat_id, text: "File upload failed, sorry.") - Logger.error("Failed upload from Telegram: #{inspect 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/tmpl.ex b/lib/tmpl.ex index e4489ac..881cbc8 100644 --- a/lib/tmpl.ex +++ b/lib/tmpl.ex @@ -24,10 +24,26 @@ defmodule Tmpl do end end - @colors [:white, :black, :blue, :green, :red, :brown, :purple, :orange, :yellow, :light_green, :cyan, :light_blue, :pink, :grey, :light_grey] + @colors [ + :white, + :black, + :blue, + :green, + :red, + :brown, + :purple, + :orange, + :yellow, + :light_green, + :cyan, + :light_blue, + :pink, + :grey, + :light_grey + ] for {color, index} <- Enum.with_index(@colors) do - code = 48+index + code = 48 + index def color_code(unquote(color)) do unquote(code) @@ -42,7 +58,9 @@ defmodule Tmpl do end end - def account_nick(%{"id" => id, "name" => name}, %{variables: %{"message" => %{"network" => network}}}) do + def account_nick(%{"id" => id, "name" => name}, %{ + variables: %{"message" => %{"network" => network}} + }) do if user = Nola.UserTrack.find_by_account(network, %Nola.Account{id: id}) do user.nick else @@ -53,7 +71,6 @@ defmodule Tmpl do def account_nick(val, ctx) do "{{account_nick}}" end - end def render(template, msg = %Nola.Message{}, context \\ %{}, safe \\ true) do @@ -64,20 +81,23 @@ defmodule Tmpl do case Liquex.parse(template) do {:ok, template_ast} -> do_render(template_ast, context, safe) + {:error, err, pos} -> - Logger.debug("Liquid error: #{pos} - #{inspect template}") - "[liquid ast error (at #{pos}): #{inspect err}]" + Logger.debug("Liquid error: #{pos} - #{inspect(template)}") + "[liquid ast error (at #{pos}): #{inspect(err)}]" end end defp do_render(template_ast, context, safe) when is_list(template_ast) do - context = Liquex.Context.new(mapify(context, safe)) - |> Map.put(:filter_module, Tmpl.Filter) + context = + Liquex.Context.new(mapify(context, safe)) + |> Map.put(:filter_module, Tmpl.Filter) + {content, _context} = Liquex.render(template_ast, context) to_string(content) rescue e -> - Logger.error("Liquid error: #{inspect e}") + Logger.error("Liquid error: #{inspect(e)}") "[liquid rendering error]" end @@ -87,8 +107,9 @@ defmodule Tmpl do defp mapify(map = %{}, safe) do map - |> Enum.reduce(Map.new, fn({k,v}, acc) -> + |> Enum.reduce(Map.new(), fn {k, v}, acc -> k = to_string(k) + if safe?(k, safe) do if v = mapify(v, safe) do Map.put(acc, k, v) @@ -121,4 +142,3 @@ defmodule Tmpl do defp safe?("password", true), do: false defp safe?(_, true), do: true end - diff --git a/lib/untappd.ex b/lib/untappd.ex index e603c25..c563fa9 100644 --- a/lib/untappd.ex +++ b/lib/untappd.ex @@ -1,12 +1,12 @@ defmodule Untappd do - - @env Mix.env - @version Mix.Project.config[:version] + @env Mix.env() + @version Mix.Project.config()[:version] require Logger def auth_url() do client_id = Keyword.get(env(), :client_id) url = NolaWeb.Router.Helpers.untappd_callback_url(NolaWeb.Endpoint, :callback) + "https://untappd.com/oauth/authenticate/?client_id=#{client_id}&response_type=code&redirect_url=#{URI.encode(url)}" end @@ -14,6 +14,7 @@ defmodule Untappd do client_id = Keyword.get(env(), :client_id) client_secret = Keyword.get(env(), :client_secret) url = NolaWeb.Router.Helpers.untappd_callback_url(NolaWeb.Endpoint, :callback) + params = %{ "client_id" => client_id, "client_secret" => client_secret, @@ -21,12 +22,14 @@ defmodule Untappd do "redirect_url" => url, "code" => code } + case HTTPoison.get("https://untappd.com/oauth/authorize", headers(), params: params) do {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> json = Poison.decode!(body) {:ok, get_in(json, ["response", "access_token"])} + error -> - Logger.error("Untappd auth callback failed: #{inspect error}") + Logger.error("Untappd auth callback failed: #{inspect(error)}") :error end end @@ -40,38 +43,56 @@ defmodule Untappd do end def checkin(token, beer_id) do - params = get_params(token: token) - |> Map.put("timezone", "CEST") - |> Map.put("bid", beer_id) - form_params = params - |> Enum.into([]) - case HTTPoison.post("https://api.untappd.com/v4/checkin/add", {:form, form_params}, headers(), params: params) do + params = + get_params(token: token) + |> Map.put("timezone", "CEST") + |> Map.put("bid", beer_id) + + form_params = + params + |> Enum.into([]) + + case HTTPoison.post("https://api.untappd.com/v4/checkin/add", {:form, form_params}, headers(), + params: params + ) do {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> - body = Jason.decode!(body) - |> Map.get("response") + body = + Jason.decode!(body) + |> Map.get("response") + {:ok, body} + {:ok, resp = %HTTPoison.Response{status_code: code, body: body}} -> - Logger.warn "Untappd checkin error: #{inspect resp}" + Logger.warn("Untappd checkin error: #{inspect(resp)}") {:error, {:http_error, code}} - {:error, error} -> {:error, {:http_error, error}} + + {:error, error} -> + {:error, {:http_error, error}} end end def search_beer(query, params \\ []) do - params = get_params(params) - |> Map.put("q", query) - |> Map.put("limit", 10) - #|> Map.put("sort", "name") + params = + get_params(params) + |> Map.put("q", query) + |> Map.put("limit", 10) + + # |> Map.put("sort", "name") case HTTPoison.get("https://api.untappd.com/v4/search/beer", headers(), params: params) do {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> {:ok, Jason.decode!(body)} + error -> - Logger.error("Untappd search error: #{inspect error}") + Logger.error("Untappd search error: #{inspect(error)}") end end def get_params(params) do - auth = %{"client_id" => Keyword.get(env(), :client_id), "client_secret" => Keyword.get(env(), :client_secret)} + auth = %{ + "client_id" => Keyword.get(env(), :client_id), + "client_secret" => Keyword.get(env(), :client_secret) + } + if token = Keyword.get(params, :token) do Map.put(auth, "access_token", token) else @@ -81,14 +102,14 @@ defmodule Untappd do def headers(extra \\ []) do client_id = Keyword.get(env(), :client_id) - extra - ++ [ - {"user-agent", "dmzbot (#{client_id}; #{@version}-#{@env})"} - ] + + extra ++ + [ + {"user-agent", "dmzbot (#{client_id}; #{@version}-#{@env})"} + ] end def env() do Application.get_env(:nola, :untappd) end - end diff --git a/lib/util.ex b/lib/util.ex index 8bd3b9d..dea7834 100644 --- a/lib/util.ex +++ b/lib/util.ex @@ -1,7 +1,17 @@ defmodule Util do + defmodule Map do + def put_if_not_null(map, _key, nil) do + map + end + + def put_if_not_null(map, key, value) do + Elixir.Map.put(map, key, value) + end + end def to_naive_date_time(naive = %NaiveDateTime{}), do: naive def to_naive_date_time(datetime = %DateTime{}), do: DateTime.to_naive(datetime) + def to_naive_date_time(timestamp) when is_integer(timestamp) do timestamp |> to_date_time() @@ -30,7 +40,8 @@ defmodule Util do def plusminus(number) when number < 0, do: "#{number}" def float_paparse(float) when is_float(float), do: {float, ""} - def float_paparse(int) when is_integer(int), do: {(int+0.0), ""} + def float_paparse(int) when is_integer(int), do: {int + 0.0, ""} + def float_paparse(string) when is_binary(string) do string |> String.replace(",", ".") @@ -45,7 +56,7 @@ defmodule Util do ets.safe_fixtable(table, false) end - defp do_ets_mutate_select_each(_, _, _, :'$end_of_table') do + defp do_ets_mutate_select_each(_, _, _, :"$end_of_table") do :ok end @@ -54,7 +65,6 @@ defmodule Util do do_ets_mutate_select_each(ets, table, fun, ets.select(continuation)) end - def ets_mutate_each(ets, table, fun) do ets.safe_fixtable(table, true) first = ets.first(table) @@ -68,7 +78,20 @@ defmodule Util do [elem] -> fun.(table, elem) _ -> nil end + do_ets_mutate_each(ets, table, fun, ets.next(table, key)) end + def read_file_list!(path) do + path + |> File.read!() + |> String.split("\n") + |> Enum.map(fn line -> + if !String.starts_with?(line, "#") do + String.trim(line) + end + end) + |> Enum.filter(& &1) + |> Enum.filter(&(&1 != "")) + end end @@ -25,22 +25,24 @@ defmodule NolaWeb do "♯" end - def format_chan("#"<>chan) do + def format_chan("#" <> chan) do chan end - def format_chan(chan = "!"<>_), do: chan + def format_chan(chan = "!" <> _), do: chan def reformat_chan("♯") do "#" end + def reformat_chan("♯♯") do "##" end - def reformat_chan(chan = "!"<>_), do: chan + + def reformat_chan(chan = "!" <> _), do: chan def reformat_chan(chan) do - "#"<>chan + "#" <> chan end def controller do @@ -55,8 +57,9 @@ defmodule NolaWeb do def view do quote do - use Phoenix.View, root: "lib/web/templates", - namespace: NolaWeb + use Phoenix.View, + root: "lib/web/templates", + namespace: NolaWeb # Import convenience functions from controllers import Phoenix.Controller, only: [get_flash: 2, view_module: 1] diff --git a/lib/web/channels/user_socket.ex b/lib/web/channels/user_socket.ex index eadd4e0..a910ffe 100644 --- a/lib/web/channels/user_socket.ex +++ b/lib/web/channels/user_socket.ex @@ -5,7 +5,7 @@ defmodule NolaWeb.UserSocket do # channel "room:*", NolaWeb.RoomChannel ## Transports - #transport :websocket, Phoenix.Transports.WebSocket + # transport :websocket, Phoenix.Transports.WebSocket # transport :longpoll, Phoenix.Transports.LongPoll # Socket params are passed from the client and can diff --git a/lib/web/components/component.ex b/lib/web/components/component.ex index fff8263..5894536 100644 --- a/lib/web/components/component.ex +++ b/lib/web/components/component.ex @@ -9,6 +9,7 @@ defmodule NolaWeb.Component do def naive_date_time_utc(assigns = %{format: format}) do assigns = assign(assigns, :format, Map.get(@date_time_formats, format, format)) + ~H""" <time class="component" id={"time-#{:erlang.phash2(@datetime)}"} @@ -19,9 +20,11 @@ defmodule NolaWeb.Component do </time> """ end + def naive_date_time_utc(assigns) do naive_date_time_utc(assign(assigns, :format, "%F %H:%M")) end + def get_luxon_format("%H:%M:%S"), do: "TIME_24_WITH_SECONDS" def nick(assigns = %{self: false}) do @@ -39,6 +42,4 @@ defmodule NolaWeb.Component do </span> """ end - - end diff --git a/lib/web/components/event_component.ex b/lib/web/components/event_component.ex index 8af3c67..32af856 100644 --- a/lib/web/components/event_component.ex +++ b/lib/web/components/event_component.ex @@ -38,6 +38,4 @@ defmodule NolaWeb.EventComponent do joined """ end - - end diff --git a/lib/web/components/message_component.ex b/lib/web/components/message_component.ex index 5d0386b..7f9bac7 100644 --- a/lib/web/components/message_component.ex +++ b/lib/web/components/message_component.ex @@ -8,5 +8,4 @@ defmodule NolaWeb.MessageComponent do <div class="inline-block flex-grow cursor-default"><%= @text %></div> """ end - end diff --git a/lib/web/context_plug.ex b/lib/web/context_plug.ex index 0a16340..aca0431 100644 --- a/lib/web/context_plug.ex +++ b/lib/web/context_plug.ex @@ -8,21 +8,27 @@ defmodule NolaWeb.ContextPlug do def get_account(conn) do cond do - get_session(conn, :account) -> get_session(conn, :account) - get_session(conn, :oidc_id) -> if account = Nola.Account.find_meta_account("identity-id", get_session(conn, :oidc_id)), do: account.id - true -> nil + get_session(conn, :account) -> + get_session(conn, :account) + + get_session(conn, :oidc_id) -> + if account = Nola.Account.find_meta_account("identity-id", get_session(conn, :oidc_id)), + do: account.id + + true -> + nil end end def call(conn, opts) do - account = with \ - {:account, account_id} when is_binary(account_id) <- {:account, get_account(conn)}, - {:account, account} when not is_nil(account) <- {:account, Nola.Account.get(account_id)} - do - account - else - _ -> nil - end + account = + with {:account, account_id} when is_binary(account_id) <- {:account, get_account(conn)}, + {:account, account} when not is_nil(account) <- + {:account, Nola.Account.get(account_id)} do + account + else + _ -> nil + end network = Map.get(conn.params, "network") network = if network == "-", do: nil, else: network @@ -30,32 +36,46 @@ defmodule NolaWeb.ContextPlug do oidc_account = Nola.Account.find_meta_account("identity-id", get_session(conn, :oidc_id)) conns = Nola.Irc.Connection.get_network(network) - chan = if c = Map.get(conn.params, "chan") do - NolaWeb.reformat_chan(c) - end + + chan = + if c = Map.get(conn.params, "chan") do + NolaWeb.reformat_chan(c) + end + chan_conn = Nola.Irc.Connection.get_network(network, chan) - memberships = if account do - Nola.Membership.of_account(account) - end + memberships = + if account do + Nola.Membership.of_account(account) + end - auth_required = cond do - Keyword.get(opts, :restrict) == :public -> false - account == nil -> true - network == nil -> false - Keyword.get(opts, :restrict) == :logged_in -> false - network && chan -> - !Enum.member?(memberships, {network, chan}) - network -> - !Enum.any?(memberships, fn({n, _}) -> n == network end) - end + auth_required = + cond do + Keyword.get(opts, :restrict) == :public -> + false - bot = cond do - network && chan && chan_conn -> chan_conn.nick - network && conns -> conns.nick - true -> nil - end + account == nil -> + true + network == nil -> + false + + Keyword.get(opts, :restrict) == :logged_in -> + false + + network && chan -> + !Enum.member?(memberships, {network, chan}) + + network -> + !Enum.any?(memberships, fn {n, _} -> n == network end) + end + + bot = + cond do + network && chan && chan_conn -> chan_conn.nick + network && conns -> conns.nick + true -> nil + end cond do account && auth_required -> @@ -63,30 +83,34 @@ defmodule NolaWeb.ContextPlug do |> put_status(404) |> text("Page not found") |> halt() + auth_required -> conn |> put_status(403) |> render(NolaWeb.AlcoologView, "auth.html", bot: bot, no_header: true, network: network) |> halt() - (network && !conns) -> + + network && !conns -> conn |> put_status(404) |> text("Page not found") |> halt() - (chan && !chan_conn) -> + + chan && !chan_conn -> conn |> put_status(404) |> text("Page not found") |> halt() + true -> - conn = conn - |> assign(:network, network) - |> assign(:chan, chan) - |> assign(:bot, bot) - |> assign(:account, account) - |> assign(:oidc_account, oidc_account) - |> assign(:memberships, memberships) + conn = + conn + |> assign(:network, network) + |> assign(:chan, chan) + |> assign(:bot, bot) + |> assign(:account, account) + |> assign(:oidc_account, oidc_account) + |> assign(:memberships, memberships) end end - end diff --git a/lib/web/controllers/alcoolog_controller.ex b/lib/web/controllers/alcoolog_controller.ex index 8d7fc11..57fc16a 100644 --- a/lib/web/controllers/alcoolog_controller.ex +++ b/lib/web/controllers/alcoolog_controller.ex @@ -2,43 +2,63 @@ defmodule NolaWeb.AlcoologController do use NolaWeb, :controller require Logger - plug NolaWeb.ContextPlug when action not in [:token] - plug NolaWeb.ContextPlug, [restrict: :public] when action in [:token] + plug(NolaWeb.ContextPlug when action not in [:token]) + plug(NolaWeb.ContextPlug, [restrict: :public] when action in [:token]) def token(conn, %{"token" => token}) do case Nola.Token.lookup(token) do - {:ok, {:alcoolog, :index, network, channel}} -> index(conn, nil, network, channel) + {:ok, {:alcoolog, :index, network, channel}} -> + index(conn, nil, network, channel) + err -> - Logger.debug("AlcoologControler: token #{inspect err} invalid") + Logger.debug("AlcoologControler: token #{inspect(err)} invalid") + conn |> put_status(404) |> text("Page not found") end end - def nick(conn = %{assigns: %{account: account}}, params = %{"network" => network, "nick" => nick}) do + def nick( + conn = %{assigns: %{account: account}}, + params = %{"network" => network, "nick" => nick} + ) do profile_account = Nola.Account.find_always_by_nick(network, nick, nick) days = String.to_integer(Map.get(params, "days", "180")) friend? = Enum.member?(Nola.Membership.friends(account), profile_account.id) + if friend? do stats = Nola.Plugins.Alcoolog.get_full_statistics(profile_account.id) - history = for {{nick, ts}, points, active, cl, deg, type, descr, meta} <- Nola.Plugins.Alcoolog.nick_history(profile_account) do - %{ - at: ts |> DateTime.from_unix!(:millisecond), - points: points, - active: active, - cl: cl, - deg: deg, - type: type, - description: descr, - meta: meta - } - end - history = Enum.sort(history, &(DateTime.compare(&1.at, &2.at) != :lt)) - |> IO.inspect() + + history = + for {{nick, ts}, points, active, cl, deg, type, descr, meta} <- + Nola.Plugins.Alcoolog.nick_history(profile_account) do + %{ + at: ts |> DateTime.from_unix!(:millisecond), + points: points, + active: active, + cl: cl, + deg: deg, + type: type, + description: descr, + meta: meta + } + end + + history = + Enum.sort(history, &(DateTime.compare(&1.at, &2.at) != :lt)) + |> IO.inspect() + conn |> assign(:title, "alcoolog #{nick}") - |> render("user.html", network: network, profile: profile_account, days: days, nick: nick, history: history, stats: stats) + |> render("user.html", + network: network, + profile: profile_account, + days: days, + nick: nick, + history: history, + stats: stats + ) else conn |> put_status(404) @@ -46,9 +66,13 @@ defmodule NolaWeb.AlcoologController do end end - def nick_stats_json(conn = %{assigns: %{account: account}}, params = %{"network" => network, "nick" => nick}) do + def nick_stats_json( + conn = %{assigns: %{account: account}}, + params = %{"network" => network, "nick" => nick} + ) do profile_account = Nola.Account.find_always_by_nick(network, nick, nick) friend? = Enum.member?(Nola.Membership.friends(account), profile_account.id) + if friend? do stats = Nola.Plugins.Alcoolog.get_full_statistics(profile_account.id) @@ -62,27 +86,39 @@ defmodule NolaWeb.AlcoologController do end end - def nick_gls_json(conn = %{assigns: %{account: account}}, params = %{"network" => network, "nick" => nick}) do + def nick_gls_json( + conn = %{assigns: %{account: account}}, + params = %{"network" => network, "nick" => nick} + ) do profile_account = Nola.Account.find_always_by_nick(network, nick, nick) friend? = Enum.member?(Nola.Membership.friends(account), profile_account.id) count = String.to_integer(Map.get(params, "days", "180")) + if friend? do data = Nola.Plugins.Alcoolog.user_over_time_gl(profile_account, count) - delay = count*((24 * 60)*60) - now = DateTime.utc_now() - start_date = DateTime.utc_now() - |> DateTime.shift_zone!("Europe/Paris", Tzdata.TimeZoneDatabase) - |> DateTime.add(-delay, :second, Tzdata.TimeZoneDatabase) - |> DateTime.to_date() - |> Date.to_erl() - filled = (:calendar.date_to_gregorian_days(start_date) .. :calendar.date_to_gregorian_days(DateTime.utc_now |> DateTime.to_date() |> Date.to_erl)) - |> Enum.to_list - |> Enum.map(&(:calendar.gregorian_days_to_date(&1))) - |> Enum.map(&Date.from_erl!(&1)) - |> Enum.map(fn(date) -> - %{date: date, gls: Map.get(data, date, 0)} - end) - |> Enum.sort(&(Date.compare(&1.date, &2.date) != :gt)) + delay = count * (24 * 60 * 60) + now = DateTime.utc_now() + + start_date = + DateTime.utc_now() + |> DateTime.shift_zone!("Europe/Paris", Tzdata.TimeZoneDatabase) + |> DateTime.add(-delay, :second, Tzdata.TimeZoneDatabase) + |> DateTime.to_date() + |> Date.to_erl() + + filled = + :calendar.date_to_gregorian_days(start_date)..:calendar.date_to_gregorian_days( + DateTime.utc_now() + |> DateTime.to_date() + |> Date.to_erl() + ) + |> Enum.to_list() + |> Enum.map(&:calendar.gregorian_days_to_date(&1)) + |> Enum.map(&Date.from_erl!(&1)) + |> Enum.map(fn date -> + %{date: date, gls: Map.get(data, date, 0)} + end) + |> Enum.sort(&(Date.compare(&1.date, &2.date) != :gt)) conn |> put_resp_content_type("application/json") @@ -94,29 +130,39 @@ defmodule NolaWeb.AlcoologController do end end - - - def nick_volumes_json(conn = %{assigns: %{account: account}}, params = %{"network" => network, "nick" => nick}) do + def nick_volumes_json( + conn = %{assigns: %{account: account}}, + params = %{"network" => network, "nick" => nick} + ) do profile_account = Nola.Account.find_always_by_nick(network, nick, nick) friend? = Enum.member?(Nola.Membership.friends(account), profile_account.id) count = String.to_integer(Map.get(params, "days", "180")) + if friend? do data = Nola.Plugins.Alcoolog.user_over_time(profile_account, count) - delay = count*((24 * 60)*60) - now = DateTime.utc_now() - start_date = DateTime.utc_now() - |> DateTime.shift_zone!("Europe/Paris", Tzdata.TimeZoneDatabase) - |> DateTime.add(-delay, :second, Tzdata.TimeZoneDatabase) - |> DateTime.to_date() - |> Date.to_erl() - filled = (:calendar.date_to_gregorian_days(start_date) .. :calendar.date_to_gregorian_days(DateTime.utc_now |> DateTime.to_date() |> Date.to_erl)) - |> Enum.to_list - |> Enum.map(&(:calendar.gregorian_days_to_date(&1))) - |> Enum.map(&Date.from_erl!(&1)) - |> Enum.map(fn(date) -> - %{date: date, volumes: Map.get(data, date, 0)} - end) - |> Enum.sort(&(Date.compare(&1.date, &2.date) != :gt)) + delay = count * (24 * 60 * 60) + now = DateTime.utc_now() + + start_date = + DateTime.utc_now() + |> DateTime.shift_zone!("Europe/Paris", Tzdata.TimeZoneDatabase) + |> DateTime.add(-delay, :second, Tzdata.TimeZoneDatabase) + |> DateTime.to_date() + |> Date.to_erl() + + filled = + :calendar.date_to_gregorian_days(start_date)..:calendar.date_to_gregorian_days( + DateTime.utc_now() + |> DateTime.to_date() + |> Date.to_erl() + ) + |> Enum.to_list() + |> Enum.map(&:calendar.gregorian_days_to_date(&1)) + |> Enum.map(&Date.from_erl!(&1)) + |> Enum.map(fn date -> + %{date: date, volumes: Map.get(data, date, 0)} + end) + |> Enum.sort(&(Date.compare(&1.date, &2.date) != :gt)) conn |> put_resp_content_type("application/json") @@ -128,22 +174,29 @@ defmodule NolaWeb.AlcoologController do end end - def nick_log_json(conn = %{assigns: %{account: account}}, %{"network" => network, "nick" => nick}) do + def nick_log_json(conn = %{assigns: %{account: account}}, %{ + "network" => network, + "nick" => nick + }) do profile_account = Nola.Account.find_always_by_nick(network, nick, nick) friend? = Enum.member?(Nola.Membership.friends(account), profile_account.id) + if friend? do - history = for {{nick, ts}, points, active, cl, deg, type, descr, meta} <- Nola.Plugins.Alcoolog.nick_history(profile_account) do - %{ - at: ts |> DateTime.from_unix!(:millisecond) |> DateTime.to_iso8601(), - points: points, - active: active, - cl: cl, - deg: deg, - type: type, - description: descr, - meta: meta - } - end + history = + for {{nick, ts}, points, active, cl, deg, type, descr, meta} <- + Nola.Plugins.Alcoolog.nick_history(profile_account) do + %{ + at: ts |> DateTime.from_unix!(:millisecond) |> DateTime.to_iso8601(), + points: points, + active: active, + cl: cl, + deg: deg, + type: type, + description: descr, + meta: meta + } + end + last = List.last(history) {_, active} = Nola.Plugins.Alcoolog.user_stats(profile_account) last = %{last | active: active, at: DateTime.utc_now() |> DateTime.to_iso8601()} @@ -159,13 +212,19 @@ defmodule NolaWeb.AlcoologController do end end - def nick_history_json(conn = %{assigns: %{account: account}}, %{"network" => network, "nick" => nick}) do + def nick_history_json(conn = %{assigns: %{account: account}}, %{ + "network" => network, + "nick" => nick + }) do profile_account = Nola.Account.find_always_by_nick(network, nick, nick) friend? = Enum.member?(Nola.Membership.friends(account), profile_account.id) + if friend? do - history = for {_, date, value} <- Nola.Plugins.AlcoologAnnouncer.log(profile_account) do - %{date: DateTime.to_iso8601(date), value: value} - end + history = + for {_, date, value} <- Nola.Plugins.AlcoologAnnouncer.log(profile_account) do + %{date: DateTime.to_iso8601(date), value: value} + end + conn |> put_resp_content_type("application/json") |> text(Jason.encode!(history)) @@ -184,7 +243,7 @@ defmodule NolaWeb.AlcoologController do index(conn, account, nil, nil) end - #def index(conn, params) do + # def index(conn, params) do # network = Map.get(params, "network") # chan = if c = Map.get(params, "chan") do # NolaWeb.reformat_chan(c) @@ -197,117 +256,175 @@ defmodule NolaWeb.AlcoologController do # conn # |> put_status(403) # |> render("auth.html", network: network, channel: chan, irc_conn: conn, bot: bot) - #end + # end def index(conn, account, network, channel) do - aday = ((24 * 60)*60) + aday = 24 * 60 * 60 now = DateTime.utc_now() - before7 = now - |> DateTime.add(-(7*aday), :second) - |> DateTime.to_unix(:millisecond) - before15 = now - |> DateTime.add(-(15*aday), :second) - |> DateTime.to_unix(:millisecond) - before31 = now - |> DateTime.add(-(31*aday), :second) - |> DateTime.to_unix(:millisecond) - #match = :ets.fun2ms(fn(obj = {{^nick, date}, _, _, _, _}) when date > before -> obj end) + + before7 = + now + |> DateTime.add(-(7 * aday), :second) + |> DateTime.to_unix(:millisecond) + + before15 = + now + |> DateTime.add(-(15 * aday), :second) + |> DateTime.to_unix(:millisecond) + + before31 = + now + |> DateTime.add(-(31 * aday), :second) + |> DateTime.to_unix(:millisecond) + + # match = :ets.fun2ms(fn(obj = {{^nick, date}, _, _, _, _}) when date > before -> obj end) match = [ - {{{:_, :"$1"}, :_, :_, :_, :_, :_, :_, :_}, - [ - {:>, :"$1", {:const, before15}}, - ], [:"$_"]} + {{{:_, :"$1"}, :_, :_, :_, :_, :_, :_, :_}, + [ + {:>, :"$1", {:const, before15}} + ], [:"$_"]} ] - # tuple ets: {{nick, date}, volumes, current, nom, commentaire} + # tuple ets: {{nick, date}, volumes, current, nom, commentaire} members = Nola.Membership.expanded_members_or_friends(account, network, channel) - members_ids = Enum.map(members, fn({account, _, nick}) -> account.id end) - member_names = Enum.reduce(members, %{}, fn({account, _, nick}, acc) -> Map.put(acc, account.id, nick) end) - drinks = :ets.select(Nola.Plugins.Alcoolog.ETS, match) - |> Enum.filter(fn({{account, _}, _vol, _cur, _cl, _deg, _name, _cmt, _meta}) -> Enum.member?(members_ids, account) end) - |> Enum.map(fn({{account, _}, _, _, _, _, _, _, _} = object) -> {object, Map.get(member_names, account)} end) - |> Enum.sort_by(fn({{{_, ts}, _, _, _, _, _, _, _}, _}) -> ts end, &>/2) + members_ids = Enum.map(members, fn {account, _, nick} -> account.id end) + + member_names = + Enum.reduce(members, %{}, fn {account, _, nick}, acc -> Map.put(acc, account.id, nick) end) + + drinks = + :ets.select(Nola.Plugins.Alcoolog.ETS, match) + |> Enum.filter(fn {{account, _}, _vol, _cur, _cl, _deg, _name, _cmt, _meta} -> + Enum.member?(members_ids, account) + end) + |> Enum.map(fn {{account, _}, _, _, _, _, _, _, _} = object -> + {object, Map.get(member_names, account)} + end) + |> Enum.sort_by(fn {{{_, ts}, _, _, _, _, _, _, _}, _} -> ts end, &>/2) stats = Nola.Plugins.Alcoolog.get_channel_statistics(account, network, channel) - top = Enum.reduce(drinks, %{}, fn({{{account_id, _}, vol, _, _, _, _, _, _}, _}, acc) -> - nick = Map.get(member_names, account_id) - all = Map.get(acc, nick, 0) - Map.put(acc, nick, all + vol) - end) - |> Enum.sort_by(fn({_nick, count}) -> count end, &>/2) + top = + Enum.reduce(drinks, %{}, fn {{{account_id, _}, vol, _, _, _, _, _, _}, _}, acc -> + nick = Map.get(member_names, account_id) + all = Map.get(acc, nick, 0) + Map.put(acc, nick, all + vol) + end) + |> Enum.sort_by(fn {_nick, count} -> count end, &>/2) + # {date, single_peak} # conn |> assign(:title, "alcoolog") - |> render("index.html", network: network, channel: channel, drinks: drinks, top: top, stats: stats) + |> render("index.html", + network: network, + channel: channel, + drinks: drinks, + top: top, + stats: stats + ) end - def index_gls_json(conn = %{assigns: %{account: account}}, %{"network" => network, "chan" => channel}) do + def index_gls_json(conn = %{assigns: %{account: account}}, %{ + "network" => network, + "chan" => channel + }) do count = 30 channel = NolaWeb.reformat_chan(channel) members = Nola.Membership.expanded_members_or_friends(account, network, channel) - members_ids = Enum.map(members, fn({account, _, nick}) -> account.id end) - member_names = Enum.reduce(members, %{}, fn({account, _, nick}, acc) -> Map.put(acc, account.id, nick) end) - delay = count*((24 * 60)*60) + members_ids = Enum.map(members, fn {account, _, nick} -> account.id end) + + member_names = + Enum.reduce(members, %{}, fn {account, _, nick}, acc -> Map.put(acc, account.id, nick) end) + + delay = count * (24 * 60 * 60) now = DateTime.utc_now() - start_date = DateTime.utc_now() - |> DateTime.shift_zone!("Europe/Paris", Tzdata.TimeZoneDatabase) - |> DateTime.add(-delay, :second, Tzdata.TimeZoneDatabase) - |> DateTime.to_date() - |> Date.to_erl() - filled = (:calendar.date_to_gregorian_days(start_date) .. :calendar.date_to_gregorian_days(DateTime.utc_now |> DateTime.to_date() |> Date.to_erl)) - |> Enum.to_list - |> Enum.map(&(:calendar.gregorian_days_to_date(&1))) - |> Enum.map(&Date.from_erl!(&1)) - |> Enum.map(fn(date) -> - {date, (for {a, _, _} <- members, into: Map.new, do: {Map.get(member_names, a.id, a.id), 0})} - end) - |> Enum.into(Map.new) - - gls = Enum.reduce(members, filled, fn({account, _, _}, gls) -> - Enum.reduce(Nola.Plugins.Alcoolog.user_over_time_gl(account, count), gls, fn({date, gl}, gls) -> - u = Map.get(gls, date, %{}) + + start_date = + DateTime.utc_now() + |> DateTime.shift_zone!("Europe/Paris", Tzdata.TimeZoneDatabase) + |> DateTime.add(-delay, :second, Tzdata.TimeZoneDatabase) + |> DateTime.to_date() + |> Date.to_erl() + + filled = + :calendar.date_to_gregorian_days(start_date)..:calendar.date_to_gregorian_days( + DateTime.utc_now() + |> DateTime.to_date() + |> Date.to_erl() + ) + |> Enum.to_list() + |> Enum.map(&:calendar.gregorian_days_to_date(&1)) + |> Enum.map(&Date.from_erl!(&1)) + |> Enum.map(fn date -> + {date, + for({a, _, _} <- members, into: Map.new(), do: {Map.get(member_names, a.id, a.id), 0})} + end) + |> Enum.into(Map.new()) + + gls = + Enum.reduce(members, filled, fn {account, _, _}, gls -> + Enum.reduce(Nola.Plugins.Alcoolog.user_over_time_gl(account, count), gls, fn {date, gl}, + gls -> + u = + Map.get(gls, date, %{}) |> Map.put(Map.get(member_names, account.id, account.id), gl) - Map.put(gls, date, u) + + Map.put(gls, date, u) + end) end) - end) - - dates = (:calendar.date_to_gregorian_days(start_date) .. :calendar.date_to_gregorian_days(DateTime.utc_now |> DateTime.to_date() |> Date.to_erl)) - |> Enum.to_list - |> Enum.map(&(:calendar.gregorian_days_to_date(&1))) - |> Enum.map(&Date.from_erl!(&1)) - - filled2 = Enum.map(member_names, fn({_, name}) -> - history = (:calendar.date_to_gregorian_days(start_date) .. :calendar.date_to_gregorian_days(DateTime.utc_now |> DateTime.to_date() |> Date.to_erl)) - |> Enum.to_list - |> Enum.map(&(:calendar.gregorian_days_to_date(&1))) - |> Enum.map(&Date.from_erl!(&1)) - |> Enum.map(fn(date) -> - get_in(gls, [date, name]) #%{date: date, gl: get_in(gls, [date, name])} - end) - if Enum.all?(history, fn(x) -> x == 0 end) do - nil - else - %{name: name, history: history} - end - end) - |> Enum.filter(fn(x) -> x end) - conn - |> put_resp_content_type("application/json") - |> text(Jason.encode!(%{labels: dates, data: filled2})) + dates = + :calendar.date_to_gregorian_days(start_date)..:calendar.date_to_gregorian_days( + DateTime.utc_now() + |> DateTime.to_date() + |> Date.to_erl() + ) + |> Enum.to_list() + |> Enum.map(&:calendar.gregorian_days_to_date(&1)) + |> Enum.map(&Date.from_erl!(&1)) + + filled2 = + Enum.map(member_names, fn {_, name} -> + history = + :calendar.date_to_gregorian_days(start_date)..:calendar.date_to_gregorian_days( + DateTime.utc_now() + |> DateTime.to_date() + |> Date.to_erl() + ) + |> Enum.to_list() + |> Enum.map(&:calendar.gregorian_days_to_date(&1)) + |> Enum.map(&Date.from_erl!(&1)) + |> Enum.map(fn date -> + # %{date: date, gl: get_in(gls, [date, name])} + get_in(gls, [date, name]) + end) + + if Enum.all?(history, fn x -> x == 0 end) do + nil + else + %{name: name, history: history} + end + end) + |> Enum.filter(fn x -> x end) + + conn + |> put_resp_content_type("application/json") + |> text(Jason.encode!(%{labels: dates, data: filled2})) end def minisync(conn, %{"user_id" => user_id, "key" => key, "value" => value}) do account = Nola.Account.get(user_id) + if account do ds = Nola.Plugins.Alcoolog.data_state() meta = Nola.Plugins.Alcoolog.get_user_meta(ds, account.id) + case Float.parse(value) do {val, _} -> new_meta = Map.put(meta, String.to_existing_atom(key), val) Nola.Plugins.Alcoolog.put_user_meta(ds, account.id, new_meta) + _ -> conn |> put_status(:unprocessable_entity) @@ -319,5 +436,4 @@ defmodule NolaWeb.AlcoologController do |> text("not found") end end - end diff --git a/lib/web/controllers/gpt_controller.ex b/lib/web/controllers/gpt_controller.ex deleted file mode 100644 index 810a875..0000000 --- a/lib/web/controllers/gpt_controller.ex +++ /dev/null @@ -1,33 +0,0 @@ -defmodule NolaWeb.GptController do - use NolaWeb, :controller - require Logger - - plug NolaWeb.ContextPlug - - def result(conn, params = %{"id" => result_id}) do - case Nola.Plugins.Gpt.get_result(result_id) do - {:ok, result} -> - network = Map.get(params, "network") - channel = if c = Map.get(params, "chan"), do: NolaWeb.reformat_chan(c) - render(conn, "result.html", network: network, channel: channel, result: result) - {:error, :not_found} -> - conn - |> put_status(404) - |> text("Page not found") - end - end - - def prompt(conn, params = %{"id" => prompt_id}) do - case Nola.Plugins.Gpt.get_prompt(prompt_id) do - {:ok, prompt} -> - network = Map.get(params, "network") - channel = if c = Map.get(params, "chan"), do: NolaWeb.reformat_chan(c) - render(conn, "prompt.html", network: network, channel: channel, prompt: prompt) - {:error, :not_found} -> - conn - |> put_status(404) - |> text("Page not found") - end - end - -end diff --git a/lib/web/controllers/icecast_see_controller.ex b/lib/web/controllers/icecast_see_controller.ex index 877ad4e..ca8fb2d 100644 --- a/lib/web/controllers/icecast_see_controller.ex +++ b/lib/web/controllers/icecast_see_controller.ex @@ -11,7 +11,7 @@ defmodule NolaWeb.IcecastSseController do |> send_chunked(200) |> subscribe |> send_sse_message("ping", "ping") - |> send_sse_message("icecast", Nola.IcecastAgent.get) + |> send_sse_message("icecast", Nola.IcecastAgent.get()) |> sse_loop end @@ -22,10 +22,11 @@ defmodule NolaWeb.IcecastSseController do end def sse_loop(conn) do - {type, event} = receive do - {:event, :ping} -> {"ping", "ping"} - {:icecast, stats} -> {"icecast", stats} - end + {type, event} = + receive do + {:event, :ping} -> {"ping", "ping"} + {:icecast, stats} -> {"icecast", stats} + end conn |> send_sse_message(type, event) @@ -37,5 +38,4 @@ defmodule NolaWeb.IcecastSseController do {:ok, conn} = chunk(conn, "event: #{type}\ndata: #{json}\n\n") conn end - end diff --git a/lib/web/controllers/irc_auth_sse_controller.ex b/lib/web/controllers/irc_auth_sse_controller.ex index 01c840b..f67a77f 100644 --- a/lib/web/controllers/irc_auth_sse_controller.ex +++ b/lib/web/controllers/irc_auth_sse_controller.ex @@ -6,12 +6,15 @@ defmodule NolaWeb.IrcAuthSseController do @expire_delay :timer.minutes(3) def sse(conn, params) do - perks = if uri = Map.get(params, "redirect_to") do - {:redirect, uri} - else - nil - end + 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) @@ -31,25 +34,33 @@ defmodule NolaWeb.IrcAuthSseController do 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 + {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 - _ -> {nil, nil, false} - end + end - conn = if type do - send_sse_message(conn, type, event) - else - conn - end + conn = + if type do + send_sse_message(conn, type, event) + else + conn + end if exit do conn @@ -62,5 +73,4 @@ defmodule NolaWeb.IrcAuthSseController do {:ok, conn} = chunk(conn, "event: #{type}\ndata: #{data}\n\n") conn end - end diff --git a/lib/web/controllers/irc_controller.ex b/lib/web/controllers/irc_controller.ex index e87382b..9de807b 100644 --- a/lib/web/controllers/irc_controller.ex +++ b/lib/web/controllers/irc_controller.ex @@ -1,31 +1,46 @@ defmodule NolaWeb.IrcController do use NolaWeb, :controller - plug NolaWeb.ContextPlug + plug(NolaWeb.ContextPlug) def index(conn, params) do network = Map.get(params, "network") channel = if c = Map.get(params, "chan"), do: NolaWeb.reformat_chan(c) - commands = for mod <- Enum.uniq([Nola.Plugins.Account] ++ Nola.Plugins.enabled()) do - if is_atom(mod) do - identifier = Module.split(mod) |> List.last |> Macro.underscore - {identifier, mod.irc_doc()} + + commands = + for mod <- Enum.uniq([Nola.Plugins.Account] ++ Nola.Plugins.enabled()) do + if is_atom(mod) do + identifier = Module.split(mod) |> List.last() |> Macro.underscore() + if Kernel.function_exported?(mod, :irc_doc, 0), do: {identifier, mod.irc_doc()} + end end - end - |> Enum.filter(& &1) - |> Enum.filter(fn({_, doc}) -> doc end) - members = cond do - network && channel -> Enum.map(Nola.UserTrack.channel(network, channel), fn(tuple) -> Nola.UserTrack.User.from_tuple(tuple) end) - true -> - Nola.Membership.of_account(conn.assigns.account) - end - render conn, "index.html", network: network, commands: commands, channel: channel, members: members + |> Enum.filter(& &1) + |> Enum.filter(fn {_, doc} -> doc end) + + members = + cond do + network && channel -> + Enum.map(Nola.UserTrack.channel(network, channel), fn tuple -> + Nola.UserTrack.User.from_tuple(tuple) + end) + + true -> + Nola.Membership.of_account(conn.assigns.account) + end + + render(conn, "index.html", + network: network, + commands: commands, + channel: channel, + members: members + ) end def txt(conn, %{"name" => name}) do if String.contains?(name, ".txt") do name = String.replace(name, ".txt", "") data = data() + if Map.has_key?(data, name) do lines = Enum.join(data[name], "\n") text(conn, lines) @@ -38,32 +53,54 @@ defmodule NolaWeb.IrcController do do_txt(conn, name) end end - def txt(conn, _), do: do_txt(conn, nil) + def txt(conn, _), do: do_txt(conn, nil) defp do_txt(conn, nil) do doc = Nola.Plugins.Txt.irc_doc() data = data() - main = Enum.filter(data, fn({trigger, _}) -> !String.contains?(trigger, ".") end) |> Enum.into(Map.new) - system = Enum.filter(data, fn({trigger, _}) -> String.contains?(trigger, ".") end) |> Enum.into(Map.new) - lines = Enum.reduce(main, 0, fn({_, lines}, acc) -> acc + Enum.count(lines) end) + + main = + Enum.filter(data, fn {trigger, _} -> !String.contains?(trigger, ".") end) + |> Enum.into(Map.new()) + + system = + Enum.filter(data, fn {trigger, _} -> String.contains?(trigger, ".") end) + |> Enum.into(Map.new()) + + lines = Enum.reduce(main, 0, fn {_, lines}, acc -> acc + Enum.count(lines) end) + conn |> assign(:title, "txt") - |> render("txts.html", data: main, doc: doc, files: Enum.count(main), lines: lines, system: system) + |> render("txts.html", + data: main, + doc: doc, + files: Enum.count(main), + lines: lines, + system: system + ) end defp do_txt(conn, txt) do data = data() - base_url = cond do - conn.assigns[:chan] -> "/#{conn.assigns.network}/#{NolaWeb.format_chan(conn.assigns.chan)}" - true -> "/-" - end + + base_url = + cond do + conn.assigns[:chan] -> + "/#{conn.assigns.network}/#{NolaWeb.format_chan(conn.assigns.chan)}" + + true -> + "/-" + end + if lines = Map.get(data, txt) do - lines = Enum.map(lines, fn(line) -> - line - |> String.split("\\\\") - |> Enum.intersperse(Phoenix.HTML.Tag.tag(:br)) - end) + lines = + Enum.map(lines, fn line -> + line + |> String.split("\\\\") + |> Enum.intersperse(Phoenix.HTML.Tag.tag(:br)) + end) + conn |> assign(:breadcrumbs, [{"txt", "#{base_url}/txt"}]) |> assign(:title, "#{txt}.txt") @@ -77,25 +114,28 @@ defmodule NolaWeb.IrcController do defp data() do dir = Application.get_env(:nola, :data_path) <> "/irc.txt/" + Path.wildcard(dir <> "/*.txt") - |> Enum.reduce(%{}, fn(path, m) -> + |> Enum.reduce(%{}, fn path, m -> path = String.split(path, "/") file = List.last(path) key = String.replace(file, ".txt", "") - data = dir <> file - |> File.read! - |> String.split("\n") - |> Enum.reject(fn(line) -> - cond do - line == "" -> true - !line -> true - true -> false - end - end) + + data = + (dir <> file) + |> File.read!() + |> String.split("\n") + |> Enum.reject(fn line -> + cond do + line == "" -> true + !line -> true + true -> false + end + end) + Map.put(m, key, data) end) - |> Enum.sort - |> Enum.into(Map.new) + |> Enum.sort() + |> Enum.into(Map.new()) end - end diff --git a/lib/web/controllers/network_controller.ex b/lib/web/controllers/network_controller.ex index 800294f..07cc291 100644 --- a/lib/web/controllers/network_controller.ex +++ b/lib/web/controllers/network_controller.ex @@ -1,11 +1,10 @@ defmodule NolaWeb.NetworkController do use NolaWeb, :controller - plug NolaWeb.ContextPlug + plug(NolaWeb.ContextPlug) def index(conn, %{"network" => network}) do conn |> assign(:title, network) |> render("index.html") end - end diff --git a/lib/web/controllers/open_id_controller.ex b/lib/web/controllers/open_id_controller.ex index 24dc1a5..b8ea505 100644 --- a/lib/web/controllers/open_id_controller.ex +++ b/lib/web/controllers/open_id_controller.ex @@ -1,10 +1,15 @@ defmodule NolaWeb.OpenIdController do use NolaWeb, :controller - plug NolaWeb.ContextPlug, restrict: :public + plug(NolaWeb.ContextPlug, restrict: :public) require Logger def login(conn, _) do - url = OAuth2.Client.authorize_url!(new_client(), scope: "openid", state: Base.url_encode64(:crypto.strong_rand_bytes(32), padding: false)) + url = + OAuth2.Client.authorize_url!(new_client(), + scope: "openid", + state: Base.url_encode64(:crypto.strong_rand_bytes(32), padding: false) + ) + redirect(conn, external: url) end @@ -14,19 +19,21 @@ defmodule NolaWeb.OpenIdController do end def callback(conn, %{"code" => code, "state" => state}) do - with \ - client = %{token: %OAuth2.AccessToken{access_token: json}} = OAuth2.Client.get_token!(new_client(), state: state, code: code), + with client = + %{token: %OAuth2.AccessToken{access_token: json}} = + OAuth2.Client.get_token!(new_client(), state: state, code: code), {:ok, %{"access_token" => token}} <- Jason.decode(json), - client = %OAuth2.Client{client | token: %OAuth2.AccessToken{access_token: token}}, + client = %OAuth2.Client{client | token: %OAuth2.AccessToken{access_token: token}}, {:ok, %OAuth2.Response{body: body}} <- OAuth2.Client.get(client, "/userinfo"), - {:ok, %{"sub" => id, "preferred_username" => username}} <- Jason.decode(body) - do + {:ok, %{"sub" => id, "preferred_username" => username}} <- Jason.decode(body) do if account = conn.assigns.account do - if !Nola.Account.get_meta(account, "identity-id") do # XXX: And oidc id not linked yet - Nola.Account.put_meta(account, "identity-id", id) - end - Nola.Account.put_meta(account, "identity-username", username) - conn + # XXX: And oidc id not linked yet + if !Nola.Account.get_meta(account, "identity-id") do + Nola.Account.put_meta(account, "identity-id", id) + end + + Nola.Account.put_meta(account, "identity-username", username) + conn else conn end @@ -39,8 +46,9 @@ defmodule NolaWeb.OpenIdController do {:error, %OAuth2.Response{status_code: 401}} -> Logger.error("OpenID: Unauthorized token") render(conn, "error.html", error: "The token is invalid.") + {:error, %OAuth2.Error{reason: reason}} -> - Logger.error("Error: #{inspect reason}") + Logger.error("Error: #{inspect(reason)}") render(conn, "error.html", error: reason) end end @@ -51,7 +59,8 @@ defmodule NolaWeb.OpenIdController do defp new_client() do config = Application.get_env(:nola, :oidc) - OAuth2.Client.new([ + + OAuth2.Client.new( strategy: OAuth2.Strategy.AuthCode, client_id: config[:client_id], client_secret: config[:client_secret], @@ -59,6 +68,6 @@ defmodule NolaWeb.OpenIdController do authorize_url: config[:authorize_url], token_url: config[:token_url], redirect_uri: Routes.open_id_url(NolaWeb.Endpoint, :callback) - ]) + ) end end diff --git a/lib/web/controllers/page_controller.ex b/lib/web/controllers/page_controller.ex index cb46b8f..4f3c44e 100644 --- a/lib/web/controllers/page_controller.ex +++ b/lib/web/controllers/page_controller.ex @@ -1,15 +1,14 @@ defmodule NolaWeb.PageController do use NolaWeb, :controller - plug NolaWeb.ContextPlug when action not in [:token] - plug NolaWeb.ContextPlug, [restrict: :public] when action in [:token] + plug(NolaWeb.ContextPlug when action not in [:token]) + plug(NolaWeb.ContextPlug, [restrict: :public] when action in [:token]) def token(conn, %{"token" => token}) do - with \ - {:ok, account, perks} <- Nola.AuthToken.lookup(token) - do - IO.puts("Authenticated account #{inspect account}") + with {:ok, account, perks} <- Nola.AuthToken.lookup(token) do + IO.puts("Authenticated account #{inspect(account)}") conn = put_session(conn, :account, account) + case perks do nil -> redirect(conn, to: "/") {:redirect, path} -> redirect(conn, to: path) @@ -27,27 +26,33 @@ defmodule NolaWeb.PageController do users = Nola.UserTrack.find_by_account(account) metas = Nola.Account.get_all_meta(account) predicates = Nola.Account.get_predicates(account) + conn |> assign(:title, account.name) - |> render("user.html", users: users, memberships: memberships, metas: metas, predicates: predicates) + |> render("user.html", + users: users, + memberships: memberships, + metas: metas, + predicates: predicates + ) end def irc(conn, _) do - bot_helps = for mod <- Nola.Irc.env(:handlers) do - mod.irc_doc() - end - render conn, "irc.html", bot_helps: bot_helps + bot_helps = + for mod <- Nola.Irc.env(:handlers) do + mod.irc_doc() + end + + render(conn, "irc.html", bot_helps: bot_helps) end def authenticate(conn, _) do - with \ - {:account, account_id} when is_binary(account_id) <- {:account, get_session(conn, :account)}, - {:account, account} when not is_nil(account) <- {:account, Nola.Account.get(account_id)} - do + with {:account, account_id} when is_binary(account_id) <- + {:account, get_session(conn, :account)}, + {:account, account} when not is_nil(account) <- {:account, Nola.Account.get(account_id)} do assign(conn, :account, account) else _ -> conn end end - end diff --git a/lib/web/controllers/sms_controller.ex b/lib/web/controllers/sms_controller.ex index 0fffa23..20a58bd 100644 --- a/lib/web/controllers/sms_controller.ex +++ b/lib/web/controllers/sms_controller.ex @@ -3,8 +3,7 @@ defmodule NolaWeb.SmsController do require Logger def ovh_callback(conn, %{"senderid" => from, "message" => message}) do - spawn(fn() -> Nola.Plugins.Sms.incoming(from, String.trim(message)) end) + spawn(fn -> Nola.Plugins.Sms.incoming(from, String.trim(message)) end) text(conn, "") end - end diff --git a/lib/web/controllers/untappd_controller.ex b/lib/web/controllers/untappd_controller.ex index e2e1596..f47f526 100644 --- a/lib/web/controllers/untappd_controller.ex +++ b/lib/web/controllers/untappd_controller.ex @@ -2,11 +2,10 @@ defmodule NolaWeb.UntappdController do use NolaWeb, :controller def callback(conn, %{"code" => code}) do - with \ - {:account, account_id} when is_binary(account_id) <- {:account, get_session(conn, :account)}, + with {:account, account_id} when is_binary(account_id) <- + {:account, get_session(conn, :account)}, {:account, account} when not is_nil(account) <- {:account, Nola.Account.get(account_id)}, - {:ok, auth_token} <- Untappd.auth_callback(code) - do + {:ok, auth_token} <- Untappd.auth_callback(code) do Nola.Account.put_meta(account, "untappd-token", auth_token) text(conn, "OK!") else @@ -14,5 +13,4 @@ defmodule NolaWeb.UntappdController do :error -> text(conn, "Error: untappd authentication failed") end end - end diff --git a/lib/web/endpoint.ex b/lib/web/endpoint.ex index a401f54..b54458d 100644 --- a/lib/web/endpoint.ex +++ b/lib/web/endpoint.ex @@ -1,49 +1,47 @@ defmodule NolaWeb.Endpoint do - use Sentry.PlugCapture use Phoenix.Endpoint, otp_app: :nola # Serve at "/" the static files from "priv/static" directory. # # You should set gzip to true if you are running phoenix.digest # when deploying your static files in production. - plug Plug.Static, - at: "/", from: :nola, gzip: false, + plug(Plug.Static, + at: "/", + from: :nola, + gzip: false, only: ~w(assets css js fonts images favicon.ico robots.txt) + ) # Code reloading can be explicitly enabled under the # :code_reloader configuration of your endpoint. - if 42==43 && code_reloading? do - socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket - plug Phoenix.LiveReloader - plug Phoenix.CodeReloader + if 42 == 43 && code_reloading? do + socket("/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket) + plug(Phoenix.LiveReloader) + plug(Phoenix.CodeReloader) end - plug Plug.RequestId - plug Plug.Logger + plug(Plug.RequestId) + plug(Plug.Logger) - plug Plug.Parsers, + plug(Plug.Parsers, parsers: [:urlencoded, :multipart, :json], pass: ["*/*"], json_decoder: Jason + ) - plug Sentry.PlugContext - plug Plug.MethodOverride - plug Plug.Head + plug(Plug.MethodOverride) + plug(Plug.Head) - @session_options [store: :cookie, - key: "_nola_key", - signing_salt: "+p7K3wrj"] + @session_options [store: :cookie, key: "_nola_key", signing_salt: "+p7K3wrj"] - - socket "/live", Phoenix.LiveView.Socket, - websocket: [connect_info: [session: @session_options]] + socket("/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]]) # The session will be stored in the cookie and signed, # this means its contents can be read but not tampered with. # Set :encryption_salt if you would also like to encrypt it. - plug Plug.Session, @session_options + plug(Plug.Session, @session_options) - plug NolaWeb.Router + plug(NolaWeb.Router) @doc """ Callback invoked for dynamically configuring the endpoint. diff --git a/lib/web/live/chat_live.ex b/lib/web/live/chat_live.ex index 8a9f6f2..0d45428 100644 --- a/lib/web/live/chat_live.ex +++ b/lib/web/live/chat_live.ex @@ -7,38 +7,47 @@ defmodule NolaWeb.ChatLive do chan = NolaWeb.reformat_chan(chan) connection = Nola.Irc.Connection.get_network(network, chan) account = Nola.Account.get(account_id) - membership = Nola.Membership.of_account(Nola.Account.get("DRgpD4fLf8PDJMLp8Dtb")) + membership = Nola.Membership.of_account(Nola.Account.get(account.id)) + if account && connection && Enum.member?(membership, {connection.network, chan}) do - {:ok, _} = Registry.register(Nola.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(Nola.PubSub, "#{connection.network}/#{chan}:#{t}", plugin: __MODULE__) + {:ok, _} = + Registry.register(Nola.PubSub, "#{connection.network}/#{chan}:#{t}", plugin: __MODULE__) end Nola.Irc.PuppetConnection.start(account, connection) - users = Nola.UserTrack.channel(connection.network, chan) - |> Enum.map(fn(tuple) -> Nola.UserTrack.User.from_tuple(tuple) end) - |> Enum.reduce(Map.new, fn(user = %{id: id}, acc) -> - Map.put(acc, id, user) - end) - - backlog = case Nola.Plugins.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) + users = + Nola.UserTrack.channel(connection.network, chan) + |> Enum.map(fn tuple -> Nola.UserTrack.User.from_tuple(tuple) end) + |> Enum.reduce(Map.new(), fn user = %{id: id}, acc -> + Map.put(acc, id, user) + end) + + backlog = + case Nola.Plugins.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 @@ -54,9 +63,11 @@ defmodule NolaWeb.ChatLive do def handle_info({:irc, :event, event = %{type: :join, user_id: id}}, socket) do if user = Nola.UserTrack.lookup(id) do - socket = socket - |> assign(:users, Map.put(socket.assigns.users, id, user)) - |> append_to_backlog(event) + socket = + socket + |> assign(:users, Map.put(socket.assigns.users, id, user)) + |> append_to_backlog(event) + {:noreply, socket} else {:noreply, socket} @@ -64,23 +75,29 @@ defmodule NolaWeb.ChatLive do 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) + 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) + 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) + socket = + socket + |> assign(:users, Map.delete(socket.assigns.users, id)) + |> append_to_backlog(event) + {:noreply, socket} end @@ -88,15 +105,19 @@ defmodule NolaWeb.ChatLive 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) + # type is text, out, or nil if it's self? + def handle_info({:irc, type, message = %Nola.Message{}}, socket) do + IO.inspect({:live_message, type, message}) + + socket = + socket + |> append_to_backlog(message) + {:noreply, socket} end def handle_info(info, socket) do - Logger.debug("Unhandled info: #{inspect info}") + Logger.debug("Unhandled info: #{inspect(info)}") {:noreply, socket} end @@ -108,13 +129,12 @@ defmodule NolaWeb.ChatLive do 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 - + else + {[line | acc], line} + end end - end diff --git a/lib/web/live/chat_live.html.heex b/lib/web/live/chat_live.html.heex index 470604f..c3bb030 100644 --- a/lib/web/live/chat_live.html.heex +++ b/lib/web/live/chat_live.html.heex @@ -26,10 +26,10 @@ <%= for message <- @backlog do %> <%= if is_map(message) && Map.get(message, :__struct__) == Nola.Message do %> <li class="flex gap-2 place-items-center message" - data-account-id={message.account.id}> + data-account-id={if(message.account, do: message.account.id, else: "bot")}> <NolaWeb.MessageComponent.content message={message} - self={message.account.id == @account_id} + self={message.account && message.account.id == @account_id} text={message.text} /> </li> diff --git a/lib/web/router.ex b/lib/web/router.ex index 5658fda..18509ac 100644 --- a/lib/web/router.ex +++ b/lib/web/router.ex @@ -2,84 +2,86 @@ defmodule NolaWeb.Router do use NolaWeb, :router pipeline :browser do - plug :accepts, ["html", "txt"] - plug :fetch_session - plug :fetch_flash - plug :fetch_live_flash - plug :protect_from_forgery - plug :put_secure_browser_headers - plug :put_root_layout, {NolaWeb.LayoutView, :root} + plug(:accepts, ["html", "txt"]) + plug(:fetch_session) + plug(:fetch_flash) + plug(:fetch_live_flash) + plug(:protect_from_forgery) + plug(:put_secure_browser_headers) + plug(:put_root_layout, {NolaWeb.LayoutView, :root}) end pipeline :api do - plug :accepts, ["json", "sse"] + plug(:accepts, ["json", "sse"]) end pipeline :matrix_app_service do - plug :accepts, ["json"] - plug Nola.Matrix.Plug.Auth - plug Nola.Matrix.Plug.SetConfig + plug(:accepts, ["json"]) + plug(Nola.Matrix.Plug.Auth) + plug(Nola.Matrix.Plug.SetConfig) end scope "/api", NolaWeb do - pipe_through :api - get "/irc-auth.sse", IrcAuthSseController, :sse - post "/sms/callback/Ovh", SmsController, :ovh_callback, as: :sms + pipe_through(:api) + get("/irc-auth.sse", IrcAuthSseController, :sse) + post("/sms/callback/Ovh", SmsController, :ovh_callback, as: :sms) end scope "/", NolaWeb do - pipe_through :browser - get "/", PageController, :index - - get "/login/irc/:token", PageController, :token, as: :login - get "/login/oidc", OpenIdController, :login - get "/login/oidc/callback", OpenIdController, :callback - - get "/api/untappd/callback", UntappdController, :callback, as: :untappd_callback - - get "/-", IrcController, :index - get "/-/txt", IrcController, :txt - get "/-/txt/:name", IrcController, :txt - get "/-/gpt/prompt/:id", GptController, :task - get "/-/gpt/result/:id", GptController, :result - - get "/-/alcoolog", AlcoologController, :index - get "/-/alcoolog/~/:account_name", AlcoologController, :index - get "/:network", NetworkController, :index - get "/:network/~:nick/alcoolog", AlcoologController, :nick - get "/:network/~:nick/alcoolog/log.json", AlcoologController, :nick_log_json - get "/:network/~:nick/alcoolog/gls.json", AlcoologController, :nick_gls_json - get "/:network/~:nick/alcoolog/volumes.json", AlcoologController, :nick_volumes_json - get "/:network/~:nick/alcoolog/history.json", AlcoologController, :nick_history_json - get "/:network/~:nick/alcoolog/stats.json", AlcoologController, :nick_stats_json - get "/:network/:chan/alcoolog", AlcoologController, :index - get "/:network/:chan/alcoolog/gls.json", AlcoologController, :index_gls_json - get "/:network/:chan/gpt/prompt/:id", GptController, :task - get "/:network/:chan/gpt/result/:id", GptController, :result - put "/api/alcoolog/minisync/:user_id/meta/:key", AlcoologController, :minisync_put_meta - - get "/:network/:chan", IrcController, :index - live "/:network/:chan/live", ChatLive - get "/:network/:chan/txt", IrcController, :txt - get "/:network/:chan/txt/:name", IrcController, :txt - get "/:network/:channel/preums", IrcController, :preums - get "/:network/:chan/alcoolog/t/:token", AlcoologController, :token - end + pipe_through(:browser) + get("/", PageController, :index) - scope "/_matrix/:appservice", MatrixAppServiceWeb.V1, as: :matrix do - pipe_through :matrix_app_service + get("/login/irc/:token", PageController, :token, as: :login) + get("/login/oidc", OpenIdController, :login) + get("/login/oidc/callback", OpenIdController, :callback) + + get("/api/untappd/callback", UntappdController, :callback, as: :untappd_callback) + + get("/-", IrcController, :index) + get("/-/txt", IrcController, :txt) + get("/-/txt/:name", IrcController, :txt) + + get("/-/gpt", GptController, :index) + get("/-/gpt/p/:id", GptController, :task) + get("/-/gpt/r/:id", GptController, :result) + + get("/-/alcoolog", AlcoologController, :index) + get("/-/alcoolog/~/:account_name", AlcoologController, :index) - put "/transactions/:txn_id", TransactionController, :push + get("/:network", NetworkController, :index) - get "/users/:user_id", UserController, :query - get "/rooms/*room_alias", RoomController, :query + get("/:network/~:nick/alcoolog", AlcoologController, :nick) + get("/:network/~:nick/alcoolog/log.json", AlcoologController, :nick_log_json) + get("/:network/~:nick/alcoolog/gls.json", AlcoologController, :nick_gls_json) + get("/:network/~:nick/alcoolog/volumes.json", AlcoologController, :nick_volumes_json) + get("/:network/~:nick/alcoolog/history.json", AlcoologController, :nick_history_json) + get("/:network/~:nick/alcoolog/stats.json", AlcoologController, :nick_stats_json) - get "/thirdparty/protocol/:protocol", ThirdPartyController, :query_protocol - get "/thirdparty/user/:protocol", ThirdPartyController, :query_users - get "/thirdparty/location/:protocol", ThirdPartyController, :query_locations - get "/thirdparty/location", ThirdPartyController, :query_location_by_alias - get "/thirdparty/user", ThirdPartyController, :query_user_by_id + get("/:network/:chan/alcoolog", AlcoologController, :index) + get("/:network/:chan/alcoolog/gls.json", AlcoologController, :index_gls_json) + + put("/api/alcoolog/minisync/:user_id/meta/:key", AlcoologController, :minisync_put_meta) + + get("/:network/:chan", IrcController, :index) + live("/:network/:chan/live", ChatLive) + get("/:network/:chan/txt", IrcController, :txt) + get("/:network/:chan/txt/:name", IrcController, :txt) + get("/:network/:channel/preums", IrcController, :preums) + get("/:network/:chan/alcoolog/t/:token", AlcoologController, :token) end + scope "/_matrix/:appservice", MatrixAppServiceWeb.V1, as: :matrix do + pipe_through(:matrix_app_service) + + put("/transactions/:txn_id", TransactionController, :push) + + get("/users/:user_id", UserController, :query) + get("/rooms/*room_alias", RoomController, :query) + get("/thirdparty/protocol/:protocol", ThirdPartyController, :query_protocol) + get("/thirdparty/user/:protocol", ThirdPartyController, :query_users) + get("/thirdparty/location/:protocol", ThirdPartyController, :query_locations) + get("/thirdparty/location", ThirdPartyController, :query_location_by_alias) + get("/thirdparty/user", ThirdPartyController, :query_user_by_id) + end end diff --git a/lib/web/templates/layout/app.html.eex b/lib/web/templates/layout/app.html.eex index b3199e2..99dbe9e 100644 --- a/lib/web/templates/layout/app.html.eex +++ b/lib/web/templates/layout/app.html.eex @@ -121,7 +121,7 @@ <%= @inner_content %> </div> <div mt-4 text-grey text-small"> - v<%= Nola.version() %> <a href="<%= Nola.source_url() %>">source</a> + v<%= Nola.version() %> — <a href="<%= Nola.source_url() %>">source</a> </div> <!-- /End replace --> </div> diff --git a/lib/web/views/alcoolog_view.ex b/lib/web/views/alcoolog_view.ex index ad52472..3a86038 100644 --- a/lib/web/views/alcoolog_view.ex +++ b/lib/web/views/alcoolog_view.ex @@ -1,6 +1,4 @@ defmodule NolaWeb.AlcoologView do use NolaWeb, :view require Integer - end - diff --git a/lib/web/views/error_helpers.ex b/lib/web/views/error_helpers.ex index 25214bd..156b801 100644 --- a/lib/web/views/error_helpers.ex +++ b/lib/web/views/error_helpers.ex @@ -9,8 +9,8 @@ defmodule NolaWeb.ErrorHelpers do Generates tag for inlined form input errors. """ def error_tag(form, field) do - Enum.map(Keyword.get_values(form.errors, field), fn (error) -> - content_tag :span, translate_error(error), class: "help-block" + Enum.map(Keyword.get_values(form.errors, field), fn error -> + content_tag(:span, translate_error(error), class: "help-block") end) end diff --git a/lib/web/views/error_view.ex b/lib/web/views/error_view.ex index 5cad939..5acd333 100644 --- a/lib/web/views/error_view.ex +++ b/lib/web/views/error_view.ex @@ -12,6 +12,6 @@ defmodule NolaWeb.ErrorView do # In case no render clause matches or no # template is found, let's render it as 500 def template_not_found(_template, assigns) do - render "500.html", assigns + render("500.html", assigns) end end diff --git a/lib/web/views/layout_view.ex b/lib/web/views/layout_view.ex index 663eccf..747740f 100644 --- a/lib/web/views/layout_view.ex +++ b/lib/web/views/layout_view.ex @@ -2,17 +2,27 @@ defmodule NolaWeb.LayoutView do use NolaWeb, :view def liquid_markdown(conn, text) do - context_path = cond do - conn.assigns[:chan] -> "/#{conn.assigns[:network]}/#{NolaWeb.format_chan(conn.assigns[:chan])}" - conn.assigns[:network] -> "/#{conn.assigns[:network]}/-" - true -> "/-" - end + context_path = + cond do + conn.assigns[:chan] -> + "/#{conn.assigns[:network]}/#{NolaWeb.format_chan(conn.assigns[:chan])}" + + conn.assigns[:network] -> + "/#{conn.assigns[:network]}/-" + + true -> + "/-" + end {:ok, ast} = Liquex.parse(text) - context = Liquex.Context.new(%{ - "context_path" => context_path - }) + + context = + Liquex.Context.new(%{ + "context_path" => context_path + }) + {content, _} = Liquex.render(ast, context) + content |> to_string() |> Earmark.as_html!() @@ -20,21 +30,28 @@ defmodule NolaWeb.LayoutView do end def page_title(conn) do - target = cond do - conn.assigns[:chan] -> - "#{conn.assigns.chan} @ #{conn.assigns.network}" - conn.assigns[:network] -> conn.assigns.network - true -> Nola.name() - end - - breadcrumb_title = Enum.map(Map.get(conn.assigns, :breadcrumbs)||[], fn({title, _href}) -> title end) - - title = [conn.assigns[:title], breadcrumb_title, target] - |> List.flatten() - |> Enum.uniq() - |> Enum.filter(fn(x) -> x end) - |> Enum.intersperse(" / ") - |> Enum.join() + target = + cond do + conn.assigns[:chan] -> + "#{conn.assigns.chan} @ #{conn.assigns.network}" + + conn.assigns[:network] -> + conn.assigns.network + + true -> + Nola.name() + end + + breadcrumb_title = + Enum.map(Map.get(conn.assigns, :breadcrumbs) || [], fn {title, _href} -> title end) + + title = + [conn.assigns[:title], breadcrumb_title, target] + |> List.flatten() + |> Enum.uniq() + |> Enum.filter(fn x -> x end) + |> Enum.intersperse(" / ") + |> Enum.join() content_tag(:title, title) end @@ -42,14 +59,16 @@ defmodule NolaWeb.LayoutView do def format_time(date, with_relative \\ true) do alias Timex.Format.DateTime.Formatters alias Timex.Timezone - date = if is_integer(date) do - date - |> DateTime.from_unix!(:millisecond) - |> DateTime.shift_zone!("Europe/Paris", Tzdata.TimeZoneDatabase) - else - date - |> DateTime.shift_zone!("Europe/Paris", Tzdata.TimeZoneDatabase) - end + + date = + if is_integer(date) do + date + |> DateTime.from_unix!(:millisecond) + |> DateTime.shift_zone!("Europe/Paris", Tzdata.TimeZoneDatabase) + else + date + |> DateTime.shift_zone!("Europe/Paris", Tzdata.TimeZoneDatabase) + end now = DateTime.now!("Europe/Paris", Tzdata.TimeZoneDatabase) @@ -57,25 +76,39 @@ defmodule NolaWeb.LayoutView do date_week = Timex.iso_week(date) {y, w} = now_week - now_last_week = {y, w-1} - now_last_roll = 7-Timex.days_to_beginning_of_week(now) + now_last_week = {y, w - 1} + now_last_roll = 7 - Timex.days_to_beginning_of_week(now) date_date = DateTime.to_date(date) now_date = DateTime.to_date(date) - format = cond do - date.year != now.year -> "{D}/{M}/{YYYY} {h24}:{m}" - date_date == now_date -> "{h24}:{m}" - (now_week == date_week) || (date_week == now_last_week && (Date.day_of_week(date) >= now_last_roll)) -> "{WDfull} {h24}:{m}" - (now.year == date.year && now.month == date.month) -> "{WDfull} {D} {h24}:{m}" - true -> "{WDfull} {D} {M} {h24}:{m}" - end + format = + cond do + date.year != now.year -> + "{D}/{M}/{YYYY} {h24}:{m}" - {:ok, relative} = Formatters.Relative.relative_to(date, Timex.now("Europe/Paris"), "{relative}", "fr") - {:ok, full} = Formatters.Default.lformat(date, "{WDfull} {D} {YYYY} {h24}:{m}", "fr") #"{h24}:{m} {WDfull} {D}", "fr") - {:ok, detail} = Formatters.Default.lformat(date, format, "fr") #"{h24}:{m} {WDfull} {D}", "fr") + date_date == now_date -> + "{h24}:{m}" - content_tag(:time, if(with_relative, do: relative, else: detail), [title: full]) - end + now_week == date_week || + (date_week == now_last_week && Date.day_of_week(date) >= now_last_roll) -> + "{WDfull} {h24}:{m}" + + now.year == date.year && now.month == date.month -> + "{WDfull} {D} {h24}:{m}" + true -> + "{WDfull} {D} {M} {h24}:{m}" + end + + {:ok, relative} = + Formatters.Relative.relative_to(date, Timex.now("Europe/Paris"), "{relative}", "fr") + + # "{h24}:{m} {WDfull} {D}", "fr") + {:ok, full} = Formatters.Default.lformat(date, "{WDfull} {D} {YYYY} {h24}:{m}", "fr") + # "{h24}:{m} {WDfull} {D}", "fr") + {:ok, detail} = Formatters.Default.lformat(date, format, "fr") + + content_tag(:time, if(with_relative, do: relative, else: detail), title: full) + end end diff --git a/lib/web/views/network_view.ex b/lib/web/views/network_view.ex index 7a24db1..58b0e55 100644 --- a/lib/web/views/network_view.ex +++ b/lib/web/views/network_view.ex @@ -1,4 +1,3 @@ defmodule NolaWeb.NetworkView do use NolaWeb, :view - end diff --git a/lib/web/views/open_id_view.ex b/lib/web/views/open_id_view.ex index bd8089b..b847202 100644 --- a/lib/web/views/open_id_view.ex +++ b/lib/web/views/open_id_view.ex @@ -1,4 +1,3 @@ defmodule NolaWeb.OpenIdView do use NolaWeb, :view - end @@ -6,9 +6,9 @@ defmodule Nola.Mixfile do app: :nola, version: version("0.2.7"), elixir: "~> 1.4", - elixirc_paths: elixirc_paths(Mix.env), - compilers: [:phoenix, :gettext] ++ Mix.compilers, - start_permanent: Mix.env == :prod, + elixirc_paths: elixirc_paths(Mix.env()), + compilers: [:phoenix, :gettext] ++ Mix.compilers(), + start_permanent: Mix.env() == :prod, deps: deps() ] end @@ -21,7 +21,7 @@ defmodule Nola.Mixfile do end defp elixirc_paths(:test), do: ["lib", "test/support"] - defp elixirc_paths(_), do: ["lib"] + defp elixirc_paths(_), do: ["lib"] defp aliases do [ @@ -41,22 +41,22 @@ defmodule Nola.Mixfile do {:telemetry_metrics, "~> 0.6"}, {:telemetry_poller, "~> 0.5"}, {:plug_cowboy, "~> 2.0"}, - {:cowlib, "~> 2.9.1", override: true}, + {:cowlib, "~> 2.15.0", override: true}, {:plug, "~> 1.7"}, {:gettext, "~> 0.11"}, {:httpoison, "~> 1.8", override: true}, {:jason, "~> 1.0"}, {:poison, "~> 4.0", override: true}, - {:floki, "~> 0.19.3"}, - {:ecto, "~> 3.4"}, + {:floki, "~> 0.38.0"}, + {:fast_html, "~> 2.0"}, + {:ecto, "~> 3.13"}, {:exirc, git: "https://git.random.sh/ircbot/exirc.git", branch: "fix-who-nick"}, {:distillery, "~> 2.0"}, {:earmark, "~> 1.2"}, {:oauther, "~> 1.1"}, {:extwitter, "~> 0.14.0"}, {:entropy_string, "~> 1.0.0"}, - {:abacus, "~> 0.3.3"}, - {:ex_chain, github: "eljojo/ex_chain"}, + {:abacus, "~> 2.1"}, {:timex, "~> 3.6"}, {:muontrap, "~> 0.5.1"}, {:tzdata, "~> 1.0"}, @@ -70,14 +70,18 @@ defmodule Nola.Mixfile do {:html_entities, "0.4.0", override: true}, {:file_size, "~> 3.0"}, {:ex2ms, "~> 1.0"}, - {:polyjuice_client, git: "https://git.random.sh/ircbot/polyjuice_client.git", branch: "master", override: true}, - {:matrix_app_service, git: "https://git.random.sh/ircbot/matrix_app_service.ex.git", branch: "master"}, - {:sentry, "~> 8.0.5"}, + {:polyjuice_client, + git: "https://git.random.sh/ircbot/polyjuice_client.git", branch: "master", override: true}, + {:matrix_app_service, + git: "https://git.random.sh/ircbot/matrix_app_service.ex.git", branch: "master"}, {:logger_json, "~> 4.3"}, {:oauth2, "~> 2.0"}, - {:powerdnsex, git: "https://git.random.sh/ircbot/powerdnsex.git", branch: "master"}, - {:pfx, "~> 0.7.0"}, - {:flake_id, "~> 0.1.0"} + # {:powerdnsex, git: "https://git.random.sh/ircbot/powerdnsex.git", branch: "master"}, + # {:pfx, "~> 0.7.0"}, + {:flake_id, "~> 0.1.0"}, + {:gun, "~> 1.3"}, + {:idna, "~> 6.0"}, + {:castore, "~> 0.1"} ] end @@ -85,22 +89,25 @@ defmodule Nola.Mixfile do {describe, 0} = System.cmd("git", ~w(describe --dirty --broken --all --tags --long)) [_, rest] = String.split(describe, "/", parts: 2) - info = rest - |> String.trim() - |> String.replace("/", "-") - env = cond do - Mix.env() == :prod -> "" - true -> "." <> to_string(Mix.env()) - end + info = + rest + |> String.trim() + |> String.replace("/", "-") - build_timestamp = DateTime.utc_now() - |> DateTime.to_unix() - |> to_string() + env = + cond do + Mix.env() == :prod -> "" + true -> "." <> to_string(Mix.env()) + end + + build_timestamp = + DateTime.utc_now() + |> DateTime.to_unix() + |> to_string() build_date_tag = ".build" <> build_timestamp v <> "+" <> info <> env <> build_date_tag end - end @@ -1,90 +1,98 @@ %{ - "abacus": {:hex, :abacus, "0.3.3", "f2f11e23073f5e16af36ac425cd9fa9a338695e2f8014684239fa14a9171d5f6", [:mix], [], "hexpm", "a41110183de16eda239f2187e7bb0c91c50658a8ae8254b85352287eb7034d88"}, + "abacus": {:hex, :abacus, "2.1.0", "b6db5c989ba3d9dd8c36d1cb269e2f0058f34768d47c67eb8ce06697ecb36dd4", [:mix], [], "hexpm", "255de08b02884e8383f1eed8aa31df884ce0fb5eb394db81ff888089f2a1bbff"}, "artificery": {:hex, :artificery, "0.4.3", "0bc4260f988dcb9dda4b23f9fc3c6c8b99a6220a331534fdf5bf2fd0d4333b02", [:mix], [], "hexpm", "12e95333a30e20884e937abdbefa3e7f5e05609c2ba8cf37b33f000b9ffc0504"}, "backoff": {:git, "https://github.com/ferd/backoff", "4b8c02d038de1055481b0193665944e11fec337e", [branch: "master"]}, "base62": {:hex, :base62, "1.2.2", "85c6627eb609317b70f555294045895ffaaeb1758666ab9ef9ca38865b11e629", [:mix], [{:custom_base, "~> 0.2.1", [hex: :custom_base, repo: "hexpm", optional: false]}], "hexpm", "d41336bda8eaa5be197f1e4592400513ee60518e5b9f4dcf38f4b4dae6f377bb"}, - "castore": {:hex, :castore, "0.1.11", "c0665858e0e1c3e8c27178e73dffea699a5b28eb72239a3b2642d208e8594914", [:mix], [], "hexpm", "91b009ba61973b532b84f7c09ce441cba7aa15cb8b006cf06c6f4bba18220081"}, - "certifi": {:hex, :certifi, "2.6.1", "dbab8e5e155a0763eea978c913ca280a6b544bfa115633fa20249c3d396d9493", [:rebar3], [], "hexpm", "524c97b4991b3849dd5c17a631223896272c6b0af446778ba4675a1dff53bb7e"}, + "castore": {:hex, :castore, "0.1.22", "4127549e411bedd012ca3a308dede574f43819fe9394254ca55ab4895abfa1a2", [:mix], [], "hexpm", "c17576df47eb5aa1ee40cc4134316a99f5cad3e215d5c77b8dd3cfef12a22cac"}, + "certifi": {:hex, :certifi, "2.15.0", "0e6e882fcdaaa0a5a9f2b3db55b1394dba07e8d6d9bcad08318fb604c6839712", [:rebar3], [], "hexpm", "b147ed22ce71d72eafdad94f055165c1c182f61a2ff49df28bcc71d1d5b94a60"}, "combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"}, "connection": {:hex, :connection, "1.1.0", "ff2a49c4b75b6fb3e674bfc5536451607270aac754ffd1bdfe175abe4a6d7a68", [:mix], [], "hexpm", "722c1eb0a418fbe91ba7bd59a47e28008a189d47e37e0e7bb85585a016b2869c"}, - "cowboy": {:hex, :cowboy, "2.9.0", "865dd8b6607e14cf03282e10e934023a1bd8be6f6bacf921a7e2a96d800cd452", [:make, :rebar3], [{:cowlib, "2.11.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "2c729f934b4e1aa149aff882f57c6372c15399a20d54f65c8d67bef583021bde"}, + "cowboy": {:hex, :cowboy, "2.13.0", "09d770dd5f6a22cc60c071f432cd7cb87776164527f205c5a6b0f24ff6b38990", [:make, :rebar3], [{:cowlib, ">= 2.14.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, ">= 1.8.0 and < 3.0.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "e724d3a70995025d654c1992c7b11dbfea95205c047d86ff9bf1cda92ddc5614"}, "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, - "cowlib": {:hex, :cowlib, "2.9.1", "61a6c7c50cf07fdd24b2f45b89500bb93b6686579b069a89f88cb211e1125c78", [:rebar3], [], "hexpm", "e4175dc240a70d996156160891e1c62238ede1729e45740bdd38064dad476170"}, + "cowlib": {:hex, :cowlib, "2.15.0", "3c97a318a933962d1c12b96ab7c1d728267d2c523c25a5b57b0f93392b6e9e25", [:make, :rebar3], [], "hexpm", "4f00c879a64b4fe7c8fcb42a4281925e9ffdb928820b03c3ad325a617e857532"}, "custom_base": {:hex, :custom_base, "0.2.1", "4a832a42ea0552299d81652aa0b1f775d462175293e99dfbe4d7dbaab785a706", [:mix], [], "hexpm", "8df019facc5ec9603e94f7270f1ac73ddf339f56ade76a721eaa57c1493ba463"}, - "date_time_parser": {:hex, :date_time_parser, "1.1.1", "cd7a04eb8f413a63cfb16892575d08a23651de1118c95278c13f84c105247901", [:mix], [{:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:timex, ">= 3.2.1", [hex: :timex, repo: "hexpm", optional: false]}], "hexpm", "2ede6de7994c1589bcf118954999ed6ff5de97415b33827ea5b30804c7e512ef"}, - "db_connection": {:hex, :db_connection, "2.4.0", "d04b1b73795dae60cead94189f1b8a51cc9e1f911c234cc23074017c43c031e5", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ad416c21ad9f61b3103d254a71b63696ecadb6a917b36f563921e0de00d7d7c8"}, - "decimal": {:hex, :decimal, "2.0.0", "a78296e617b0f5dd4c6caf57c714431347912ffb1d0842e998e9792b5642d697", [:mix], [], "hexpm", "34666e9c55dea81013e77d9d87370fe6cb6291d1ef32f46a1600230b1d44f577"}, + "date_time_parser": {:hex, :date_time_parser, "1.2.0", "3d5a816b91967f51e0f94dcb16a34b2cb780f22cd48931779e81d72f7d3eadb1", [:mix], [{:kday, "~> 1.0", [hex: :kday, repo: "hexpm", optional: false]}], "hexpm", "0cf09ada9f42c0b3bfba02dc0ea2e4b4d2f543d9d2bf99b831a29e6b4a4160e5"}, + "db_connection": {:hex, :db_connection, "2.8.0", "64fd82cfa6d8e25ec6660cea73e92a4cbc6a18b31343910427b702838c4b33b2", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "008399dae5eee1bf5caa6e86d204dcb44242c82b1ed5e22c881f2c34da201b15"}, + "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, "distillery": {:hex, :distillery, "2.1.1", "f9332afc2eec8a1a2b86f22429e068ef35f84a93ea1718265e740d90dd367814", [:mix], [{:artificery, "~> 0.2", [hex: :artificery, repo: "hexpm", optional: false]}], "hexpm", "bbc7008b0161a6f130d8d903b5b3232351fccc9c31a991f8fcbf2a12ace22995"}, - "earmark": {:hex, :earmark, "1.4.15", "2c7f924bf495ec1f65bd144b355d0949a05a254d0ec561740308a54946a67888", [:mix], [{:earmark_parser, ">= 1.4.13", [hex: :earmark_parser, repo: "hexpm", optional: false]}], "hexpm", "3b1209b85bc9f3586f370f7c363f6533788fb4e51db23aa79565875e7f9999ee"}, + "earmark": {:hex, :earmark, "1.4.48", "5f41e579d85ef812351211842b6e005f6e0cef111216dea7d4b9d58af4608434", [:mix], [], "hexpm", "a461a0ddfdc5432381c876af1c86c411fd78a25790c75023c7a4c035fdc858f9"}, "earmark_parser": {:hex, :earmark_parser, "1.4.15", "b29e8e729f4aa4a00436580dcc2c9c5c51890613457c193cc8525c388ccb2f06", [:mix], [], "hexpm", "044523d6438ea19c1b8ec877ec221b008661d3c27e3b848f4c879f500421ca5c"}, - "ecto": {:hex, :ecto, "3.7.1", "a20598862351b29f80f285b21ec5297da1181c0442687f9b8329f0445d228892", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d36e5b39fc479e654cffd4dbe1865d9716e4a9b6311faff799b6f90ab81b8638"}, - "ecto_sql": {:hex, :ecto_sql, "3.7.0", "2fcaad4ab0c8d76a5afbef078162806adbe709c04160aca58400d5cbbe8eeac6", [:mix], [{:db_connection, "~> 2.2", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.4.0 or ~> 0.5.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a26135dfa1d99bf87a928c464cfa25bba6535a4fe761eefa56077a4febc60f70"}, - "elixir_make": {:hex, :elixir_make, "0.6.2", "7dffacd77dec4c37b39af867cedaabb0b59f6a871f89722c25b28fcd4bd70530", [:mix], [], "hexpm", "03e49eadda22526a7e5279d53321d1cced6552f344ba4e03e619063de75348d9"}, + "ecto": {:hex, :ecto, "3.13.2", "7d0c0863f3fc8d71d17fc3ad3b9424beae13f02712ad84191a826c7169484f01", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "669d9291370513ff56e7b7e7081b7af3283d02e046cf3d403053c557894a0b3e"}, + "ecto_sql": {:hex, :ecto_sql, "3.13.2", "a07d2461d84107b3d037097c822ffdd36ed69d1cf7c0f70e12a3d1decf04e2e1", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "539274ab0ecf1a0078a6a72ef3465629e4d6018a3028095dc90f60a19c371717"}, + "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"}, "entropy_string": {:hex, :entropy_string, "1.0.7", "61a5a989e78fd2798e35a17a98a17f81fb504e8d4ba620bcd4f19063eb782943", [:mix], [], "hexpm", "c497fc9cf6bae2075c4c985e66b4306baa4cb19f142d97e0aa1d7a993ae3bb47"}, - "ex2ms": {:hex, :ex2ms, "1.6.1", "66d472eb14da43087c156e0396bac3cc7176b4f24590a251db53f84e9a0f5f72", [:mix], [], "hexpm", "a7192899d84af03823a8ec2f306fa858cbcce2c2e7fd0f1c49e05168fb9c740e"}, - "ex_aws": {:hex, :ex_aws, "2.2.4", "b6b9a73468205c67851f6c195429b435741ec3d5f45be4cfdaa8f54a62491f15", [:mix], [{:configparser_ex, "~> 4.0", [hex: :configparser_ex, repo: "hexpm", optional: true]}, {:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:jsx, "~> 3.0", [hex: :jsx, repo: "hexpm", optional: true]}, {:sweet_xml, "~> 0.6", [hex: :sweet_xml, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.3", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "cc9af9199d869c0532819f87678a547c7dd7c57088d932b43dcbdbc315886601"}, - "ex_aws_s3": {:hex, :ex_aws_s3, "2.3.0", "5dfe50116bad048240bae7cd9418bfe23296542ff72a01b9138113a1cd31451c", [:mix], [{:ex_aws, "~> 2.0", [hex: :ex_aws, repo: "hexpm", optional: false]}, {:sweet_xml, ">= 0.0.0", [hex: :sweet_xml, repo: "hexpm", optional: true]}], "hexpm", "0b13b11478825d62d2f6e57ae763695331be06f2216468f31bb304316758b096"}, + "ex2ms": {:hex, :ex2ms, "1.7.0", "45b9f523d0b777667ded60070d82d871a37e294f0b6c5b8eca86771f00f82ee1", [:mix], [], "hexpm", "2589eee51f81f1b1caa6d08c990b1ad409215fe6f64c73f73c67d36ed10be827"}, + "ex_aws": {:hex, :ex_aws, "2.5.10", "d3f8ca8959dad6533a2a934dfdf380df1b1bef425feeb215a47a5176dee8736c", [:mix], [{:configparser_ex, "~> 5.0", [hex: :configparser_ex, repo: "hexpm", optional: true]}, {:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:jsx, "~> 2.8 or ~> 3.0", [hex: :jsx, repo: "hexpm", optional: true]}, {:mime, "~> 1.2 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "88fcd9cc1b2e0fcea65106bdaa8340ac56c6e29bf72f46cf7ef174027532d3da"}, + "ex_aws_s3": {:hex, :ex_aws_s3, "2.5.7", "e571424d2f345299753382f3a01b005c422b1a460a8bc3ed47659b3d3ef91e9e", [:mix], [{:ex_aws, "~> 2.0", [hex: :ex_aws, repo: "hexpm", optional: false]}, {:sweet_xml, ">= 0.0.0", [hex: :sweet_xml, repo: "hexpm", optional: true]}], "hexpm", "858e51241e50181e29aa2bc128fef548873a3a9cd580471f57eda5b64dec937f"}, "ex_chain": {:git, "https://github.com/eljojo/ex_chain.git", "09d88a10613b6acc33340c9fa1b3540493e431b8", []}, + "ex_json_schema": {:hex, :ex_json_schema, "0.9.2", "c9a42e04e70cd70eb11a8903a22e8ec344df16edef4cb8e6ec84ed0caffc9f0f", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "4854329cb352b6c01c4c4b8dbfb3be14dc5bea19ea13e0eafade4ff22ba55224"}, "exirc": {:git, "https://git.random.sh/ircbot/exirc.git", "ae1de0025dc0184697c0a440eb3cbbf505b154b6", [branch: "fix-who-nick"]}, + "expo": {:hex, :expo, "1.1.0", "f7b9ed7fb5745ebe1eeedf3d6f29226c5dd52897ac67c0f8af62a07e661e5c75", [:mix], [], "hexpm", "fbadf93f4700fb44c331362177bdca9eeb8097e8b0ef525c9cc501cb9917c960"}, "extwitter": {:hex, :extwitter, "0.14.0", "5e15c5ea5e6a09baaf03fd5da6de1431eeeca0ef05a9c0f6cc62872e5b8816ed", [:mix], [{:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:oauther, "~> 1.3", [hex: :oauther, repo: "hexpm", optional: false]}], "hexpm", "5e9bbb491531317062df42ab56ccb8368addb00931b0785c8a5e1d652813b0a8"}, + "fast_html": {:hex, :fast_html, "2.3.0", "08c1d8ead840dd3060ba02c761bed9f37f456a1ddfe30bcdcfee8f651cec06a6", [:make, :mix], [{:elixir_make, "~> 0.4", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 0.2.0", [hex: :nimble_pool, repo: "hexpm", optional: false]}], "hexpm", "f18e3c7668f82d3ae0b15f48d48feeb257e28aa5ab1b0dbf781c7312e5da029d"}, "file_size": {:hex, :file_size, "3.0.1", "ad447a69442a82fc701765a73992d7b1110136fa0d4a9d3190ea685d60034dcd", [:mix], [{:decimal, ">= 1.0.0 and < 3.0.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:number, "~> 1.0", [hex: :number, repo: "hexpm", optional: false]}], "hexpm", "64dd665bc37920480c249785788265f5d42e98830d757c6a477b3246703b8e20"}, - "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, + "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"}, "flake_id": {:hex, :flake_id, "0.1.0", "7716b086d2e405d09b647121a166498a0d93d1a623bead243e1f74216079ccb3", [:mix], [{:base62, "~> 1.2", [hex: :base62, repo: "hexpm", optional: false]}, {:ecto, ">= 2.0.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm", "31fc8090fde1acd267c07c36ea7365b8604055f897d3a53dd967658c691bd827"}, - "floki": {:hex, :floki, "0.19.3", "652d1447767f783bd6cae1d882fd2145f25db28c6841ab87659225b468cff101", [:mix], [{:html_entities, "~> 0.4.0", [hex: :html_entities, repo: "hexpm", optional: false]}, {:mochiweb, "~> 2.15", [hex: :mochiweb, repo: "hexpm", optional: false]}], "hexpm", "1c8da482a0848c55a1d22af49ce6547790077adac2a04cf265e1f26583781adb"}, + "floki": {:hex, :floki, "0.38.0", "62b642386fa3f2f90713f6e231da0fa3256e41ef1089f83b6ceac7a3fd3abf33", [:mix], [], "hexpm", "a5943ee91e93fb2d635b612caf5508e36d37548e84928463ef9dd986f0d1abd9"}, "gen_magic": {:git, "https://github.com/hrefhref/gen_magic", "48a12cca10305c8d357fe16b10fd7ead9b64a56a", [branch: "develop"]}, - "gettext": {:hex, :gettext, "0.18.2", "7df3ea191bb56c0309c00a783334b288d08a879f53a7014341284635850a6e55", [:mix], [], "hexpm", "f9f537b13d4fdd30f3039d33cb80144c3aa1f8d9698e47d7bcbcc8df93b1f5c5"}, + "gettext": {:hex, :gettext, "0.26.2", "5978aa7b21fada6deabf1f6341ddba50bc69c999e812211903b169799208f2a8", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "aa978504bcf76511efdc22d580ba08e2279caab1066b76bb9aa81c4a1e0a32a5"}, "gun": {:hex, :gun, "1.3.3", "cf8b51beb36c22b9c8df1921e3f2bc4d2b1f68b49ad4fbc64e91875aa14e16b4", [:rebar3], [{:cowlib, "~> 2.7.0", [hex: :cowlib, repo: "hexpm", optional: false]}], "hexpm", "3106ce167f9c9723f849e4fb54ea4a4d814e3996ae243a1c828b256e749041e0"}, - "hackney": {:hex, :hackney, "1.17.4", "99da4674592504d3fb0cfef0db84c3ba02b4508bae2dff8c0108baa0d6e0977c", [:rebar3], [{:certifi, "~>2.6.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~>6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~>1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~>1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "de16ff4996556c8548d512f4dbe22dd58a587bf3332e7fd362430a7ef3986b16"}, + "hackney": {:hex, :hackney, "1.24.1", "f5205a125bba6ed4587f9db3cc7c729d11316fa8f215d3e57ed1c067a9703fa9", [:rebar3], [{:certifi, "~> 2.15.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.4", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "f4a7392a0b53d8bbc3eb855bdcc919cd677358e65b2afd3840b5b3690c4c8a39"}, + "html5ever": {:hex, :html5ever, "0.16.1", "3dccc3349e0c3e5f5542bcc09253e6246d174391aca692bdecccd446a1c62132", [:mix], [{:rustler, ">= 0.0.0", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.6.0 or ~> 0.7.0", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "6eb06b7796eb100bc815dffd3f500de376a426a088a8405402305cdd8e7cc08a"}, "html_entities": {:hex, :html_entities, "0.4.0", "f2fee876858cf6aaa9db608820a3209e45a087c5177332799592142b50e89a6b", [:mix], [], "hexpm", "3e3d7156a272950373ce5a4018b1490bea26676f8d6a7d409f6fac8568b8cb9a"}, - "html_sanitize_ex": {:hex, :html_sanitize_ex, "1.3.0", "f005ad692b717691203f940c686208aa3d8ffd9dd4bb3699240096a51fa9564e", [:mix], [{:mochiweb, "~> 2.15", [hex: :mochiweb, repo: "hexpm", optional: false]}], "hexpm", "abfb393ad888d57700f4d0f119c2643c8a9d98856f9b8a92001be7efad1419d6"}, - "httpoison": {:hex, :httpoison, "1.8.0", "6b85dea15820b7804ef607ff78406ab449dd78bed923a49c7160e1886e987a3d", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "28089eaa98cf90c66265b6b5ad87c59a3729bea2e74e9d08f9b51eb9729b3c3a"}, - "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, - "jason": {:hex, :jason, "1.4.0", "e855647bc964a44e2f67df589ccf49105ae039d4179db7f6271dfd3843dc27e6", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "79a3791085b2a0f743ca04cec0f7be26443738779d09302e01318f97bdb82121"}, - "liquex": {:hex, :liquex, "0.6.1", "2e07fc177dfb2ecafe326f11bd641373f3f6b62704a0231832d8634e162e852a", [:mix], [{:date_time_parser, "~> 1.1", [hex: :date_time_parser, repo: "hexpm", optional: false]}, {:html_entities, "~> 0.5.1", [hex: :html_entities, repo: "hexpm", optional: false]}, {:html_sanitize_ex, "~> 1.3.0", [hex: :html_sanitize_ex, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.1", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:timex, "~> 3.6", [hex: :timex, repo: "hexpm", optional: false]}], "hexpm", "2ec6c68fce04e10ca1fd3874d146991cf1b44adc0c8451615873263944353772"}, + "html_sanitize_ex": {:hex, :html_sanitize_ex, "1.4.3", "67b3d9fa8691b727317e0cc96b9b3093be00ee45419ffb221cdeee88e75d1360", [:mix], [{:mochiweb, "~> 2.15 or ~> 3.1", [hex: :mochiweb, repo: "hexpm", optional: false]}], "hexpm", "87748d3c4afe949c7c6eb7150c958c2bcba43fc5b2a02686af30e636b74bccb7"}, + "httpoison": {:hex, :httpoison, "1.8.2", "9eb9c63ae289296a544842ef816a85d881d4a31f518a0fec089aaa744beae290", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "2bb350d26972e30c96e2ca74a1aaf8293d61d0742ff17f01e0279fef11599921"}, + "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, + "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, + "kday": {:hex, :kday, "1.1.0", "64efac85279a12283eaaf3ad6f13001ca2dff943eda8c53288179775a8c057a0", [:mix], [{:ex_doc, "~> 0.21", [hex: :ex_doc, repo: "hexpm", optional: true]}], "hexpm", "69703055d63b8d5b260479266c78b0b3e66f7aecdd2022906cd9bf09892a266d"}, + "liquex": {:hex, :liquex, "0.13.1", "49f90d0b85fb2908f2558f35cd49d78497fe77a895eb55b360889940e1d7afb9", [:mix], [{:date_time_parser, "~> 1.2", [hex: :date_time_parser, repo: "hexpm", optional: false]}, {:html_entities, "~> 0.5.2", [hex: :html_entities, repo: "hexpm", optional: false]}, {:html_sanitize_ex, "~> 1.4.3", [hex: :html_sanitize_ex, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "fbea5b9db264c1758a69bfafdcc8aaebcd56e168365bb9575392cd55d800108f"}, "logger_json": {:hex, :logger_json, "4.3.0", "41aaaab2c2e1c071bfddbcc5a3f567884fdf312d222c7f1a7e3de6ab667774f7", [:mix], [{:ecto, "~> 2.1 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "001bbc34d7c451cfeed298c8384cb3aab10b364db2eb095c466c7a1a28bee6e0"}, "matrix_app_service": {:git, "https://git.random.sh/ircbot/matrix_app_service.ex.git", "5a4efc102f97abad6b60fab7bf761acef6acc78d", [branch: "master"]}, "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, - "mime": {:hex, :mime, "1.6.0", "dabde576a497cef4bbdd60aceee8160e02a6c89250d6c0b29e56c0dfb00db3d2", [:mix], [], "hexpm", "31a1a8613f8321143dde1dafc36006a17d28d02bdfecb9e95a880fa7aabd19a7"}, - "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, - "mochiweb": {:hex, :mochiweb, "2.22.0", "f104d6747c01a330c38613561977e565b788b9170055c5241ac9dd6e4617cba5", [:rebar3], [], "hexpm", "cbbd1fd315d283c576d1c8a13e0738f6dafb63dc840611249608697502a07655"}, + "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, + "mimerl": {:hex, :mimerl, "1.4.0", "3882a5ca67fbbe7117ba8947f27643557adec38fa2307490c4c4207624cb213b", [:rebar3], [], "hexpm", "13af15f9f68c65884ecca3a3891d50a7b57d82152792f3e19d88650aa126b144"}, + "mochiweb": {:hex, :mochiweb, "3.2.2", "bb435384b3b9fd1f92f2f3fe652ea644432877a3e8a81ed6459ce951e0482ad3", [:rebar3], [], "hexpm", "4114e51f1b44c270b3242d91294fe174ce1ed989100e8b65a1fab58e0cba41d5"}, "muontrap": {:hex, :muontrap, "0.5.1", "98fe96d0e616ee518860803a37a29eb23ffc2ca900047cb1bb7fd37521010093", [:make, :mix], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3c11b7f151b202148912c73cbdd633b76fa68fabc26cc441c9d6d140e22290dc"}, "mutex": {:hex, :mutex, "1.1.3", "d7e19f96fe19d6d97583bf12ca1ec182bbf14619b7568592cc461135de1c3b81", [:mix], [], "hexpm", "2b83b92784add2611c23dd527073b5e8dfe3c9c6c94c5bf9e3081b5c41c3ff3e"}, "nimble_csv": {:hex, :nimble_csv, "0.7.0", "52f23ce46eee304d063d1716e19e45ea544bd751536bc53e5d41cb7fc0ca9405", [:mix], [], "hexpm", "e7051e7a95b5c4f26512af5805c320ee9185e752d949f048bf318fedef86cccc"}, - "nimble_parsec": {:hex, :nimble_parsec, "1.1.0", "3a6fca1550363552e54c216debb6a9e95bd8d32348938e13de5eda962c0d7f89", [:mix], [], "hexpm", "08eb32d66b706e913ff748f11694b17981c0b04a33ef470e33e11b3d3ac8f54b"}, - "nimble_pool": {:hex, :nimble_pool, "0.2.4", "1db8e9f8a53d967d595e0b32a17030cdb6c0dc4a451b8ac787bf601d3f7704c3", [:mix], [], "hexpm", "367e8071e137b787764e6a9992ccb57b276dc2282535f767a07d881951ebeac6"}, + "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, + "nimble_pool": {:hex, :nimble_pool, "0.2.6", "91f2f4c357da4c4a0a548286c84a3a28004f68f05609b4534526871a22053cde", [:mix], [], "hexpm", "1c715055095d3f2705c4e236c18b618420a35490da94149ff8b580a2144f653f"}, "nimble_strftime": {:hex, :nimble_strftime, "0.1.1", "b988184d1bd945bc139b2c27dd00a6c0774ec94f6b0b580083abd62d5d07818b", [:mix], [], "hexpm", "89e599c9b8b4d1203b7bb5c79eb51ef7c6a28fbc6228230b312f8b796310d755"}, - "number": {:hex, :number, "1.0.3", "932c8a2d478a181c624138958ca88a78070332191b8061717270d939778c9857", [:mix], [{:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "dd397bbc096b2ca965a6a430126cc9cf7b9ef7421130def69bcf572232ca0f18"}, - "oauth2": {:hex, :oauth2, "2.0.0", "338382079fe16c514420fa218b0903f8ad2d4bfc0ad0c9f988867dfa246731b0", [:mix], [{:hackney, "~> 1.13", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "881b8364ac7385f9fddc7949379cbe3f7081da37233a1aa7aab844670a91e7e7"}, + "number": {:hex, :number, "1.0.5", "d92136f9b9382aeb50145782f116112078b3465b7be58df1f85952b8bb399b0f", [:mix], [{:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "c0733a0a90773a66582b9e92a3f01290987f395c972cb7d685f51dd927cd5169"}, + "oauth2": {:hex, :oauth2, "2.1.0", "beb657f393814a3a7a8a15bd5e5776ecae341fd344df425342a3b6f1904c2989", [:mix], [{:tesla, "~> 1.5", [hex: :tesla, repo: "hexpm", optional: false]}], "hexpm", "8ac07f85b3307dd1acfeb0ec852f64161b22f57d0ce0c15e616a1dfc8ebe2b41"}, "oauther": {:hex, :oauther, "1.3.0", "82b399607f0ca9d01c640438b34d74ebd9e4acd716508f868e864537ecdb1f76", [:mix], [], "hexpm", "78eb888ea875c72ca27b0864a6f550bc6ee84f2eeca37b093d3d833fbcaec04e"}, - "parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"}, + "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, "pfx": {:hex, :pfx, "0.7.0", "551ead4c303d6e4943d315bba349ee2a7cecf05a5311d8a8e6a2661cc9e64951", [:mix], [], "hexpm", "4497f1625c0b71d5749bebca0acf564ae60e5ea374645088c7c57079165379ae"}, - "phoenix": {:hex, :phoenix, "1.6.0-rc.0", "87dc1bb400588019a878ecf32c2d229c7d7f31a520c574860a059934663ffa70", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 1.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.2", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2a0d344d2a2f654a9300b2b09dbf9c3821762e1364e26fce12d76fcd498b92c0"}, - "phoenix_ecto": {:hex, :phoenix_ecto, "4.4.0", "0672ed4e4808b3fbed494dded89958e22fb882de47a97634c0b13e7b0b5f7720", [:mix], [{:ecto, "~> 3.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "09864e558ed31ee00bd48fcc1d4fc58ae9678c9e81649075431e69dbabb43cc1"}, - "phoenix_html": {:hex, :phoenix_html, "3.0.2", "0d71bd7dfa5fad2103142206e25e16accd64f41bcbd0002af3f0da17e530968d", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "d6c6e85d9bef8d52a5a66fcccd15529651f379eaccbf10500343a17f6f814f82"}, - "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.5.0", "3282d8646e1bfc1ef1218f508d9fcefd48cf47f9081b7667bd9b281b688a49cf", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.6", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:phoenix_live_view, "~> 0.16.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "609740be43de94ae0abd2c4300ff0356a6e8a9487bf340e69967643a59fa7ec8"}, - "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.3.3", "3a53772a6118d5679bf50fc1670505a290e32a1d195df9e069d8c53ab040c054", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "766796676e5f558dbae5d1bdb066849673e956005e3730dfd5affd7a6da4abac"}, - "phoenix_live_view": {:hex, :phoenix_live_view, "0.16.1", "a17652e936718b6b6b52ef64d4b9860bc30c41b9a491e25f2b49a70604efa436", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.5.9 or ~> 1.6.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "94bbc572471ad151b756b38dd10acbf91e0bcc132ad8b78240baa0dcf77cea74"}, - "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.0.0", "a1ae76717bb168cdeb10ec9d92d1480fec99e3080f011402c0a2d68d47395ffb", [:mix], [], "hexpm", "c52d948c4f261577b9c6fa804be91884b381a7f8f18450c5045975435350f771"}, - "phoenix_view": {:hex, :phoenix_view, "1.0.0", "fea71ecaaed71178b26dd65c401607de5ec22e2e9ef141389c721b3f3d4d8011", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "82be3e2516f5633220246e2e58181282c71640dab7afc04f70ad94253025db0c"}, - "plug": {:hex, :plug, "1.12.1", "645678c800601d8d9f27ad1aebba1fdb9ce5b2623ddb961a074da0b96c35187d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d57e799a777bc20494b784966dc5fbda91eb4a09f571f76545b72a634ce0d30b"}, - "plug_cowboy": {:hex, :plug_cowboy, "2.5.1", "7cc96ff645158a94cf3ec9744464414f02287f832d6847079adfe0b58761cbd0", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "107d0a5865fa92bcb48e631cc0729ae9ccfa0a9f9a1bd8f01acb513abf1c2d64"}, - "plug_crypto": {:hex, :plug_crypto, "1.2.2", "05654514ac717ff3a1843204b424477d9e60c143406aa94daf2274fdd280794d", [:mix], [], "hexpm", "87631c7ad914a5a445f0a3809f99b079113ae4ed4b867348dd9eec288cecb6db"}, + "phoenix": {:hex, :phoenix, "1.6.16", "e5bdd18c7a06da5852a25c7befb72246de4ddc289182285f8685a40b7b5f5451", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 1.0 or ~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.2", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e15989ff34f670a96b95ef6d1d25bad0d9c50df5df40b671d8f4a669e050ac39"}, + "phoenix_ecto": {:hex, :phoenix_ecto, "4.6.5", "c4ef322acd15a574a8b1a08eff0ee0a85e73096b53ce1403b6563709f15e1cea", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "26ec3208eef407f31b748cadd044045c6fd485fbff168e35963d2f9dfff28d4b"}, + "phoenix_html": {:hex, :phoenix_html, "3.3.4", "42a09fc443bbc1da37e372a5c8e6755d046f22b9b11343bf885067357da21cb3", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0249d3abec3714aff3415e7ee3d9786cb325be3151e6c4b3021502c585bf53fb"}, + "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.5.3", "ff153c46aee237dd7244f07e9b98d557fe0d1de7a5916438e634c3be2d13c607", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:phoenix_live_view, "~> 0.16.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "e36e62b1f61c19b645853af78290a5e7900f7cae1e676714ff69f9836e2f2e76"}, + "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.0", "2791fac0e2776b640192308cc90c0dbcf67843ad51387ed4ecae2038263d708d", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "b3a1fa036d7eb2f956774eda7a7638cf5123f8f2175aca6d6420a7f95e598e1c"}, + "phoenix_live_view": {:hex, :phoenix_live_view, "0.16.4", "5692edd0bac247a9a816eee7394e32e7a764959c7d0cf9190662fc8b0cd24c97", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.5.9 or ~> 1.6.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "754ba49aa2e8601afd4f151492c93eb72df69b0b9856bab17711b8397e43bba0"}, + "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"}, + "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, + "phoenix_view": {:hex, :phoenix_view, "2.0.4", "b45c9d9cf15b3a1af5fb555c674b525391b6a1fe975f040fb4d913397b31abf4", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "4e992022ce14f31fe57335db27a28154afcc94e9983266835bb3040243eb620b"}, + "plug": {:hex, :plug, "1.18.0", "d78df36c41f7e798f2edf1f33e1727eae438e9dd5d809a9997c463a108244042", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "819f9e176d51e44dc38132e132fe0accaf6767eab7f0303431e404da8476cfa2"}, + "plug_cowboy": {:hex, :plug_cowboy, "2.7.4", "729c752d17cf364e2b8da5bdb34fb5804f56251e88bb602aff48ae0bd8673d11", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "9b85632bd7012615bae0a5d70084deb1b25d2bcbb32cab82d1e9a1e023168aa3"}, + "plug_crypto": {:hex, :plug_crypto, "1.2.5", "918772575e48e81e455818229bf719d4ab4181fcbf7f85b68a35620f78d89ced", [:mix], [], "hexpm", "26549a1d6345e2172eb1c233866756ae44a9609bd33ee6f99147ab3fd87fd842"}, "poison": {:hex, :poison, "4.0.1", "bcb755a16fac91cad79bfe9fc3585bb07b9331e50cfe3420a24bcc2d735709ae", [:mix], [], "hexpm", "ba8836feea4b394bb718a161fc59a288fe0109b5006d6bdf97b6badfcf6f0f25"}, "polyjuice_client": {:git, "https://git.random.sh/ircbot/polyjuice_client.git", "92c949be2def3cd0280cbc78849b109d34c8fcaa", [branch: "master"]}, "polyjuice_util": {:hex, :polyjuice_util, "0.1.0", "69901959c143245b47829c8302d0605dff6c0e1c3b116730c162982e0f512ee0", [:mix], [], "hexpm", "af5d1f614f52ce1da59a1f5a7c49249a2dbfda279d99d52d1b4e83e84c19a8d5"}, "poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm", "dad79704ce5440f3d5a3681c8590b9dc25d1a561e8f5a9c995281012860901e3"}, "powerdnsex": {:git, "https://git.random.sh/ircbot/powerdnsex.git", "1dad0c28ac0af45f0b5b1171af2a117fc6b341bf", [branch: "master"]}, - "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, - "retry": {:hex, :retry, "0.14.1", "722d1b0cf87096b71213f5801d99fface7ca76adc83fc9dbf3e1daee952aef10", [:mix], [], "hexpm", "b3a609f286f6fe4f6b2c15f32cd4a8a60427d78d05d7b68c2dd9110981111ae0"}, + "ranch": {:hex, :ranch, "2.2.0", "25528f82bc8d7c6152c57666ca99ec716510fe0925cb188172f41ce93117b1b0", [:make, :rebar3], [], "hexpm", "fa0b99a1780c80218a4197a59ea8d3bdae32fbff7e88527d7d8a4787eff4f8e7"}, + "retry": {:hex, :retry, "0.19.0", "aeb326d87f62295d950f41e1255fe6f43280a1b390d36e280b7c9b00601ccbc2", [:mix], [], "hexpm", "85ef376aa60007e7bff565c366310966ec1bd38078765a0e7f20ec8a220d02ca"}, + "rustler_precompiled": {:hex, :rustler_precompiled, "0.7.3", "42cb9449785cd86c87453e39afdd27a0bdfa5c77a4ec5dc5ce45112e06b9f89b", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "cbc4b3777682e5f6f43ed39b0e0b4a42dccde8053aba91b4514e8f5ff9a5ac6d"}, "sentry": {:hex, :sentry, "8.0.5", "5ca922b9238a50c7258b52f47364b2d545beda5e436c7a43965b34577f1ef61f", [:mix], [{:hackney, "~> 1.8", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.6", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, "~> 2.3", [hex: :plug_cowboy, repo: "hexpm", optional: true]}], "hexpm", "4972839fdbf52e886d7b3e694c8adf421f764f2fa79036b88fb4742049bd4b7c"}, - "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, + "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, "ssl_verify_hostname": {:hex, :ssl_verify_hostname, "1.0.6", "45866d958d9ae51cfe8fef0050ab8054d25cba23ace43b88046092aa2c714645", [:make], [], "hexpm", "72b2fc8a8e23d77eed4441137fefa491bbf4a6dc52e9c0045f3f8e92e66243b5"}, "telegram": {:git, "https://github.com/hrefhref/telegram.git", "21c81460a633b656d2de8aa33beff49c3bb87670", [branch: "master"]}, "telemetry": {:hex, :telemetry, "1.0.0", "0f453a102cdf13d506b7c0ab158324c337c41f1cc7548f0bc0e130bbf0ae9452", [:rebar3], [], "hexpm", "73bc09fa59b4a0284efb4624335583c528e07ec9ae76aca96ea0673850aec57a"}, - "telemetry_metrics": {:hex, :telemetry_metrics, "0.6.1", "315d9163a1d4660aedc3fee73f33f1d355dcc76c5c3ab3d59e76e3edf80eef1f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7be9e0871c41732c233be71e4be11b96e56177bf15dde64a8ac9ce72ac9834c6"}, + "telemetry_metrics": {:hex, :telemetry_metrics, "0.6.2", "2caabe9344ec17eafe5403304771c3539f3b6e2f7fb6a6f602558c825d0d0bfb", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9b43db0dc33863930b9ef9d27137e78974756f5f198cae18409970ed6fa5b561"}, "telemetry_poller": {:hex, :telemetry_poller, "0.5.1", "21071cc2e536810bac5628b935521ff3e28f0303e770951158c73eaaa01e962a", [:rebar3], [{:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "4cab72069210bc6e7a080cec9afffad1b33370149ed5d379b81c7c5f0c663fd4"}, - "tesla": {:hex, :tesla, "1.4.3", "f5a494e08fb1abe4fd9c28abb17f3d9b62b8f6fc492860baa91efb1aab61c8a0", [:mix], [{:castore, "~> 0.1", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:finch, "~> 0.3", [hex: :finch, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, "~> 1.3", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.0", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "e0755bb664bf4d664af72931f320c97adbf89da4586670f4864bf259b5750386"}, - "timex": {:hex, :timex, "3.7.6", "502d2347ec550e77fdf419bc12d15bdccd31266bb7d925b30bf478268098282f", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.10", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 1.0", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "a296327f79cb1ec795b896698c56e662ed7210cc9eb31f0ab365eb3a62e2c589"}, - "tzdata": {:hex, :tzdata, "1.1.0", "72f5babaa9390d0f131465c8702fa76da0919e37ba32baa90d93c583301a8359", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "18f453739b48d3dc5bcf0e8906d2dc112bb40baafe2c707596d89f3c8dd14034"}, - "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, + "tesla": {:hex, :tesla, "1.14.3", "b27ba2814cc08b5c4eb5f0245120198542cd023f575c490fa14447ae6763ea8d", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:finch, "~> 0.13", [hex: :finch, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, ">= 1.0.0", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.21", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.2", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:mox, "~> 1.0", [hex: :mox, repo: "hexpm", optional: true]}, {:msgpax, "~> 2.3", [hex: :msgpax, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "4f419aa2ab908cff43a117d3b5de99edad8fd54690211cbbdc7d2941c03a1458"}, + "timex": {:hex, :timex, "3.7.13", "0688ce11950f5b65e154e42b47bf67b15d3bc0e0c3def62199991b8a8079a1e2", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 1.1", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "09588e0522669328e973b8b4fd8741246321b3f0d32735b589f78b136e6d4c54"}, + "tzdata": {:hex, :tzdata, "1.1.3", "b1cef7bb6de1de90d4ddc25d33892b32830f907e7fc2fccd1e7e22778ab7dfbc", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "d4ca85575a064d29d4e94253ee95912edfb165938743dbf002acdf0dcecb0c28"}, + "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"}, + "wasmex": {:hex, :wasmex, "0.8.2", "5e781944b32105b1f606b04e99a1acb39d65ba8660ad5bc4ba233340842f8de5", [:mix], [{:rustler, "~> 0.26.0", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.5.5", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "44a5c3452565d08453433f8d66fa19c46bc2f87ac9125fd24eb535677cf58313"}, } |