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 @@ -1,108 +1,146 @@ defmodule Couch do @moduledoc """ 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 end @doc """ Retrieve a document `doc` from `db`. `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 defp prepare_request(path, headers \\ [], params \\ [], options \\ []) do config = Application.get_env(:nola, :couch) 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 user = Keyword.get(config, :user) pass = Keyword.get(config, :pass) hackney_options = Keyword.get(options, :hackney, []) hackney_options = if user, do: [{:basic_auth, {user, pass}} | hackney_options], else: [] options = [{:hackney, [:insecure | hackney_options]} | options] {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 diff --git a/lib/irc.ex b/lib/irc.ex index 2c7a468..26e3843 100644 --- a/lib/irc.ex +++ b/lib/irc.ex @@ -1,49 +1,53 @@ defmodule Nola.Irc do require Logger def env(), do: Nola.env(:irc, []) def env(key, default \\ nil), do: Keyword.get(env(), key, default) 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 user = Nola.UserTrack.find_by_account(network, account) nick = if(user, do: user.nick, else: account.name) Nola.Irc.Connection.broadcast_message(network, channel, "<#{nick}> #{text}", meta) end end def admin?(%Nola.Message{sender: sender}), do: admin?(sender) def admin?(%{nick: nick, user: user, host: host}) do for {n, u, h} <- Nola.Irc.env(:admins, []) do admin_part_match?(n, nick) && admin_part_match?(u, user) && admin_part_match?(h, host) end - |> Enum.any? + |> Enum.any?() end defp admin_part_match?(:_, _), do: true defp admin_part_match?(a, a), do: true defp admin_part_match?(_, _), do: false def application_childs do import Supervisor.Spec 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 # Start plugins first to let them get on connection events. def after_start() 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 39556fe..e4834fa 100644 --- a/lib/irc/admin_handler.ex +++ b/lib/irc/admin_handler.ex @@ -1,41 +1,46 @@ defmodule Nola.Irc.AdminHandler do @moduledoc """ # admin !op op; requiert admin """ def irc_doc, do: nil def start_link(client) do GenServer.start_link(__MODULE__, [client]) end def init([client]) do - ExIRC.Client.add_handler client, self() + 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 def handle_info({:joined, chan, sender}, client) 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/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/matrix.ex b/lib/matrix.ex index 0ad0836..384f204 100644 --- a/lib/matrix.ex +++ b/lib/matrix.ex @@ -1,169 +1,185 @@ defmodule Nola.Matrix do require Logger alias Polyjuice.Client @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() end def setup() do {:ok, _} = :dets.open_file(dets(:rooms), []) {:ok, _} = :dets.open_file(dets(:room_aliases), []) {:ok, _} = :dets.open_file(dets(:users), []) :ok end 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 def get_or_create_matrix_user(id) do if mxid = lookup_user(id) do mxid else opts = [ type: "m.login.application_service", inhibit_login: true, device_id: "APP_SERVICE", 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}) end end def lookup_user(id) do case :dets.lookup(dets(:users), id) do [{_, matrix_id}] -> matrix_id _ -> nil 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 def lookup_room_alias(room_alias) do case :dets.lookup(dets(:room_aliases), room_alias) do [{_, room_id}] -> {:ok, room_id} _ -> {:error, :no_such_room_alias} end end def lookup_or_create_room(room_alias) do case lookup_room_alias(room_alias) do {:ok, room_id} -> {:ok, room_id} {:error, :no_such_room_alias} -> create_room(room_alias) end 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, %{}}) :dets.insert(dets(:room_aliases), {room_alias, room_id}) {:ok, room_id} else nil -> {:error, :no_such_network_channel} error -> error end 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} [channel] -> {:ok, "random", channel} _ -> {:error, :invalid_localpart} end end @impl MatrixAppService.Adapter.Room def query_alias(room_alias) do case lookup_or_create_room(room_alias) 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,25 +1,23 @@ 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 end defmodule SetConfig do def init(state) do state end def call(conn, _) do config = Application.get_all_env(:matrix_app_service) 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 @@ -1,196 +1,251 @@ defmodule Nola.Matrix.Room do require Logger alias Nola.Matrix alias Polyjuice.Client import Matrix, only: [client: 0, client: 1, user_name: 1, myself?: 1] defmodule Supervisor do use DynamicSupervisor def start_link() do DynamicSupervisor.start_link(__MODULE__, [], name: __MODULE__) end def start_child(room_id) do - spec = %{id: room_id, start: {Nola.Matrix.Room, :start_link, [room_id]}, restart: :transient} + spec = %{ + id: room_id, + start: {Nola.Matrix.Room, :start_link, [room_id]}, + restart: :transient + } + DynamicSupervisor.start_child(__MODULE__, spec) end @impl true def init(_init_arg) do DynamicSupervisor.init( strategy: :one_for_one, max_restarts: 10, max_seconds: 1 ) end end def start(room_id) do __MODULE__.Supervisor.start_child(room_id) end def start_link(room_id) do GenServer.start_link(__MODULE__, [room_id], name: name(room_id)) end def start_and_send_matrix_event(room_id, event) do - pid = if pid = whereis(room_id) do - pid - else - case __MODULE__.start(room_id) do - {:ok, pid} -> pid - {:error, {:already_started, pid}} -> pid - :ignore -> nil + 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 end end def name(room_id) do {:global, {__MODULE__, room_id}} end def init([room_id]) do case Matrix.lookup_room(room_id) do {:ok, state} -> Logger.metadata(matrix_room: room_id) {:ok, _} = Registry.register(Nola.PubSub, "#{state.network}:events", plugin: __MODULE__) + for t <- ["messages", "triggers", "outputs", "events"] do - {:ok, _} = Registry.register(Nola.PubSub, "#{state.network}/#{state.channel}:#{t}", plugin: __MODULE__) + {: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 def handle_info({:irc, :text, message}, state), do: handle_irc(message, state) def handle_info({:irc, :out, message}, state), do: handle_irc(message, state) def handle_info({:irc, :trigger, _, message}, state), do: handle_irc(message, state) def handle_info({:irc, :event, event}, state), do: handle_irc(event, state) + def handle_info({:matrix_event, event}, state) do if myself?(event.user_id) do {:noreply, state} else handle_matrix(event, state) end end def handle_irc(message = %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 def handle_irc(%{type: :join, account_id: account_id}, state) do introduce_irc_account(account_id, state) {:noreply, state} end - def handle_irc(%{type: quit_or_part, account_id: account_id}, state) when quit_or_part in [:quit, :part] do + 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 def get_account(%{user_id: user_id}, %{id: id}) do Nola.Account.find_by_nick("matrix", user_id) end defp introduce_irc_account(account_id, state) do mxid = Matrix.get_or_create_matrix_user(account_id) account = Nola.Account.get(account_id) user = Nola.UserTrack.find_by_account(state.network, account) base_nick = if(user, do: user.nick, else: account.name) + case Client.Profile.put_displayname(client(user_id: mxid), base_nick) do - :ok -> :ok + :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,30 +1,28 @@ defmodule Nola do - @default_brand [ name: "Nola", source_url: "https://phab.random.sh/source/Nola/", owner: "Ashamed owner", owner_email: "contact@my.nola.bot" ] def env(), do: Application.get_env(:nola) def env(key, default \\ nil), do: Application.get_env(:nola, key, default) def brand(), do: env(:brand, @default_brand) def brand(key), do: Keyword.get(brand(), key) def name(), do: brand(:name) def source_url(), do: brand(:source_url) def data_path(suffix) do Path.join(data_path(), suffix) end def data_path do Application.get_env(:nola, :data_path) end 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 @@ -1,263 +1,279 @@ defmodule Nola.Account do alias Nola.UserTrack.User @moduledoc """ Account registry.... Maps a network predicate: * `{net, {:nick, nickname}}` * `{net, {:account, account}}` * `{net, {:mask, user@host}}` to an unique identifier, that can be shared over multiple networks. If a predicate cannot be found for an existing account, a new account will be made in the database. To link two existing accounts from different network onto a different one, a merge operation is provided. """ # FIXME: Ensure uniqueness of name? @derive {Poison.Encoder, except: [:token]} defstruct [:id, :name, :token] @type t :: %__MODULE__{id: id(), name: String.t()} @type id :: String.t() defimpl Inspect, for: __MODULE__ do import Inspect.Algebra def inspect(%{id: id, name: name}, opts) do concat(["#Nola.Account[", id, " ", name, "]"]) end end def file(base) do to_charlist(Nola.data_path() <> "/account_#{base}.dets") end defp from_struct(%__MODULE__{id: id, name: name, token: token}) do {id, name, token} end defp from_tuple({id, name, token}) do %__MODULE__{id: id, name: name, token: token} end def start_link() do - GenServer.start_link(__MODULE__, [], [name: __MODULE__]) + GenServer.start_link(__MODULE__, [], name: __MODULE__) end def init(_) do {:ok, accounts} = :dets.open_file(file("db"), []) {:ok, meta} = :dets.open_file(file("meta"), []) {:ok, predicates} = :dets.open_file(file("predicates"), [{:type, :set}]) {:ok, %{accounts: accounts, meta: meta, predicates: predicates}} end def get(id) do case :dets.lookup(file("db"), id) do [account] -> from_tuple(account) _ -> nil end end def get_by_name(name) do spec = [{{:_, :"$1", :_}, [{:==, :"$1", {:const, name}}], [:"$_"]}] + case :dets.select(file("db"), spec) do [account] -> from_tuple(account) _ -> nil end end def get_meta(%__MODULE__{id: id}, key, default \\ nil) do case :dets.lookup(file("meta"), {id, key}) do - [{_, value}] -> (value || default) + [{_, value}] -> value || default _ -> default end end @spec find_meta_accounts(String.t()) :: [{account :: t(), value :: String.t()}, ...] @doc "Find all accounts that have a meta of `key`." def find_meta_accounts(key) do spec = [{{{:"$1", :"$2"}, :"$3"}, [{:==, :"$2", {:const, key}}], [{{:"$1", :"$3"}}]}] for {id, val} <- :dets.select(file("meta"), spec), do: {get(id), val} end @doc "Find an account given a specific meta `key` and `value`." @spec find_meta_account(String.t(), String.t()) :: t() | nil def find_meta_account(key, value) do - #spec = [{{{:"$1", :"$2"}, :"$3"}, [:andalso, {:==, :"$2", {:const, key}}, {:==, :"$3", {:const, value}}], [:"$1"]}] - spec = [{{{:"$1", :"$2"}, :"$3"}, [{:andalso, {:==, :"$2", {:const, key}}, {:==, {:const, value}, :"$3"}}], [:"$1"]}] + # spec = [{{{:"$1", :"$2"}, :"$3"}, [:andalso, {:==, :"$2", {:const, key}}, {:==, :"$3", {:const, value}}], [:"$1"]}] + spec = [ + {{{:"$1", :"$2"}, :"$3"}, + [{:andalso, {:==, :"$2", {:const, key}}, {:==, {:const, value}, :"$3"}}], [:"$1"]} + ] + case :dets.select(file("meta"), spec) do [id] -> get(id) _ -> nil end end def get_all_meta(%__MODULE__{id: id}) do spec = [{{{:"$1", :"$2"}, :"$3"}, [{:==, :"$1", {:const, id}}], [{{:"$2", :"$3"}}]}] :dets.select(file("meta"), spec) end def put_user_meta(account = %__MODULE__{}, key, value) do - put_meta(account, "u:"<>key, value) + put_meta(account, "u:" <> key, value) end def put_meta(%__MODULE__{id: id}, key, value) do :dets.insert(file("meta"), {{id, key}, value}) end def delete_meta(%__MODULE__{id: id}, key) do :dets.delete(file("meta"), {id, key}) end def all_accounts() do - :dets.traverse(file("db"), fn(obj) -> {:continue, from_tuple(obj)} end) + :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 if old_id != new_id do spec = [{{:"$1", :"$2"}, [{:==, :"$2", {:const, old_id}}], [:"$1"]}] predicates = :dets.select(file("predicates"), spec) for pred <- predicates, do: :ok = :dets.insert(file("predicates"), {pred, new_id}) spec = [{{{:"$1", :"$2"}, :"$3"}, [{:==, :"$1", {:const, old_id}}], [{{:"$2", :"$3"}}]}] metas = :dets.select(file("meta"), spec) - for {k,v} <- metas do + + 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 @doc "Find an account by a logged in user" def find_by_nick(network, nick) do do_lookup(%ExIRC.SenderInfo{nick: nick, network: network}, false) end @doc "Always find an account by nickname, even if offline. Uses predicates and then account name." def find_always_by_nick(network, chan, nick) do - with \ - nil <- find_by_nick(network, nick), + 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 end end end def find(something) do do_lookup(something, false) end def lookup(something, make_default \\ true) do account = do_lookup(something, make_default) + if account && Map.get(something, :nick) do - 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 def handle_info(_, state) do {:noreply, state} end def handle_cast(_, state) do {:noreply, state} end def handle_call(_, _, state) do {:noreply, state} end def terminate(_, state) do for {_, dets} <- state do :dets.sync(dets) :dets.close(dets) end end - defp do_lookup(message = %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 defp do_lookup(sender = %ExIRC.Who{}, make_default) do if user = Nola.UserTrack.find_by_nick(sender) do lookup(user, make_default) else - #FIXME this will never work with continued lookup by other methods as Who isn't compatible - lookup_by_nick(sender, :dets.lookup(file("predicates"), {sender.network,{:nick, sender.nick}}), make_default) + # FIXME this will never work with continued lookup by other methods as Who isn't compatible + lookup_by_nick( + sender, + :dets.lookup(file("predicates"), {sender.network, {:nick, sender.nick}}), + make_default + ) end end defp do_lookup(sender = %ExIRC.SenderInfo{}, make_default) do lookup(Nola.UserTrack.find_by_nick(sender), make_default) end defp do_lookup(user = %User{account: id}, make_default) when is_binary(id) do get(id) end defp do_lookup(user = %User{network: server, nick: nick}, make_default) do - lookup_by_nick(user, :dets.lookup(file("predicates"), {server,{:nick, nick}}), make_default) + lookup_by_nick(user, :dets.lookup(file("predicates"), {server, {:nick, nick}}), make_default) end defp do_lookup(nil, _) do nil end defp lookup_by_nick(_, [{_, id}], _make_default) do get(id) end defp lookup_by_nick(user, _, make_default) do - #authenticate_by_host(user) + # authenticate_by_host(user) if make_default, do: new_account(user), else: nil end def new_account(%{nick: nick, network: server}) do id = EntropyString.large_id() :dets.insert(file("db"), {id, nick, EntropyString.token()}) :dets.insert(file("predicates"), {{server, {:nick, nick}}, id}) get(id) end def new_account(nick) when is_binary(nick) do id = EntropyString.large_id() :dets.insert(file("db"), {id, nick, EntropyString.token()}) get(id) end def update_account_name(account = %__MODULE__{id: id}, name) do account = %__MODULE__{account | name: name} :dets.insert(file("db"), from_struct(account)) get(id) end def get_predicates(%__MODULE__{} = account) do spec = [{{:"$1", :"$2"}, [{:==, :"$2", {:const, account.id}}], [:"$1"]}] :dets.select(file("predicates"), spec) end - end diff --git a/lib/nola/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 @@ -1,59 +1,62 @@ 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 GenServer.call(__MODULE__, {:lookup, id}) end def new_path(account, perks \\ nil) do case new(account, perks) do {:ok, id} -> NolaWeb.Router.Helpers.login_path(NolaWeb.Endpoint, :token, id) + error -> error end end def new_url(account, perks \\ nil) do case new(account, perks) do {:ok, id} -> NolaWeb.Router.Helpers.login_url(NolaWeb.Endpoint, :token, id) + error -> error end end def new(account, perks \\ nil) do GenServer.call(__MODULE__, {:new, account, perks}) 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 -> IO.inspect(x) {:reply, {:error, :invalid_token}, state} end end 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 @@ -1,117 +1,133 @@ defmodule Nola.Icecast do use GenServer require Logger @hackney_pool :default @httpoison_opts [hackney: [pool: @hackney_pool]] @fuse __MODULE__ def start_link, do: GenServer.start_link(__MODULE__, [], []) def init(_) do GenServer.cast(self(), :poll) {:ok, nil} end def handle_cast(:poll, state) do state = poll(state) {:noreply, state} end def handle_info(:poll, state) do state = poll(state) {:noreply, state} 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 end # 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 # defp stats_json_url do base_url() <> "/status-json.xsl" end 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 @@ -1,17 +1,15 @@ defmodule Nola.IcecastAgent do use Agent def start_link() do Agent.start_link(fn -> nil end, name: __MODULE__) 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 @@ -1,129 +1,138 @@ defmodule Nola.Membership do @moduledoc """ Memberships (users in channels) """ # Key: {account, net, channel} # 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 dets = :dets.open_file(dets(), []) {:ok, dets} end def of_account(%Nola.Account{id: id}) do spec = [{{{:"$1", :"$2", :"$3"}, :_}, [{:==, :"$1", {:const, id}}], [{{:"$2", :"$3"}}]}] :dets.select(dets(), spec) 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) end 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) end 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 def members_or_friends(account, _network, nil) do friends(account) end def members_or_friends(_, network, channel) do members(network, channel) end def expanded_members_or_friends(account, network, channel) do expand(network, members_or_friends(account, network, channel)) end def expanded_members(network, channel) do expand(network, members(network, channel)) 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 def friends(account = %Nola.Account{id: id}) do for({net, chan} <- of_account(account), do: members(net, chan)) |> List.flatten() |> Enum.uniq() end def handle_info(_, dets) do {:noreply, dets} end def handle_cast(_, dets) do {:noreply, dets} end def handle_call(_, _, dets) do {:noreply, dets} end def terminate(_, dets) do :dets.sync(dets) :dets.close(dets) end defp expand(network, list) do for id <- list do if account = Nola.Account.get(id) do user = Nola.UserTrack.find_by_account(network, account) nick = if(user, do: user.nick, else: account.name) {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 @@ -1,23 +1,22 @@ defmodule Nola.Message do @moduledoc """ Well, a message! """ @derive {Poison.Encoder, except: [:replyfun]} 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 @@ -1,137 +1,151 @@ defmodule Nola.Plugins do require Logger @builtins [ Nola.Plugins.Account, Nola.Plugins.Alcoolog, Nola.Plugins.AlcoologAnnouncer, Nola.Plugins.Base, Nola.Plugins.Boursorama, Nola.Plugins.Buffer, Nola.Plugins.Calc, Nola.Plugins.Coronavirus, Nola.Plugins.Correction, Nola.Plugins.Dice, Nola.Plugins.Finance, Nola.Plugins.Gpt, Nola.Plugins.Image, Nola.Plugins.KickRoulette, Nola.Plugins.LastFm, Nola.Plugins.Link, Nola.PLugins.Logger, Nola.Plugins.Preums, Nola.Plugins.QuatreCentVingt, Nola.Plugins.RadioFrance, Nola.Plugins.Say, Nola.Plugins.Script, Nola.Plugins.Seen, Nola.Plugins.Sms, Nola.Plugins.Tell, Nola.Plugins.Txt, Nola.Plugins.Untappd, Nola.Plugins.UserMention, Nola.Plugins.WolframAlpha, - Nola.Plugins.YouTube, + Nola.Plugins.YouTube ] defmodule Supervisor do use DynamicSupervisor require Logger def start_link() do DynamicSupervisor.start_link(__MODULE__, [], name: __MODULE__) end 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 end @impl true def init(_init_arg) do DynamicSupervisor.init( strategy: :one_for_one, max_restarts: 10, max_seconds: 1 ) end end def dets(), do: to_charlist(Nola.data_path("/plugins.dets")) def setup() do :dets.open_file(dets(), []) 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 Logger.info("starting plugins.") for mod <- enabled(), do: {mod, __MODULE__.Supervisor.start_child(mod)} end def declare(module) do case get(module) do :disabled -> :dets.insert(dets(), {module, true, nil}) _ -> nil end end def declare_all_builtins do for b <- @builtins, do: declare(b) end def start(module, opts \\ []) do __MODULE__.Supervisor.start_child(module) end @doc "Enables a plugin" def enable(name), do: switch(name, true) @doc "Disables a plugin" def disable(name), do: switch(name, false) @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 @spec get(module()) :: {:ok, last_start :: nil | non_neg_integer()} | :disabled def get(name) do case :dets.lookup(dets(), name) do [{name, enabled, last_start}] -> {:ok, enabled, last_start} _ -> :disabled end end 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, _} -> Logger.info("#{__MODULE__}: #{to_string(module)} ignored start: #{to_string(error)}") :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 @@ -1,84 +1,111 @@ defmodule Nola.Subnet do use Agent def start_link(_) do Agent.start_link(&setup/0, name: __MODULE__) end def assignations() do :dets.select(dets(), [{{:"$1", :"$2"}, [is_binary: :"$2"], [{{:"$1", :"$2"}}]}]) end def find_subnet_for(binary) when is_binary(binary) do case :dets.select(dets(), [{{:"$1", :"$2"}, [{:==, :"$2", binary}], [{{:"$1", :"$2"}}]}]) do [{subnet, _}] -> subnet _ -> nil end 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 end def set_reverse(name, ip, value \\ nil) def set_reverse(name, ip, nil) do set_reverse(name, ip, "#{name}.users.goulag.org") end def set_reverse(_, ip, value) do ptr_zone = "3.0.0.2.d.f.0.a.2.ip6.arpa" 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 @doc false def dets() do (Nola.data_path() <> "/subnets.dets") |> String.to_charlist() end @doc false def setup() do {:ok, dets} = :dets.open_file(dets(), []) dets end 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 @@ -1,38 +1,42 @@ 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} end end def new(cred) do GenServer.call(__MODULE__, {:new, cred}) 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 @@ -1,12 +1,11 @@ defmodule Nola.Trigger do @moduledoc "A `Nola.Message` parsed command/trigger." @derive Poison.Encoder defstruct [ :type, :trigger, :args ] - end diff --git a/lib/nola/user_track.ex b/lib/nola/user_track.ex index c1218b0..0b07a91 100644 --- a/lib/nola/user_track.ex +++ b/lib/nola/user_track.ex @@ -1,329 +1,466 @@ defmodule Nola.UserTrack do @moduledoc """ User Track DB & Utilities """ @ets Nola.UserTrack.Storage # {uuid, network, nick, nicks, privilege_map} # Privilege map: # %{"#channel" => [:operator, :voice] defmodule Storage do - def delete(id) do - op(fn(ets) -> :ets.delete(ets, id) end) + 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 def op(fun) do GenServer.call(__MODULE__, {:op, fun}) end def start_link do - GenServer.start_link(__MODULE__, [], [name: __MODULE__]) + GenServer.start_link(__MODULE__, [], name: __MODULE__) end def init([]) do ets = :ets.new(__MODULE__, [:set, :named_table, :protected, {:read_concurrency, true}]) {:ok, ets} end def handle_call({:op, fun}, _from, ets) do - returned = try do - {:ok, fun.(ets)} - rescue - rescued -> {:error, rescued} - catch - rescued -> {:error, rescued} - end + returned = + try do + {:ok, fun.(ets)} + rescue + rescued -> {:error, rescued} + catch + rescued -> {:error, rescued} + end + {:reply, returned, ets} end def terminate(_reason, ets) do :ok end end - defmodule Id, do: use EntropyString + defmodule 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 def find_by_account(network, nil) do nil end def find_by_account(network, %Nola.Account{id: id}) do - #iex(15)> :ets.fun2ms(fn(obj = {_, net, acct, _, _, _, _, _, _}) when net == network and acct == account -> obj end) + # 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 def clear_network(network) do Storage.clear_network(network) end - def merge_account(old_id, new_id) do - #iex(15)> :ets.fun2ms(fn(obj = {_, net, acct, _, _, _, _, _, _}) when net == network and acct == account -> obj end) + # 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 def find_by_nick(%ExIRC.Who{network: network, nick: nick}) do find_by_nick(network, nick) end - def find_by_nick(%ExIRC.SenderInfo{network: network, nick: nick}) do find_by_nick(network, nick) end def find_by_nick(network, nick) do - case :ets.match(@ets, {:"$1", network, :_, String.downcase(nick), :_, :_, :_, :_, :_, :_, :_, :_}) do - [[id] | _] -> lookup(id) + case :ets.match( + @ets, + {:"$1", network, :_, String.downcase(nick), :_, :_, :_, :_, :_, :_, :_, :_} + ) do + [[id] | _] -> + lookup(id) + _ -> nil end end def to_list, do: :ets.tab2list(@ets) def lookup(id) do case :ets.lookup(@ets, id) do [] -> nil [tuple] -> User.from_tuple(tuple) end end def operator?(network, channel, nick) do if user = find_by_nick(network, nick) do privs = Map.get(user.privileges, channel, []) Enum.member?(privs, :admin) || Enum.member?(privs, :operator) else false end end def channel(network, channel) do - Enum.filter(to_list(), fn({_, network, _, _, _, _, _, _, _, channels, _, _}) -> + Enum.filter(to_list(), fn {_, network, _, _, _, _, _, _, _, channels, _, _} -> Map.get(channels, channel) end) end # TODO def connected(network, nick, user, host, account_id, opts \\ %{}) do if account = Nola.Account.get(account_id) do - user = if user = find_by_nick(network, nick) do - user - else - user = %User{id: Nola.UserTrack.Id.large_id, account: account_id, network: network, nick: nick, username: user, host: host, privileges: %{}, options: opts} - Storage.op(fn(ets) -> - :ets.insert(ets, User.to_tuple(user)) - end) - user - end + 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 # %User{nick: nick, privileges: %{channel => privileges}} # end # # 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 :ok end end def renamed(network, old_nick, new_nick) do if user = find_by_nick(network, old_nick) do old_account = Nola.Account.lookup(user) - user = %User{user | nick: new_nick, nicks: [old_nick|user.nicks]} + 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 def change_privileges(network, channel, nick, {add, remove}) do if user = find_by_nick(network, nick) do privs = Map.get(user.privileges, channel) - privs = Enum.reduce(add, privs, fn(priv, acc) -> [priv|acc] end) - privs = Enum.reduce(remove, privs, fn(priv, acc) -> List.delete(acc, priv) end) + 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 # XXX: Reason def parted(channel, %{network: network, nick: nick}) do parted(network, channel, nick) end def parted(network, channel, nick) do if user = find_by_nick(network, nick) do if user.account do Nola.Membership.touch(user.account, network, channel) end privs = Map.delete(user.privileges, channel) lasts = Map.delete(user.last_active, channel) + if Enum.count(privs) > 0 do user = %User{user | privileges: privs} Storage.insert(User.to_tuple(user)) - 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 end def quitted(sender, reason) do if user = find_by_nick(sender.network, sender.nick) do if user.account do for {channel, _} <- user.privileges do Nola.Membership.touch(user.account, sender.network, channel) end - 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 defp userchans(%{privileges: privileges}) do for({chan, _} <- privileges, do: chan) end end diff --git a/lib/open_ai.ex b/lib/open_ai.ex index da54e3a..2b8783f 100644 --- a/lib/open_ai.ex +++ b/lib/open_ai.ex @@ -1,33 +1,40 @@ defmodule OpenAi do require Logger - def post(path, data, options \\ []) do config = Application.get_env(:nola, :openai, []) 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")}] + + 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}") + 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, body: body}} -> - Logger.error("OpenAI: HTTP #{code} #{inspect 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 `` ``** Authenticate and link the current nickname to an account - * **auth** list authentications methods - * **whoami** list currently authenticated users - * **web** get a one-time login link to web - * **enable-telegram** Link a Telegram account - * **enable-sms** Link a SMS number - * **enable-untappd** Link a Untappd account - * **set-name** set account name - * **setusermeta puppet-nick ``** Set puppet IRC nickname - """ - - def irc_doc, do: @moduledoc - def start_link(), do: GenServer.start_link(__MODULE__, [], name: __MODULE__) - def init(_) do - {:ok, _} = Registry.register(Nola.PubSub, "messages:private", []) - {:ok, nil} - end + @moduledoc """ + # Account + + * **account** Get current account id and token + * **auth `` ``** Authenticate and link the current nickname to an account + * **auth** list authentications methods + * **whoami** list currently authenticated users + * **web** get a one-time login link to web + * **enable-telegram** Link a Telegram account + * **enable-sms** Link a SMS number + * **enable-untappd** Link a Untappd account + * **set-name** set account name + * **setusermeta puppet-nick ``** Set puppet IRC nickname + """ + + def irc_doc, do: @moduledoc + def start_link(), do: GenServer.start_link(__MODULE__, [], name: __MODULE__) + + def init(_) do + {:ok, _} = Registry.register(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 : 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 : 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 " 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 @@ -1,1229 +1,1696 @@ defmodule Nola.Plugins.Alcoolog do require Logger @moduledoc """ # [alcoolog]({{context_path}}/alcoolog) * **!santai `` ` [annotation]`**: enregistre un nouveau verre de `montant` d'une boisson à `degrés d'alcool`. * **!santai `` ``**: enregistre un nouveau verre de `cl` de la bière `beer name`, et checkin sur Untappd.com. * **!moar `[cl]` : enregistre un verre équivalent au dernier !santai. * **-santai**: annule la dernière entrée d'alcoolisme. * **.alcoolisme**: état du channel en temps réel. * **.alcoolisme ``**: points par jour, sur X j. * **!alcoolisme `[pseudo]`**: affiche les points d'alcoolisme. * **!alcoolisme `[pseudo]` ``**: affiche les points d'alcoolisme par jour sur X j. * **+alcoolisme `` `` `[facteur de perte en mg/l (10, 15, 20, 25)]`**: Configure votre profil d'alcoolisme. * **.sobre**: affiche quand la sobriété frappera sur le chan. * **!sobre `[pseudo]`**: affiche quand la sobriété frappera pour `[pseudo]`. * **!sobrepour ``**: affiche tu pourras être sobre pour ``, et si oui, combien de volumes d'alcool peuvent encore être consommés. * **!alcoolog**: ([voir]({{context_path}}/alcoolog)) lien pour voir l'état/statistiques et historique de l'alcoolémie du channel. * **!alcool `` ``**: donne le nombre d'unités d'alcool dans `` à `°`. * **!soif**: c'est quand l'apéro ? 1 point = 1 volume d'alcool. Annotation: champ libre! --- ## `!txt`s * status utilisateur: `alcoolog.user_(sober|legal|legalhigh|high|toohigh|sick)(|_rising)` * mauvaises boissons: `alcoolog.drink_(negative|zero|negative)` * santo: `alcoolog.santo` * santai: `alcoolog.santai` * plus gros, moins gros: `alcoolog.(fatter|thinner)` """ def irc_doc, do: @moduledoc def start_link(), do: GenServer.start_link(__MODULE__, [], name: __MODULE__) # tuple dets: {nick, date, volumes, current_alcohol_level, nom, commentaire} # tuple ets: {{nick, date}, volumes, current, nom, commentaire} # tuple meta dets: {nick, map} # %{:weight => float, :sex => true(h),false(f)} @pubsub ~w(account) @pubsub_triggers ~w(santai moar again bis santo santeau alcoolog sobre sobrepour soif alcoolisme alcool) @default_user_meta %{weight: 77.4, sex: true, loss_factor: 15} def data_state() do - dets_filename = (Nola.data_path() <> "/" <> "alcoolisme.dets") |> String.to_charlist - dets_meta_filename = (Nola.data_path() <> "/" <> "alcoolisme_meta.dets") |> String.to_charlist + dets_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) :ets.insert(ets, {{nick, date}, volumes, active, cl, deg, name, comment, Map.new()}) dets object = {nick, naive = %NaiveDateTime{}, volumes, active, cl, deg, name, comment, meta} -> - date = naive - |> DateTime.from_naive!("Etc/UTC") - |> DateTime.to_unix() + date = + naive + |> DateTime.from_naive!("Etc/UTC") + |> DateTime.to_unix() + new = {nick, date, volumes, active, cl, deg, name, comment, Map.new()} :dets.delete_object(dets, object) :dets.insert(dets, new) :ets.insert(ets, {{nick, date}, volumes, active, cl, deg, name, comment, Map.new()}) dets object = {nick, date, volumes, active, cl, deg, name, comment, meta} -> :ets.insert(ets, {{nick, date}, volumes, active, cl, deg, name, comment, meta}) dets _ -> dets end end + :dets.foldl(traverse_fun, dets, dets) :dets.sync(dets) state = %{dets: dets, meta: meta, ets: ets} {:ok, state} end @eau ["santo", "santeau"] - def handle_info({:irc, :trigger, santeau, m = %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 {: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 @moar [ "{{message.sender.nick}}: la même donc ?", "{{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/^(?\d+[.]\d+)cl\s+(?\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) meta = %{} meta = Map.put(meta, "timestamp", now) meta = Map.put(meta, "weight", user_meta.weight) meta = Map.put(meta, "sex", user_meta.sex) - :ok = :dets.insert(state.dets, {m.account.id, now, points, if(old_stats, do: old_stats.active, else: 0), cl, deg, name, comment, meta}) - true = :ets.insert(state.ets, {{m.account.id, now}, points, if(old_stats, do: old_stats.active, else: 0),cl, deg, name, comment, meta}) - #sante = @santai |> Enum.map(fn(s) -> String.trim(String.upcase(s)) end) |> Enum.shuffle() |> Enum.random() + + :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) extra = " " <> present_type(name, comment) <> "" 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) nick = if(user, do: user.nick, else: m.account.name) Nola.Irc.Connection.broadcast_message(net, chan, "#{nick}: #{miss}") end end end 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 [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 get_full_statistics(data_state(), nick) end defp get_full_statistics(state, nick) do case get_statistics_for_nick(state, nick) do - {count, {_, last_at, last_points, last_active, last_cl, last_deg, last_type, last_descr, _meta}} -> + {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, & 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, & 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 !") end 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 def user_over_time(account, count) do user_over_time(data_state(), account, count) end def user_over_time(state, account, count) do - delay = count*((24 * 60)*60) + 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) end 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 :/") else m.replyfun.("alcoolisme de #{nick}, #{duration} derniers jours: #{stats}") end end else if stats = get_full_statistics(state, account.id) do - trend_symbol = if stats.active_drinks > 1 do - Enum.join(for(_ <- 1..stats.active_drinks, do: stats.trend_symbol)) - else - stats.trend_symbol - end - # TODO: Lookup nick for account_id - msg = "#{nick} #{stats.user_status} " - <> (if stats.active > 0 || stats.active15m > 0 || stats.active30m > 0 || stats.active1h > 0, do: ": #{trend_symbol} #{Float.round(stats.active, 4)}g/l ", else: "") - <> (if stats.active30m > 0 || stats.active1h > 0, do: "(15m: #{stats.active15m}, 30m: #{stats.active30m}, 1h: #{stats.active1h}) ", else: "") - <> (if stats.sober_in > 0, do: "— Sobre dans #{stats.sober_in_s} ", else: "") - <> (if stats.since && stats.since_min > 180, do: "— Paitai depuis #{stats.since_s} ", else: "") - <> "— Dernier verre: #{present_type(stats.last_type, stats.last_descr)} [#{Float.round(stats.last_points+0.0, 4)}] " - <> "#{format_duration_from_now(stats.last_at)} " - <> (if stats.daily_volumes > 0, do: "— Aujourd'hui: #{stats.daily_volumes} #{stats.daily_gl}g/l", else: "") + 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 def terminate(_, state) do for dets <- [state.dets, state.meta] do :dets.sync(dets) :dets.close(dets) end end - defp rename_object_owner(table, ets, object = {old_id, date, volume, current, cl, deg, name, comment, meta}, old_id, new_id) do + defp rename_object_owner( + table, + ets, + object = {old_id, date, volume, current, cl, deg, name, comment, meta}, + old_id, + new_id + ) do :dets.delete_object(table, object) :ets.delete(ets, {old_id, date}) :dets.insert(table, {new_id, date, volume, current, cl, deg, name, comment, meta}) :ets.insert(ets, {{new_id, date}, volume, current, cl, deg, name, comment, meta}) end # Account: move from nick to account id def handle_info({:accounts, accounts}, state) do - #for x={:account, _, _, _, _} <- accounts, do: handle_info(x, state) - #{:noreply, state} - mapping = Enum.reduce(accounts, Map.new, fn({:account, _net, _chan, nick, account_id}, acc) -> - Map.put(acc, String.downcase(nick), account_id) - end) + # 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, & acc + (points||0) end) + qvc = + :dets.lookup(state.dets, account_id) + |> Enum.sort_by(fn {_, ts, _, _, _, _, _, _, _} -> ts end, & + acc + (points || 0) + end) + last = List.last(qvc) || nil {count, last} end def present_type(type, descr) when descr in [nil, ""], do: "#{type}" def present_type(type, description), do: "#{type} (#{description})" def format_points(int) when is_integer(int) and int > 0 do "+#{Integer.to_string(int)}" end + def format_points(int) when is_integer(int) and int < 0 do Integer.to_string(int) end + def format_points(int) when is_float(int) and int > 0 do - "+#{Float.to_string(Float.round(int,4))}" + "+#{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 end def put_user_meta(state, account_id, meta) do :dets.insert(state.meta, {{:meta, account_id}, meta}) :ok end def get_user_meta(%{meta: meta}, account_id) do case :dets.lookup(meta, {:meta, account_id}) do [{{:meta, _}, meta}] -> Map.merge(@default_user_meta, meta) + _ -> @default_user_meta end end + # Calcul g/l actuel: # 1. load user meta # 2. foldr ets # for each object # get_current_alcohol # ((object g/l) - 0,15/l/60)* minutes_since_drink # if minutes_since_drink < 10, reduce g/l (?!) # acc + current_alcohol # stop folding when ? # def user_stats(account) do user_stats(data_state(), account.id) end defp user_stats(state = %{ets: ets}, account_id) do meta = get_user_meta(state, account_id) - aday = (10 * 60)*60 + 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"}, :_, :_, :_, :_, :_, :_, :_}, [ {:>, :"$2", {:const, before}}, {:"=:=", {: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"}, :_, :_, :_, :_, :_, :_, :_}, [ {:>, :"$2", {:const, before}}, {:"=:=", {:const, account_id}, :"$1"} ], [:"$_"]} ] - # tuple ets: {{nick, date}, volumes, current, nom, commentaire} - drinks = :ets.select(ets, match) - |> Enum.sort_by(fn({{_, date}, _, _, _, _, _, _, _}) -> date end, & Enum.sort_by(fn {{_, date}, _, _, _, _, _, _, _} -> date end, & - k = if meta.sex, do: 0.7, else: 0.6 - weight = meta.weight - peak = (10*volume)/(k*weight) - date = case date do - ts when is_integer(ts) -> DateTime.from_unix!(ts, :millisecond) - date = %NaiveDateTime{} -> DateTime.from_naive!(date, "Etc/UTC") - date = %DateTime{} -> date - end - last_at = last_at || date - mins_since = round(DateTime.diff(now, date)/60.0) - #IO.puts "Drink: #{inspect({date, volume})} - mins since: #{inspect mins_since} - last drink at #{inspect last_at}" - # Apply loss since `last_at` on `all` - # - all = if last_at do - mins_since_last = round(DateTime.diff(date, last_at)/60.0) - loss = ((meta.loss_factor/100)/60)*(mins_since_last) - #IO.puts "Applying last drink loss: from #{all}, loss of #{inspect loss} (mins since #{inspect mins_since_first})" - cond do - (all-loss) > 0 -> all - loss - true -> 0.0 + {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} end 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}" end end defp format_minute_duration(minutes) do - sober_in_s = if (minutes != 0) do - duration = Timex.Duration.from_minutes(minutes) - Timex.Format.Duration.Formatter.lformat(duration, "fr", :humanized) - else - nil - end + 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 @@ -1,269 +1,319 @@ defmodule Nola.Plugins.AlcoologAnnouncer do require Logger @moduledoc """ Annonce changements d'alcoolog """ @channel "#dmz" @seconds 30 @apero [ "C'EST L'HEURE DE L'APÉRRROOOOOOOO !!", "SAAAAANNNNNNNTTTTTTTTAAAAAAAAIIIIIIIIIIIIIIIIIIIIII", "APÉRO ? APÉRO !", {:timed, ["/!\\ à vos tires bouchons…", "… débouchez …", "… BUVEZ! SANTAIII!"]}, "/!\\ ALERTE APÉRO /!\\", "CED !!! VASE DE ROUGE !", "DIDI UN PETIT RICARD™??!", "ALLEZ GUIGUI UNE PETITE BIERE ?", {:timed, ["/!\\ à vos tires bouchons…", "… débouchez …", "… BUVEZ! SANTAIII!"]}, "APPPPAIIIRRREAAUUUUUUUUUUU" ] def irc_doc, do: nil def start_link(), do: GenServer.start_link(__MODULE__, [], name: __MODULE__) def log(account) do - dets_filename = (Nola.data_path() <> "/" <> "alcoologlog.dets") |> String.to_charlist - {:ok, dets} = :dets.open_file(dets_filename, [{:type,:bag}]) + 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 end def init(_) 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") {:noreply, state} 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) Nola.Irc.Connection.broadcast_message(net, chan, "#{nick}: #{message}") end end end 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 {:noreply, state} end defp now() do DateTime.utc_now() |> Timex.Timezone.convert("Europe/Paris") end defp get_stats() do Enum.into(Nola.Plugins.Alcoolog.get_all_stats(), %{}) end defp timer() do Process.send_after(self(), :stats, :timer.seconds(@seconds)) end - end diff --git a/lib/plugins/base.ex b/lib/plugins/base.ex index 97aaa05..7fb285b 100644 --- a/lib/plugins/base.ex +++ b/lib/plugins/base.ex @@ -1,144 +1,166 @@ defmodule Nola.Plugins.Base do - def irc_doc, do: nil def start_link() do GenServer.start_link(__MODULE__, [], name: __MODULE__) end def init([]) do regopts = [plugin: __MODULE__] {:ok, _} = Registry.register(Nola.PubSub, "trigger:version", regopts) {:ok, _} = Registry.register(Nola.PubSub, "trigger:help", regopts) {:ok, _} = Registry.register(Nola.PubSub, "trigger:liquidrender", regopts) {:ok, _} = Registry.register(Nola.PubSub, "trigger:plugin", regopts) {:ok, _} = Registry.register(Nola.PubSub, "trigger:plugins", regopts) {:ok, nil} end def handle_info({:irc, :trigger, "plugins", msg = %{trigger: %{type: :bang, args: []}}}, _) do - enabled_string = Nola.Plugins.enabled() - |> Enum.map(fn(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 def handle_info({:irc, :trigger, "liquidrender", m = %{trigger: %{args: args}}}, _) do template = Enum.join(args, " ") m.replyfun.(Tmpl.render(template, m)) {:noreply, nil} end def handle_info({:irc, :trigger, "help", m = %{trigger: %{type: :bang}}}, _) do - url = NolaWeb.Router.Helpers.irc_url(NolaWeb.Endpoint, :index, m.network, NolaWeb.format_chan(m.channel)) + url = + NolaWeb.Router.Helpers.irc_url( + NolaWeb.Endpoint, + :index, + m.network, + NolaWeb.format_chan(m.channel) + ) + m.replyfun.("-> #{url}") {:noreply, nil} end def handle_info({:irc, :trigger, "version", message = %{trigger: %{type: :bang}}}, _) do {:ok, vsn} = :application.get_key(:nola, :vsn) ver = List.to_string(vsn) url = NolaWeb.Router.Helpers.irc_url(NolaWeb.Endpoint, :index) - elixir_ver = Application.started_applications() |> List.keyfind(:elixir, 0) |> elem(2) |> to_string() + + elixir_ver = + Application.started_applications() |> List.keyfind(:elixir, 0) |> elem(2) |> to_string() + otp_ver = :erlang.system_info(:system_version) |> to_string() |> String.trim() system = :erlang.system_info(:system_architecture) |> to_string() brand = Nola.brand(:name) owner = "#{Nola.brand(:owner)} <#{Nola.brand(:owner_email)}>" 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/buffer.ex b/lib/plugins/buffer.ex index 42a435e..5f848ef 100644 --- a/lib/plugins/buffer.ex +++ b/lib/plugins/buffer.ex @@ -1,44 +1,50 @@ defmodule Nola.Plugins.Buffer do @table __MODULE__.ETS def irc_doc, do: nil def table(), do: @table def select_buffer(network, channel, limit \\ 50) do import Ex2ms - spec = fun do {{n, c, _}, m} when n == ^network and (c == ^channel or is_nil(c)) -> m end + + spec = + fun do + {{n, c, _}, m} when n == ^network and (c == ^channel or is_nil(c)) -> m + end + :ets.select(@table, spec, limit) end def start_link() do GenServer.start_link(__MODULE__, [], name: __MODULE__) end def init(_) do for e <- ~w(messages triggers events outputs) do {:ok, _} = Registry.register(Nola.PubSub, e, plugin: __MODULE__) end + {:ok, :ets.new(@table, [:named_table, :ordered_set, :protected])} end def handle_info({:irc, :trigger, _, message}, ets), do: handle_message(message, ets) def handle_info({:irc, :text, message}, ets), do: handle_message(message, ets) def handle_info({:irc, :event, event}, ets), do: handle_message(event, ets) defp handle_message(message = %{network: network}, ets) do key = {network, Map.get(message, :channel), ts(message.at)} :ets.insert(ets, {key, message}) {:noreply, ets} end defp ts(nil), do: ts(NaiveDateTime.utc_now()) defp ts(naive = %NaiveDateTime{}) do - ts = naive - |> DateTime.from_naive!("Etc/UTC") - |> DateTime.to_unix() + ts = + 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 @@ -1,37 +1,43 @@ defmodule Nola.Plugins.Calc do @moduledoc """ # calc * **!calc ``**: évalue l'expression mathématique ``. """ def irc_doc, do: @moduledoc def start_link() do GenServer.start_link(__MODULE__, [], name: __MODULE__) end def init(_) do - {:ok, _} = Registry.register(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 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,172 +1,252 @@ defmodule Nola.Plugins.Coronavirus do require Logger NimbleCSV.define(CovidCsv, separator: ",", escape: "\"") + @moduledoc """ # Corona Virus Données de [Johns Hopkins University](https://github.com/CSSEGISandData/COVID-19) et mises à jour a peu près tous les jours. * `!coronavirus [France | Country]`: :-) * `!coronavirus`: top 10 confirmés et non guéris * `!coronavirus confirmés`: top 10 confirmés * `!coronavirus morts`: top 10 morts * `!coronavirus soignés`: top 10 soignés """ def irc_doc, do: @moduledoc def start_link() do GenServer.start_link(__MODULE__, [], name: __MODULE__) end def init(_) do - {:ok, _} = Registry.register(Nola.PubSub, "trigger:coronavirus", [plugin: __MODULE__]) + {:ok, _} = Registry.register(Nola.PubSub, "trigger:coronavirus", plugin: __MODULE__) {:ok, nil, {:continue, :init}} :ignore end def handle_continue(:init, _) do date = Date.add(Date.utc_today(), -2) {data, _} = fetch_data(%{}, date) {data, next} = fetch_data(data) :timer.send_after(next, :update) {:noreply, %{data: data}} end def handle_info(:update, state) do {data, next} = fetch_data(state.data) :timer.send_after(next, :update) {:noreply, %{data: data}} end - def handle_info({:irc, :trigger, "coronavirus", m = %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 # 1. Try to fetch data for today # 2. Fetch yesterday if no results defp fetch_data(current_data, date \\ nil) do now = Date.utc_today() - url = fn(date) -> + + 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 @@ -1,59 +1,67 @@ defmodule Nola.Plugins.Correction do @moduledoc """ # correction * `s/pattern/replace` replace `pattern` by `replace` in the last matching message """ def irc_doc, do: @moduledoc + def start_link() do GenServer.start_link(__MODULE__, [], name: __MODULE__) end def init(_) do - {:ok, _} = Registry.register(Nola.PubSub, "messages", [plugin: __MODULE__]) - {:ok, _} = Registry.register(Nola.PubSub, "triggers", [plugin: __MODULE__]) + {:ok, _} = Registry.register(Nola.PubSub, "messages", plugin: __MODULE__) + {:ok, _} = Registry.register(Nola.PubSub, "triggers", plugin: __MODULE__) {:ok, %{}} end # Trigger fallback def handle_info({:irc, :trigger, _, m = %Nola.Message{}}, state) do {:noreply, correction(m, state)} end def handle_info({:irc, :text, m = %Nola.Message{}}, state) do {:noreply, correction(m, state)} end def correction(m, state) do history = Map.get(state, key(m), []) + if String.starts_with?(m.text, "s/") do case String.split(m.text, "/") do ["s", match, replace | _] -> case Regex.compile(match) do {:ok, reg} -> - repl = Enum.find(history, fn(m) -> Regex.match?(reg, m.text) end) + 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 @@ -1,66 +1,69 @@ defmodule Nola.Plugins.Dice do require Logger @moduledoc """ # dice * **!dice `[1 | lancés]` `[6 | faces]`**: lance une ou plusieurs fois un dé de 6 ou autre faces """ @default_faces 6 @default_rolls 1 @max_rolls 50 def short_irc_doc, do: "!dice (jeter un dé)" defstruct client: nil, dets: nil def irc_doc, do: @moduledoc def start_link() do GenServer.start_link(__MODULE__, [], name: __MODULE__) end def init([]) do - {:ok, _} = Registry.register(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) {:noreply, state} end def handle_info(info, state) do {:noreply, state} end defp roll(state, message, faces, 1) when faces > 0 do - random = :crypto.rand_uniform(1, faces+1) + 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 b083df8..68afc48 100644 --- a/lib/plugins/finance.ex +++ b/lib/plugins/finance.ex @@ -1,199 +1,251 @@ defmodule Nola.Plugins.Finance do require Logger @moduledoc """ # finance Données de [alphavantage.co](https://alphavantage.co). ## forex / monnaies / crypto-monnaies * **`!forex [MONNAIE2]`**: taux de change entre deux monnaies. * **`!forex `**: converti `montant` entre deux monnaies * **`?currency `**: recherche une monnaie Utiliser le symbole des monnaies (EUR, USD, ...). ## bourses * **`!stocks `** * **`?stocks `** cherche un symbole Pour les symboles non-US, ajouter le suffixe (RNO Paris: RNO.PAR). """ @currency_list "http://www.alphavantage.co/physical_currency_list/" @crypto_list "http://www.alphavantage.co/digital_currency_list/" HTTPoison.start() - load_currency = fn(url) -> + + 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 def init([]) do regopts = [plugin: __MODULE__] {:ok, _} = Registry.register(Nola.PubSub, "trigger:forex", regopts) {:ok, _} = Registry.register(Nola.PubSub, "trigger:currency", regopts) {:ok, _} = Registry.register(Nola.PubSub, "trigger:stocks", regopts) {:ok, nil} end - - def handle_info({:irc, :trigger, "stocks", message = %{trigger: %{type: :query, args: args = search}}}, state) do + def handle_info( + {:irc, :trigger, "stocks", message = %{trigger: %{type: :query, args: args = search}}}, + state + ) do search(search, message) {:noreply, state} end 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 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) IO.inspect(data) + case data do %{"Error Message" => error} -> - Logger.error("AlphaVantage API invalid request #{url} - #{inspect 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})" + 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") from_name = Map.get(data, "2. From_Currency Name") to_name = Map.get(data, "4. To_Currency Name") rate = Map.get(data, "5. Exchange Rate") {rate, _} = Float.parse(rate) - value = amount*rate - message.replyfun.("#{amount} #{from} (#{from_name}) -> #{value} #{to} (#{to_name}) (#{rate})") + 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 defp api_key() do Application.get_env(:nola, :alphavantage, []) |> Keyword.get(:api_key, "demo") 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 @@ -1,95 +1,102 @@ defmodule Nola.Plugins.TempRefHelper do @moduledoc """ This module allows to easily implement local temporary simple references for easy access from IRC. For example, your plugin output could be acted on, and instead of giving the burden for the user to write or copy that uuid, you could give them a small alphanumeric reference to use instead. You can configure how many and for how long the references are kept. ## Usage `import Irc.Plugin.TempRef` ```elixir defmodule Irc.MyPlugin do defstruct [:temprefs] def init(_) do # … {:ok, %__MODULE__{temprefs: new_temp_refs()} end end ``` """ defstruct [:refs, :max, :expire, :build_fun, :build_increase_fun, :build_options] defmodule SimpleAlphaNumericBuilder do def build(options) do length = Keyword.get(options, :length, 3) for _ <- 1..length, into: "", do: <> end - + def increase(options) do Keyword.put(options, :length, Keyword.get(options, :length, 3) + 1) end end def new_temp_refs(options \\ []) do %__MODULE__{ refs: Keyword.get(options, :init_refs, []), 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 def janitor_refs(state = %__MODULE__{}) do if length(state.refs) > state.max do %__MODULE__{refs: state.refs |> Enum.reverse() |> tl() |> Enum.reverse()} else state end end 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]}} else {nil, state} end end def lookup_temp_ref(key, state, default \\ nil) do case List.keyfind(state.refs, key, 0) do {_, _, data} -> data _ -> default end end defp new_nonexisting_key(state, i) when i > 50 do nil 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/kick_roulette.ex b/lib/plugins/kick_roulette.ex index 3f81977..2194775 100644 --- a/lib/plugins/kick_roulette.ex +++ b/lib/plugins/kick_roulette.ex @@ -1,32 +1,33 @@ defmodule Nola.Plugins.KickRoulette do @moduledoc """ # kick roulette * **!kick**, tentez votre chance… """ def irc_doc, do: @moduledoc + def start_link() do GenServer.start_link(__MODULE__, [], name: __MODULE__) end def init([]) do - {:ok, _} = Registry.register(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 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 @@ -1,187 +1,250 @@ defmodule Nola.Plugins.LastFm do require Logger @moduledoc """ # last.fm * **!lastfm|np `[nick|username]`** * **.lastfm|np** * **+lastfm, -lastfm `; ?lastfm`** Configurer un nom d'utilisateur last.fm """ @single_trigger ~w(lastfm np) @pubsub_topics ~w(trigger:lastfm trigger:np) defstruct dets: nil def irc_doc, do: @moduledoc def start_link() do GenServer.start_link(__MODULE__, [], name: __MODULE__) end def init([]) do regopts = [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 def handle_info({:irc, :trigger, _, message = %{trigger: %{type: :bang, args: []}}}, state) do irc_now_playing(message.account.id, message, state) {:noreply, state} end - def handle_info({:irc, :trigger, _, message = %{trigger: %{type: :bang, args: [nick_or_user]}}}, state) do + 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 def handle_info(info, state) do {:noreply, state} end def terminate(_reason, state) do if state.dets do :dets.sync(state.dets) :dets.close(state.dets) end + :ok end defp irc_now_playing(nick_or_user, message, state) do nick_or_user = String.strip(nick_or_user) - id_or_user = if account = 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 end defp now_playing(user) do api = Application.get_env(:nola, :lastfm)[:api_key] - url = "http://ws.audioscrobbler.com/2.0/?method=user.getrecenttracks&format=json&limit=1&extended=1" <> "&api_key=" <> api <> "&user="<> user + + 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 defp format_now_playing(%{"recenttracks" => %{"track" => [track | _]}}, et) do format_track(false, track, et) end defp format_now_playing(%{"error" => err, "message" => message}, _) do "last.fm error #{err}: #{message}" end defp format_now_playing(miss) do nil end defp format_track(np, track, extended) do artist = track["artist"]["name"] album = if track["album"]["#text"], do: " (" <> track["album"]["#text"] <> ")", else: "" name = track["name"] <> album action = if np, do: "écoute ", else: "a écouté" love = if track["loved"] != "0", do: "❤️" count = if x = extended["userplaycount"], do: "x#{x} #{love}" - tags = (get_in(extended, ["toptags", "tag"]) || []) - |> Enum.map(fn(tag) -> tag["name"] end) - |> Enum.filter(& &1) - |> Enum.join(", ") + + 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/github.ex b/lib/plugins/link/github.ex index 77fa81f..fcd76a0 100644 --- a/lib/plugins/link/github.ex +++ b/lib/plugins/link/github.ex @@ -1,76 +1,82 @@ defmodule Nola.Plugins.Link.Github do @behaviour Nola.Plugins.Link @impl true def match(uri = %URI{host: "github.com", path: path}, _) do with ["", user, repo] <- String.split(path, "/") do {true, %{user: user, repo: repo, path: "#{user}/#{repo}"}} else _ -> false end end def match(_, _), do: false @impl true def post_match(_, _, _, _), do: false @impl true def expand(_uri, %{user: user, repo: repo}, _opts) do 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"]})") + 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 - " || "" + 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/image.ex b/lib/plugins/link/image.ex index cf3d9b0..2fb6862 100644 --- a/lib/plugins/link/image.ex +++ b/lib/plugins/link/image.ex @@ -1,80 +1,92 @@ defmodule Nola.Plugins.Link.Image do require Logger @behaviour Nola.Plugins.Link @impl true 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 def post_match(_, _, _, _), do: false @impl true 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 index a2972eb..b46c430 100644 --- a/lib/plugins/link/img_debrid_link.ex +++ b/lib/plugins/link/img_debrid_link.ex @@ -1,32 +1,32 @@ defmodule Nola.Plugins.Link.ImgDebridLink do @behaviour Nola.Plugins.Link @impl true def match(uri = %URI{host: "img.debrid-link.fr", path: path}, _opts) 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.fr/api/v1/images/#{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 + with {:ok, %HTTPoison.Response{status_code: 200, body: body}} <- + HTTPoison.get("https://img.debrid-link.fr/api/v1/images/#{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 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 @@ -1,96 +1,113 @@ defmodule Nola.Plugins.Link.Imgur do @behaviour Nola.Plugins.Link @moduledoc """ # Imgur link preview No options. Needs to have a Imgur API key configured: ``` config :nola, :imgur, client_id: "xxxxxxxx", client_secret: "xxxxxxxxxxxxxxxxxxxx" ``` """ @impl true 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 def post_match(_, _, _, _), do: false def expand(_uri, %{album_id: album_id}, opts) do expand_imgur_album(album_id, opts) end def expand(_uri, %{image_id: image_id}, opts) do expand_imgur_image(image_id, opts) end def expand_imgur_image(image_id, opts) 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) data = json["data"] title = String.slice(data["title"] || data["description"], 0, 180) nsfw = if data["nsfw"], do: "(NSFW) - ", else: " " height = Map.get(data, "height") width = Map.get(data, "width") size = Map.get(data, "size") {:ok, "image, #{width}x#{height}, #{size} bytes #{nsfw}#{title}"} + other -> :error end end def expand_imgur_album(album_id, opts) 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) data = json["data"] 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 @@ -1,39 +1,47 @@ defmodule Nola.Plugins.Link.PDF do require Logger @behaviour Nola.Plugins.Link @impl true 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 def post_match(_, _, _, _), do: false @impl true 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/redacted.ex b/lib/plugins/link/redacted.ex index a7cfe74..0c14520 100644 --- a/lib/plugins/link/redacted.ex +++ b/lib/plugins/link/redacted.ex @@ -1,18 +1,20 @@ 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 def match(_, _), do: false @impl true def post_match(_, _, _, _), do: false def expand(_uri, %{torrent: id}, _opts) do end - end diff --git a/lib/plugins/link/reddit.ex b/lib/plugins/link/reddit.ex index 707e284..bd38084 100644 --- a/lib/plugins/link/reddit.ex +++ b/lib/plugins/link/reddit.ex @@ -1,119 +1,152 @@ defmodule Nola.Plugins.Link.Reddit do @behaviour Nola.Plugins.Link @impl true def match(uri = %URI{host: "reddit.com", path: path}, _) 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 end def match(uri = %URI{host: host, path: path}, opts) do if String.ends_with?(host, ".reddit.com") do match(%URI{uri | host: "reddit.com"}, opts) else false end end @impl true def post_match(_, _, _, _), do: false @impl true def expand(_, %{mode: :sub, sub: sub}, _opts) do url = "https://api.reddit.com/r/#{sub}/about" + case HTTPoison.get(url) 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 {: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, #{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 index f5487e3..c30ae5f 100644 --- a/lib/plugins/link/scraper.ex +++ b/lib/plugins/link/scraper.ex @@ -1,45 +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}] + + 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, %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}}} -> + {: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}") + 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 index 566cc9a..ea43070 100644 --- a/lib/plugins/link/store.ex +++ b/lib/plugins/link/store.ex @@ -1,30 +1,29 @@ defmodule Nola.Plugins.Link.Store do require Record import Ex2ms @type url() :: String.t() Record.defrecord(:link, link: nil, at: nil) @type link :: record(:link, link: String.t(), at: nil) Record.defrecord(:link_entry, key: nil, at: nil) @type link_entry :: record(:link_entry, key: {url(), String.t()}, at: nil) def setup do :ets.new(:links, [:set, :public, :named_table, keypos: 2]) end @spec insert_link(url()) :: true def insert_link(url) do :ets.insert(:links, link(link: url, at: NaiveDateTime.utc_now() |> NaiveDateTime.to_unix())) end @spec get_link(url()) :: String.t() | nil def get_link(url) do case :ets.lookup(:links, url) do [link] -> link [] -> nil end 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 @@ -1,158 +1,183 @@ defmodule Nola.Plugins.Link.Twitter do @behaviour Nola.Plugins.Link @moduledoc """ # Twitter Link Preview Configuration: needs an API key and auth tokens: ``` config :extwitter, :oauth, [ consumer_key: "zzzzz", consumer_secret: "xxxxxxx", access_token: "yyyyyy", access_token_secret: "ssshhhhhh" ] ``` options: * `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 def match(_, _), do: false @impl true def post_match(_, _, _, _), do: false def expand(_uri, %{status_id: status_id}, opts) do expand_tweet(ExTwitter.show(status_id, tweet_mode: "extended"), opts) end defp expand_tweet(nil, _opts) do :error end defp link_tweet(tweet_or_screen_id_tuple, opts, force_twitter_com \\ false) defp link_tweet({screen_name, id}, opts, force_twitter_com) do path = "/#{screen_name}/status/#{id}" nitter = Keyword.get(opts, :nitter) host = if !force_twitter_com && nitter, do: nitter, else: "twitter.com" "https://#{host}/#{screen_name}/status/#{id}" end defp link_tweet(tweet, opts, force_twitter_com) do link_tweet({tweet.user.screen_name, tweet.id}, opts, force_twitter_com) end defp expand_tweet(tweet, opts) do head = format_tweet_header(tweet, opts) # 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 prefix = Keyword.get(format_opts, :prefix, nil) details = Keyword.get(format_opts, :details, true) padded_prefix = if prefix, do: "#{prefix} ", else: "" author = <> link = link_tweet(tweet, opts) {:ok, at} = Timex.parse(tweet.created_at, "%a %b %e %H:%M:%S %z %Y", :strftime) {:ok, formatted_time} = Timex.format(at, "{relative}", :relative) nsfw = if tweet.possibly_sensitive, do: <<3, 52, "NSFW", 3>> rts = if tweet.retweet_count && tweet.retweet_count > 0, do: "#{tweet.retweet_count} RT" likes = if tweet.favorite_count && tweet.favorite_count > 0, do: "#{tweet.favorite_count} ❤︎" qrts = if tweet.quote_count && tweet.quote_count > 0, do: "#{tweet.quote_count} QRT" 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>> <> 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 @@ -1,72 +1,91 @@ defmodule Nola.Plugins.Link.YouTube do @behaviour Nola.Plugins.Link @moduledoc """ # YouTube link preview needs an API key: ``` config :nola, :youtube, api_key: "xxxxxxxxxxxxx" ``` options: * `invidious`: Add a link to invidious. """ @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 def match(_, _), do: false @impl true def post_match(_, _, _, _), do: false @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 @@ -1,83 +1,84 @@ defmodule Nola.Plugins.Logger do require Logger @couch_db "bot-logs" def irc_doc(), do: nil def start_link() do GenServer.start_link(__MODULE__, [], name: __MODULE__) end def init([]) do regopts = [plugin: __MODULE__] {:ok, _} = Registry.register(Nola.PubSub, "triggers", regopts) {:ok, _} = Registry.register(Nola.PubSub, "messages", regopts) {:ok, _} = Registry.register(Nola.PubSub, "messages:telegram", regopts) {:ok, _} = Registry.register(Nola.PubSub, "irc:outputs", regopts) {:ok, _} = Registry.register(Nola.PubSub, "messages:private", regopts) {:ok, nil} end def handle_info({:irc, :trigger, _, m}, state) do {:noreply, log(m, state)} end def handle_info({:irc, :text, m}, state) do {:noreply, log(m, state)} end def handle_info({:irc, :out, m}, state) do {:noreply, log(m, state)} 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/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 @@ -1,149 +1,182 @@ defmodule Nola.Plugins.QuatreCentVingt do require Logger @moduledoc """ # 420 * **!420**: recorde un nouveau 420. * **!420*x**: recorde un nouveau 420*x (*2 = 840, ...) (à vous de faire la multiplication). * **!420 pseudo**: stats du pseudo. """ @achievements %{ - 1 => ["[le premier… il faut bien commencer un jour]"], - 10 => ["T'en es seulement à 10 ? ╭∩╮(Ο_Ο)╭∩╮"], - 42 => ["Bravo, et est-ce que autant de pétards t'on aidés à trouver la Réponse ? ٩(- ̮̮̃-̃)۶ [42]"], - 100 => ["°º¤ø,¸¸,ø¤º°`°º¤ø,¸,ø¤°º¤ø,¸¸,ø¤º°`°º¤ø,¸ 100 °º¤ø,¸¸,ø¤º°`°º¤ø,¸,ø¤°º¤ø,¸¸,ø¤º°`°º¤ø,¸"], - 115 => [" ۜ\(סּںסּَ` )/ۜ 115!!"] + 1 => ["[le premier… il faut bien commencer un jour]"], + 10 => ["T'en es seulement à 10 ? ╭∩╮(Ο_Ο)╭∩╮"], + 42 => [ + "Bravo, et est-ce que autant de pétards t'on aidés à trouver la Réponse ? ٩(- ̮̮̃-̃)۶ [42]" + ], + 100 => [ + "°º¤ø,¸¸,ø¤º°`°º¤ø,¸,ø¤°º¤ø,¸¸,ø¤º°`°º¤ø,¸ 100 °º¤ø,¸¸,ø¤º°`°º¤ø,¸,ø¤°º¤ø,¸¸,ø¤º°`°º¤ø,¸" + ], + 115 => [" ۜ\(סּںסּَ` )/ۜ 115!!"] } @emojis [ "\\o/", "~o~", "~~o∞~~", "*\\o/*", "**\\o/**", - "*ô*", + "*ô*" ] @coeffs Range.new(1, 100) def irc_doc, do: @moduledoc def start_link, do: GenServer.start_link(__MODULE__, [], name: __MODULE__) def init(_) do for coeff <- @coeffs do - {:ok, _} = Registry.register(Nola.PubSub, "trigger:#{420*coeff}", [plugin: __MODULE__]) + {: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 def handle_info(_, dets) do {:noreply, dets} end defp rename_object_owner(table, object = {_, at}, account_id) do :dets.delete_object(table, object) :dets.insert(table, {account_id, at}) end - defp format_relative_timestamp(timestamp) do alias Timex.Format.DateTime.Formatters alias Timex.Timezone - date = timestamp - |> DateTime.from_unix! - |> Timezone.convert("Europe/Paris") - {:ok, relative} = Formatters.Relative.relative_to(date, Timex.now("Europe/Paris"), "{relative}", "fr") + 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 @achievements_keys Map.keys(@achievements) defp achievement_text(count) when count in @achievements_keys do Enum.random(Map.get(@achievements, count)) end defp achievement_text(count) do emoji = Enum.random(@emojis) "#{emoji} [#{count}]" end - end diff --git a/lib/plugins/radio_france.ex b/lib/plugins/radio_france.ex index d95c54a..e9adc4e 100644 --- a/lib/plugins/radio_france.ex +++ b/lib/plugins/radio_france.ex @@ -1,133 +1,156 @@ defmodule Nola.Plugins.RadioFrance do require Logger def irc_doc() do """ # radio france Qu'est ce qu'on écoute sur radio france ? * **!radiofrance `[station]`, !rf `[station]`** * **!fip, !inter, !info, !bleu, !culture, !musique, !fip `[sous-station]`, !bleu `[région]`** """ end def start_link() do GenServer.start_link(__MODULE__, [], name: __MODULE__) end @trigger "radiofrance" @shortcuts ~w(fip inter info bleu culture musique) def init(_) do regopts = [plugin: __MODULE__] {:ok, _} = Registry.register(Nola.PubSub, "trigger:radiofrance", regopts) {:ok, _} = Registry.register(Nola.PubSub, "trigger:rf", regopts) + for s <- @shortcuts do {:ok, _} = Registry.register(Nola.PubSub, "trigger:#{s}", regopts) end + {:ok, nil} end - def handle_info({:irc, :trigger, "rf", m = %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 defp args_to_station(args) do args |> Enum.map(&unalias/1) |> Enum.map(&String.downcase/1) |> Enum.join("_") end def handle_info(info, state) do - Logger.debug("unhandled info: #{inspect info}") + Logger.debug("unhandled info: #{inspect(info)}") {:noreply, state} end defp now(station, m) when is_binary(station) do case HTTPoison.get(np_url(station), [], []) do {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> json = Poison.decode!(body) song? = !!get_in(json, ["now", "song"]) station = reformat_station_name(get_in(json, ["now", "stationName"])) now_title = get_in(json, ["now", "firstLine", "title"]) - now_subtitle = get_in(json, ["now", "secondLine", "title"]) + now_subtitle = get_in(json, ["now", "secondLine", "title"]) next_title = get_in(json, ["next", "firstLine", "title"]) next_subtitle = get_in(json, ["next", "secondLine", "title"]) next_song? = !!get_in(json, ["next", "song"]) next_at = get_in(json, ["next", "startTime"]) now = format_title(song?, now_title, now_subtitle) prefix = if song?, do: "🎶", else: "🎤" m.replyfun.("#{prefix} #{station}: #{now}") - + next = format_title(song?, next_title, next_subtitle) + if next do - next_prefix = if next_at do - next_date = DateTime.from_unix!(next_at) - in_seconds = DateTime.diff(next_date, DateTime.utc_now()) - in_minutes = ceil(in_seconds / 60) - if in_minutes >= 5 do - if next_song?, do: "#{in_minutes}m 🔜", else: "dans #{in_minutes} minutes:" + 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 {:error, %HTTPoison.Response{status_code: 404}} -> m.replyfun.("radiofrance: la radio \"#{station}\" n'existe pas") {:error, %HTTPoison.Response{status_code: code}} -> m.replyfun.("radiofrance: erreur http #{code}") _ -> m.replyfun.("radiofrance: ça n'a pas marché, rip") end end defp np_url(station), do: "https://www.radiofrance.fr/api/v2.0/stations/#{station}/live" defp unalias("inter"), do: "franceinter" defp unalias("info"), do: "franceinfo" defp unalias("bleu"), do: "francebleu" defp unalias("culture"), do: "franceculture" defp unalias("musique"), do: "francemusique" defp unalias(station), do: station defp format_title(_, nil, nil) do nil end + defp format_title(true, title, artist) do [artist, title] |> Enum.filter(& &1) |> Enum.join(" - ") end + defp format_title(false, show, section) do [show, section] |> Enum.filter(& &1) |> Enum.join(": ") end defp reformat_station_name(station) do station |> String.replace("france", "france ") |> String.replace("_", " ") end - end diff --git a/lib/plugins/say.ex b/lib/plugins/say.ex index 114ca64..3ccd0a4 100644 --- a/lib/plugins/say.ex +++ b/lib/plugins/say.ex @@ -1,73 +1,81 @@ defmodule Nola.Plugins.Say do - def irc_doc do """ # say Say something... * **!say `` ``** say something on `channel` * **!asay `` ``** same but anonymously You must be a member of the channel. """ end def start_link() do GenServer.start_link(__MODULE__, [], name: __MODULE__) end def init([]) do regopts = [type: __MODULE__] {:ok, _} = Registry.register(Nola.PubSub, "trigger:say", regopts) {:ok, _} = Registry.register(Nola.PubSub, "trigger:asay", regopts) {:ok, _} = Registry.register(Nola.PubSub, "messages:private", regopts) {:ok, nil} end - def handle_info({:irc, :trigger, "say", m = %{trigger: %{type: :bang, args: [target | text]}}}, state) do + 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 def handle_info(_, state) do {:noreply, state} end 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 Nola.Irc.Connection.broadcast_message(net, chan, text) end end end end - end diff --git a/lib/plugins/script.ex b/lib/plugins/script.ex index c8d00a9..0a65627 100644 --- a/lib/plugins/script.ex +++ b/lib/plugins/script.ex @@ -1,42 +1,46 @@ defmodule Nola.Plugins.Script do require Logger @moduledoc """ Allows to run outside scripts. Scripts are expected to be long running and receive/send data as JSON over stdin/stdout. """ @ircdoc """ # script Allows to run an outside script. * **+script `` `[command]`** défini/lance un script * **-script ``** arrête un script * **-script del ``** supprime un script """ def irc_doc, do: @ircdoc def start_link() do GenServer.start_link(__MODULE__, [], name: __MODULE__) end def init([]) do - {:ok, _} = Registry.register(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 @@ -1,59 +1,67 @@ defmodule Nola.Plugins.Seen do @moduledoc """ # seen * **!seen ``** """ def irc_doc, do: @moduledoc + def start_link() do GenServer.start_link(__MODULE__, [], name: __MODULE__) end def init([]) do regopts = [plugin: __MODULE__] {:ok, _} = Registry.register(Nola.PubSub, "triggers", regopts) {:ok, _} = Registry.register(Nola.PubSub, "messages", regopts) dets_filename = (Nola.data_path() <> "/seen.dets") |> String.to_charlist() {:ok, dets} = :dets.open_file(dets_filename, []) {:ok, %{dets: dets}} end - def handle_info({:irc, :trigger, "seen", m = %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} end def handle_info({:irc, :trigger, _, m}, state) do witness(m, state) {:noreply, state} end def handle_info({:irc, :text, m}, state) do witness(m, state) {:noreply, state} end defp witness(%Nola.Message{channel: channel, text: text, sender: %{nick: nick}}, %{dets: dets}) do :dets.insert(dets, {{channel, nick}, DateTime.utc_now(), text}) :ok end defp last_seen(channel, nick, %{dets: dets}) do case :dets.lookup(dets, {channel, nick}) do [{_, date, text}] -> - diff = round(DateTime.diff(DateTime.utc_now(), date)/60) + 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 @@ -1,165 +1,202 @@ defmodule Nola.Plugins.Sms do @moduledoc """ ## sms * **!sms `` ``** envoie un SMS. """ def short_irc_doc, do: false def irc_doc, do: @moduledoc require Logger - def incoming(from, "enable "<>key) do + def incoming(from, "enable " <> key) do key = String.trim(key) account = Nola.Account.find_meta_account("sms-validation-code", String.downcase(key)) + if account do net = Nola.Account.get_meta(account, "sms-validation-target") Nola.Account.put_meta(account, "sms-number", from) Nola.Account.delete_meta(account, "sms-validation-code") Nola.Account.delete_meta(account, "sms-validation-number") Nola.Account.delete_meta(account, "sms-validation-target") Nola.Irc.Connection.broadcast_message(net, account, "SMS Number #{from} added!") send_sms(from, "Yay! Number linked to account #{account.name}") end end def incoming(from, message) do account = Nola.Account.find_meta_account("sms-number", from) + if account do - reply_fun = fn(text) -> + 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, network: "sms", channel: nil, text: message, account: account, sender: %ExIRC.SenderInfo{nick: account.name}, 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 end def my_number() do Keyword.get(Application.get_env(:nola, :sms, []), :number, "+33000000000") end def start_link() do GenServer.start_link(__MODULE__, [], name: __MODULE__) end def path() do account = Keyword.get(Application.get_env(:nola, :sms), :account) "https://eu.api.ovh.com/1.0/sms/#{account}" end def path(rest) do Path.join(path(), rest) end def send_sms(number, text) do url = path("/virtualNumbers/#{my_number()}/jobs") - body = %{ - "message" => text, - "receivers" => [number], - #"senderForResponse" => true, - #"noStopClause" => true, - "charset" => "UTF-8", - "coding" => "8bit" - } |> Poison.encode!() + + 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 def handle_info(msg, state) do {:noreply, state} end defp register_ovh_callback() do url = path() - body = %{ - "callBack" =>NolaWeb.Router.Helpers.sms_url(NolaWeb.Endpoint, :ovh_callback), - "smsResponse" => %{ - "cgiUrl" => NolaWeb.Router.Helpers.sms_url(NolaWeb.Endpoint, :ovh_callback), - "responseType" => "cgi" + + 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 defp sign(method, url, body) do ts = DateTime.utc_now() |> DateTime.to_unix() as = env(:app_secret) ck = env(:consumer_key) sign = Enum.join([as, ck, String.upcase(method), url, body, ts], "+") sign_hex = :crypto.hash(:sha, sign) |> Base.encode16(case: :lower) - headers = [{"X-OVH-Application", env(:app_key)}, {"X-OVH-Timestamp", ts}, - {"X-OVH-Signature", "$1$"<>sign_hex}, {"X-Ovh-Consumer", ck}] + + headers = [ + {"X-OVH-Application", env(:app_key)}, + {"X-OVH-Timestamp", ts}, + {"X-OVH-Signature", "$1$" <> sign_hex}, + {"X-Ovh-Consumer", ck} + ] end def parse_number(num) do {:error, :todo} end defp env() do Application.get_env(:nola, :sms) end defp env(key) do Keyword.get(env(), key) end end diff --git a/lib/plugins/tell.ex b/lib/plugins/tell.ex index b4d05dc..923c2ef 100644 --- a/lib/plugins/tell.ex +++ b/lib/plugins/tell.ex @@ -1,106 +1,120 @@ defmodule Nola.Plugins.Tell do use GenServer @moduledoc """ # Tell * **!tell `` ``**: tell `message` to `nick` when they reconnect. """ def irc_doc, do: @moduledoc + def start_link() do GenServer.start_link(__MODULE__, [], name: __MODULE__) end def dets do (Nola.data_path() <> "/tell.dets") |> String.to_charlist() end def tell(m, target, message) do GenServer.cast(__MODULE__, {:tell, m, target, message}) end def init([]) do regopts = [plugin: __MODULE__] {:ok, _} = Registry.register(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 def handle_cast({:tell, m, target, message}, state) do do_tell(state, m, target, message) {:noreply, state} end - def handle_info({:irc, :trigger, "tell", m = %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 def terminate(_, state) do :dets.close(state.dets) :ok end defp do_tell(state, m, nick_target, message) do target = Nola.Account.find_always_by_nick(m.network, m.channel, nick_target) message = Enum.join(message, " ") - with \ - {:target, %Nola.Account{} = target} <- {:target, target}, + + 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 {:same, _} -> m.replyfun.("are you so stupid that you need a bot to tell yourself things ?") {:target, _} -> m.replyfun.("#{nick_target} unknown") {:absent, _, nick} -> m.replyfun.("#{nick} is here, tell yourself!") {:message, _} -> m.replyfun.("can't tell without a message") end end - end diff --git a/lib/plugins/txt.ex b/lib/plugins/txt.ex index a66a984..b06e5ff 100644 --- a/lib/plugins/txt.ex +++ b/lib/plugins/txt.ex @@ -1,556 +1,658 @@ defmodule Nola.Plugins.Txt do alias Nola.UserTrack require Logger @moduledoc """ # [txt]({{context_path}}/txt) * **.txt**: liste des fichiers et statistiques. Les fichiers avec une `*` sont vérrouillés. [Voir sur le web]({{context_path}}/txt). * **!txt**: lis aléatoirement une ligne dans tous les fichiers. * **!txt ``**: recherche une ligne dans tous les fichiers. * **~txt**: essaie de générer une phrase (markov). * **~txt ``**: essaie de générer une phrase commencant par ``. * **!`FICHIER`**: lis aléatoirement une ligne du fichier `FICHIER`. * **!`FICHIER` ``**: lis la ligne `` du fichier `FICHIER`. * **!`FICHIER` ``**: recherche une ligne contenant `` dans `FICHIER`. * **+txt `**: crée le fichier ``. * **+`FICHIER` ``**: ajoute une ligne `` dans le fichier `FICHIER`. * **-`FICHIER` ``**: supprime la ligne `` du fichier `FICHIER`. * **-txtrw, +txtrw**. op seulement. active/désactive le mode lecture seule. * **+txtlock ``, -txtlock ``**. op seulement. active/désactive le verrouillage d'un fichier. Insérez `\\\\` pour faire un saut de ligne. """ def short_irc_doc, do: "!txt https://sys.115ans.net/irc/txt " def irc_doc, do: @moduledoc def start_link() do GenServer.start_link(__MODULE__, [], name: __MODULE__) end defstruct triggers: %{}, rw: true, locks: nil, markov_handler: nil, markov: nil def random(file) do GenServer.call(__MODULE__, {:random, file}) end def reply_random(message, file) do if line = random(file) do line |> format_line(nil, message) |> message.replyfun.() line end end def init([]) do - dets_locks_filename = (Nola.data_path() <> "/" <> "txtlocks.dets") |> String.to_charlist + 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.Markov.Native + ) + {: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 {:noreply, %__MODULE__{state | triggers: load()}} end # # ADMIN: RW/RO # - def handle_info({:irc, :trigger, "txtrw", msg = %{channel: channel, trigger: %{type: :plus}}}, state = %{rw: false}) do + def handle_info( + {:irc, :trigger, "txtrw", msg = %{channel: channel, trigger: %{type: :plus}}}, + state = %{rw: false} + ) do if channel && UserTrack.operator?(msg.network, channel, msg.sender.nick) do msg.replyfun.("txt: écriture réactivée") {:noreply, %__MODULE__{state | rw: true}} else {:noreply, state} end end - def handle_info({:irc, :trigger, "txtrw", msg = %{channel: channel, trigger: %{type: :minus}}}, state = %{rw: true}) do + def handle_info( + {:irc, :trigger, "txtrw", msg = %{channel: channel, trigger: %{type: :minus}}}, + state = %{rw: true} + ) do if channel && UserTrack.operator?(msg.network, channel, msg.sender.nick) do msg.replyfun.("txt: écriture désactivée") {:noreply, %__MODULE__{state | rw: false}} else {:noreply, state} end end # # ADMIN: LOCKS # - def handle_info({:irc, :trigger, "txtlock", msg = %{trigger: %{type: :plus, args: [trigger]}}}, state) do - with \ - {trigger, _} <- clean_trigger(trigger), - true <- UserTrack.operator?(msg.network, msg.channel, msg.sender.nick) - do + 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 # # FILE LIST # def handle_info({:irc, :trigger, "txt", msg = %{trigger: %{type: :dot}}}, state) do - map = Enum.map(state.triggers, fn({key, data}) -> - ignore? = String.contains?(key, ".") - locked? = case :dets.lookup(state.locks, key) do - [{trigger}] -> "*" - _ -> "" - end + map = + Enum.map(state.triggers, fn {key, data} -> + ignore? = String.contains?(key, ".") + + locked? = + case :dets.lookup(state.locks, key) do + [{trigger}] -> "*" + _ -> "" + end + + unless ignore?, do: "#{key}: #{to_string(Enum.count(data))}#{locked?}" + end) + |> Enum.filter(& &1) + + total = + Enum.reduce(state.triggers, 0, fn {_, data}, acc -> + acc + Enum.count(data) + end) - 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, + m.network, + NolaWeb.format_chan(m.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) + (detail <> total <> ro) |> msg.replyfun.() + {:noreply, state} end # # GLOBAL: RANDOM # def handle_info({:irc, :trigger, "txt", msg = %{trigger: %{type: :bang, args: []}}}, state) do - result = Enum.reduce(state.triggers, [], fn({trigger, data}, acc) -> - Enum.reduce(data, acc, fn({l, _}, acc) -> - [{trigger, l} | acc] + 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 def with_stateful_results(msg, key, initfun) do me = self() scope = {msg.network, msg.channel || msg.sender.nick} key = {__MODULE__, me, scope, key} with_stateful_results(key, initfun) end def with_stateful_results(key, initfun) do - pid = case :global.whereis_name(key) do - :undefined -> - start_stateful_results(key, initfun.()) - pid -> pid - end + pid = + case :global.whereis_name(key) do + :undefined -> + start_stateful_results(key, initfun.()) + + pid -> + pid + end + if pid, do: wait_stateful_results(key, initfun, pid) end def start_stateful_results(key, []) do nil end def start_stateful_results(key, list) do me = self() - {pid, _} = spawn_monitor(fn() -> - Process.monitor(me) - stateful_results(me, list) - end) + + {pid, _} = + spawn_monitor(fn -> + Process.monitor(me) + stateful_results(me, list) + end) + :yes = :global.register_name(key, pid) pid end def wait_stateful_results(key, initfun, pid) do send(pid, :get) + receive do {:stateful_results, line} -> line + {:DOWN, _ref, :process, ^pid, reason} -> with_stateful_results(key, initfun) after 5000 -> nil end end defp stateful_results(owner, []) do send(owner, :empty) :ok end @stateful_results_expire :timer.minutes(30) defp stateful_results(owner, [line | rest] = acc) do receive do :get -> send(owner, {:stateful_results, line}) stateful_results(owner, rest) + {:DOWN, _ref, :process, ^owner, _} -> :ok - after - @stateful_results_expire -> :ok + after + @stateful_results_expire -> :ok end end # # GLOBAL: MARKOV # def handle_info({:irc, :trigger, "txt", msg = %{trigger: %{type: :tilde, args: []}}}, state) do case state.markov_handler.sentence(state.markov) do {:ok, line} -> msg.replyfun.(line) + error -> - Logger.error "Txt Markov error: "<>inspect error + 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 # # 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 _ -> {:noreply, state} end end # # TXT: RANDOM # def handle_info({:irc, :trigger, trigger, m = %{trigger: %{type: :query, args: opts}}}, state) do {trigger, _} = clean_trigger(trigger) + if Map.get(state.triggers, trigger) do - url = if m.channel do - NolaWeb.Router.Helpers.irc_url(NolaWeb.Endpoint, :txt, m.network, NolaWeb.format_chan(m.channel), trigger) - else - NolaWeb.Router.Helpers.irc_url(NolaWeb.Endpoint, :txt, trigger) - end + url = + if m.channel do + NolaWeb.Router.Helpers.irc_url( + NolaWeb.Endpoint, + :txt, + m.network, + NolaWeb.format_chan(m.channel), + trigger + ) + else + NolaWeb.Router.Helpers.irc_url(NolaWeb.Endpoint, :txt, trigger) + end + m.replyfun.("-> #{url}") end + {:noreply, state} end def handle_info({:irc, :trigger, trigger, msg = %{trigger: %{type: :bang, args: opts}}}, state) do {trigger, _} = clean_trigger(trigger) line = get_random(msg, state.triggers, trigger, String.trim(Enum.join(opts, " "))) + if line do msg.replyfun.(format_line(line, nil, msg)) end + {:noreply, state} end # # TXT: ADD # - def handle_info({:irc, :trigger, trigger, msg = %{trigger: %{type: :plus, args: content}}}, state) do - with \ - true <- can_write?(state, msg, trigger), - {:ok, idx} <- add(state.triggers, msg.text) - do + 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 # # TXT: DELETE # def handle_info({:irc, :trigger, trigger, msg = %{trigger: %{type: :minus, args: [id]}}}, state) do - with \ - true <- can_write?(state, msg, trigger), - data <- Map.get(state.triggers, trigger), - {id, ""} <- Integer.parse(id), - {text, _id} <- Enum.find(data, fn({_, idx}) -> id-1 == idx end) - do - data = data |> Enum.into(Map.new) + with true <- can_write?(state, msg, trigger), + data <- Map.get(state.triggers, trigger), + {id, ""} <- Integer.parse(id), + {text, _id} <- Enum.find(data, fn {_, idx} -> id - 1 == idx end) do + data = data |> Enum.into(Map.new()) data = Map.delete(data, text) msg.replyfun.("#{msg.sender.nick}: #{trigger}.txt##{id} supprimée: #{text}") dump(trigger, data) {:noreply, %__MODULE__{state | triggers: load()}} else _ -> {:noreply, state} end end - def handle_info(:reload_markov, state=%__MODULE__{triggers: triggers, markov: markov}) do + def handle_info(:reload_markov, state = %__MODULE__{triggers: triggers, markov: markov}) do state.markov_handler.reload(state.triggers, state.markov) {:noreply, state} end def handle_info(msg, state) do {:noreply, state} end def handle_call({:random, file}, _from, state) do random = get_random(nil, state.triggers, file, []) {:reply, random, state} end def terminate(_reason, state) do if state.locks do :dets.sync(state.locks) :dets.close(state.locks) end + :ok end # Load/Reloads text files from disk defp load() do - triggers = Path.wildcard(directory() <> "/*.txt") - |> Enum.reduce(%{}, fn(path, m) -> - file = Path.basename(path) - key = String.replace(file, ".txt", "") - data = directory() <> file - |> File.read! - |> String.split("\n") - |> Enum.reject(fn(line) -> - cond do - line == "" -> true - !line -> true - true -> false - end + 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 if data = Map.get(triggers, trigger) do {data, _idx} = Enum.random(data) data else nil end end defp get_random(msg, triggers, trigger, opt) do - arg = case Integer.parse(opt) do - {pos, ""} -> {:index, pos} - {_pos, _some_string} -> {:grep, opt} - _error -> {:grep, opt} - end + 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 defp create_file(name) do File.touch!(directory() <> "/" <> name <> ".txt") :ok end defp add(triggers, trigger_and_content) do case String.split(trigger_and_content, " ", parts: 2) do [trigger, content] -> {trigger, _} = clean_trigger(trigger) - if Map.has_key?(triggers, trigger) do - jaro = Enum.find(triggers[trigger], fn({string, idx}) -> String.jaro_distance(content, string) > 0.9 end) + 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+1}} + {: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) end def directory() do Application.get_env(:nola, :data_path) <> "/irc.txt/" end defp can_write?(%{rw: rw?, locks: locks}, msg = %{channel: nil, sender: sender}, trigger) do admin? = 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? if !can? do reason = if !rw?, do: "lecture seule", else: "fichier vérrouillé" msg.replyfun.("#{sender.nick}: permission refusée (#{reason})") end + can? end - defp can_write?(state = %__MODULE__{rw: rw?, locks: locks}, msg = %{channel: channel, sender: sender}, trigger) do + 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? if !can? do reason = if !rw?, do: "lecture seule", else: "fichier vérrouillé" msg.replyfun.("#{sender.nick}: permission refusée (#{reason})") end + can? end - end diff --git a/lib/plugins/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_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,39 +1,38 @@ defmodule Nola.Plugins.Txt.MarkovPyMarkovify do - def start_link() do {:ok, nil} end def reload(_data, _markov) do :ok end def sentence(_) do {:ok, run()} end def complete_sentence(sentence, _) do {:ok, run([sentence])} end 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}" end end 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,66 +1,83 @@ defmodule Nola.Plugins.Untappd do - def irc_doc() do """ # [Untappd](https://untappd.com) * `!beer ` Information about the first beer matching `` * `?beer ` List the 10 firsts beer matching `` _Note_: The best way to search is always "Brewery Name + Beer Name", such as "Dogfish 60 Minute". Link your Untappd account to the bot (for automated checkins on [alcoolog](#alcoolog), ...) with the `enable-untappd` command, in private. """ end def start_link() do GenServer.start_link(__MODULE__, [], name: __MODULE__) end def init(_) do - {:ok, _} = Registry.register(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 634167e..1a9881c 100644 --- a/lib/plugins/user_mention.ex +++ b/lib/plugins/user_mention.ex @@ -1,52 +1,70 @@ defmodule Nola.Plugins.UserMention do @moduledoc """ # mention * **@`` ``**: notifie si possible le nick immédiatement via Telegram, SMS, ou équivalent à `!tell`. """ require Logger def short_irc_doc, do: false def irc_doc, do: @moduledoc def start_link() do GenServer.start_link(__MODULE__, [], name: __MODULE__) end def init(_) do {:ok, _} = Registry.register(Nola.PubSub, "triggers", plugin: __MODULE__) {: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") text = "#{channel} <#{sender.nick}> #{Enum.join(content, " ")}" 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 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 @@ -1,90 +1,108 @@ defmodule Nola.Plugins.Wikipedia do require Logger @moduledoc """ # wikipédia * **!wp ``**: retourne le premier résultat de la `` Wikipedia * **!wp**: un article Wikipédia au hasard """ def irc_doc, do: @moduledoc def start_link() do GenServer.start_link(__MODULE__, [], name: __MODULE__) end def init(_) do - {:ok, _} = Registry.register(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 def handle_info(info, state) do {:noreply, state} end defp irc_search("", message), do: irc_random(message) + defp irc_search(query, message) do params = %{ "action" => "query", "list" => "search", "srsearch" => String.strip(query), - "srlimit" => 1, + "srlimit" => 1 } + case query_wikipedia(params) do {:ok, %{"query" => %{"search" => [item | _]}}} -> title = item["title"] url = "https://fr.wikipedia.org/wiki/" <> String.replace(title, " ", "_") msg = "Wikipédia: #{title} — #{url}" message.replyfun.(msg) + _ -> nil end end defp irc_random(message) do params = %{ "action" => "query", "generator" => "random", "grnnamespace" => 0, "prop" => "info" } + case query_wikipedia(params) do {:ok, %{"query" => %{"pages" => map = %{}}}} -> [{_, item}] = Map.to_list(map) title = item["title"] url = "https://fr.wikipedia.org/wiki/" <> String.replace(title, " ", "_") msg = "Wikipédia: #{title} — #{url}" message.replyfun.(msg) + _ -> nil end end defp query_wikipedia(params) do url = "https://fr.wikipedia.org/w/api.php" - params = params - |> Map.put("format", "json") - |> Map.put("utf8", "") + + 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 @@ -1,47 +1,58 @@ defmodule Nola.Plugins.WolframAlpha do use GenServer require Logger @moduledoc """ # wolfram alpha * **`!wa `** lance `` sur WolframAlpha """ def irc_doc, do: @moduledoc def start_link() do GenServer.start_link(__MODULE__, [], name: __MODULE__) end def init(_) do - {:ok, _} = Registry.register(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 @@ -1,104 +1,130 @@ defmodule Nola.Plugins.YouTube do require Logger @moduledoc """ # youtube * **!yt ``**, !youtube ``: retourne le premier résultat de la `` YouTube """ defstruct client: nil def irc_doc, do: @moduledoc def start_link() do GenServer.start_link(__MODULE__, [], name: __MODULE__) end def init([]) do - for t <- ["trigger:yt", "trigger:youtube"], do: {:ok, _} = Registry.register(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 def handle_info(info, state) do {:noreply, state} end defp irc_search(query, message) do case search(query) do {:ok, %{"items" => [item | _]}} -> url = "https://youtube.com/watch?v=" <> item["id"] snippet = item["snippet"] - duration = item["contentDetails"]["duration"] |> String.replace("PT", "") |> String.downcase - date = snippet["publishedAt"] - |> DateTime.from_iso8601() - |> elem(1) - |> Timex.format("{relative}", :relative) - |> elem(1) - - info_line = "— #{duration} — uploaded by #{snippet["channelTitle"]} — #{date}" - <> " — #{item["statistics"]["viewCount"]} views, #{item["statistics"]["likeCount"]} likes," - <> " #{item["statistics"]["dislikeCount"]} dislikes" + + 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 @@ -1,236 +1,347 @@ defmodule Nola.Telegram do require Logger @behaviour Telegram.ChatBot def my_path() do "https://t.me/beauttebot" end def send_message(id, text, md2 \\ false) do md = if md2, do: "MarkdownV2", else: "Markdown" token = Keyword.get(Application.get_env(:nola, :telegram, []), :key) Telegram.Bot.ChatBot.Chat.Session.Supervisor.start_child(Nola.Telegram, id) Telegram.Api.request(token, "sendMessage", chat_id: id, text: text, parse_mode: "Markdown") end @impl Telegram.ChatBot def init(chat_id) when chat_id < 0 do case Nola.TelegramRoom.init(chat_id) do {:ok, state} -> {:ok, %{room_state: state}} _ -> :ignore end end def init(chat_id) do Logger.info("Telegram session starting: #{chat_id}") account = Nola.Account.find_meta_account("telegram-id", chat_id) account_id = if account, do: account.id {:ok, %{account: account_id}} end @impl Telegram.ChatBot def handle_update(update, token, %{room_state: room_state}) do {:ok, room_state} = Nola.TelegramRoom.handle_update(update, token, room_state) {:ok, %{room_state: room_state}} end - def handle_update(%{"message" => m = %{"chat" => %{"type" => "private"}, "text" => text = "/start"<>_}}, _token, state) do - text = "*Welcome to beautte!*\n\nQuery the bot on IRC and say \"enable-telegram\" to continue." + 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"}, # "message_id" => 29, # "photo" => [ # %{"file_id" => "AgACAgQAAxkBAAMdXtYyz4RQqLcpOlR6xKK3w3NayHAAAnCzMRuL4bFSgl_cTXMl4m5G_T0kXQADAQADAgADbQADZVMBAAEaBA", # "file_size" => 9544, "file_unique_id" => "AQADRv09JF0AA2VTAQAB", "height" => 95, "width" => 320}, # %{"file_id" => "AgACAgQAAxkBAAMdXtYyz4RQqLcpOlR6xKK3w3NayHAAAnCzMRuL4bFSgl_cTXMl4m5G_T0kXQADAQADAgADeAADZFMBAAEaBA", # "file_size" => 21420, "file_unique_id" => "AQADRv09JF0AA2RTAQAB", "height" => 148, "width" => 501}]}, # "update_id" => 218161546} for type <- ~w(photo voice video document animation) do def handle_update(data = %{"message" => %{unquote(type) => _}}, token, state) do start_upload(unquote(type), data, token, state) end end - #[debug] Unhandled update: %{"callback_query" => + # [debug] Unhandled update: %{"callback_query" => # %{ # "chat_instance" => "-7948978714441865930", "data" => "evolu.net/#dmz", # "from" => %{"first_name" => "J", "id" => 2075406, "is_bot" => false, "language_code" => "en", "username" => "ahref"}, # "id" => "8913804780149600", # "message" => %{"chat" => %{"first_name" => "J", "id" => 2075406, "type" => "private", "username" => "ahref"}, # "date" => 1591098553, "from" => %{"first_name" => "devbeautte", "id" => 1293058838, "is_bot" => true, "username" => "devbeauttebot"}, # "message_id" => 62, # "reply_markup" => %{"inline_keyboard" => [[%{"callback_data" => "random/#", "text" => "random/#"}, # %{"callback_data" => "evolu.net/#dmz", "text" => "evolu.net/#dmz"}]]}, # "text" => "Where should I send the file?"} # } # , "update_id" => 218161568} - #def handle_update(t, %{"callback_query" => cb = %{"data" => "resend", "id" => id, "message" => m = %{"message_id" => m_id, "chat" => %{"id" => chat_id}, "reply_to_message" => op}}}) do - #end + # def handle_update(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), - <> = 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), + <> = 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 @impl Telegram.ChatBot def handle_info(info, %{room_state: room_state}) do {:ok, room_state} = Nola.TelegramRoom.handle_info(info, room_state) {:ok, %{room_state: room_state}} end def handle_info(_info, state) do {:ok, state} end defp as_irc_message(id, text, account) do - reply_fun = fn(text) -> send_message(id, text) end - trigger_text = cond do - String.starts_with?(text, "/") -> - "/"<>text = text - "!"<>text - Enum.any?(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 @@ -1,194 +1,247 @@ defmodule Nola.TelegramRoom do require Logger @behaviour Telegram.ChatBot alias Telegram.Api @couch "bot-telegram-rooms" def rooms(), do: rooms(:with_docs) - @spec rooms(:with_docs | :ids) :: [Map.t | integer( )] + @spec rooms(:with_docs | :ids) :: [Map.t() | integer()] def rooms(:with_docs) do case Couch.get(@couch, :all_docs, include_docs: true) do {:ok, %{"rows" => rows}} -> {:ok, for(%{"doc" => doc} <- rows, do: doc)} error = {:error, _} -> error end end def rooms(:ids) do case Couch.get(@couch, :all_docs) do {:ok, %{"rows" => rows}} -> {:ok, for(%{"id" => id} <- rows, do: id)} error = {:error, _} -> error end end def room(id, opts \\ []) do Couch.get(@couch, id, opts) end # TODO: Create couch def setup() do :ok end def after_start() do {: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 @impl Telegram.ChatBot def init(id) when is_integer(id) and id < 0 do token = Keyword.get(Application.get_env(:nola, :telegram, []), :key) {:ok, chat} = Api.request(token, "getChat", chat_id: id) Logger.metadata(transport: :telegram, id: id, telegram_room_id: id) - tg_room = case room(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 defp find_or_create_meta_account(from = %{"id" => user_id}, state) do if account = Nola.Account.find_meta_account("telegram-id", user_id) do account else first_name = Map.get(from, "first_name") last_name = Map.get(from, "last_name") - name = [first_name, last_name] - |> Enum.filter(& &1) - |> Enum.join(" ") + + 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) Logger.info("telegram_room: created account #{account.id} for telegram user #{user_id}") account end end - def handle_update(%{"message" => %{"from" => from = %{"id" => user_id}, "text" => text}}, _token, state) do + 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 for type <- ~w(photo voice video document animation) do def handle_update(data = %{"message" => %{unquote(type) => _}}, token, state) do upload(unquote(type), data, token, state) end end def handle_update(update, token, state) do {:ok, state} end def handle_info({:irc, _, _, message}, state) do handle_info({:irc, nil, message}, state) end def handle_info({:irc, _, message = %Nola.Message{sender: %{nick: nick}, text: text}}, state) do if Map.get(message.meta, :from) == self() || Map.get(message.meta, :origin) == __MODULE__ do else body = if Map.get(message.meta, :self), do: text, else: "<#{nick}> #{text}" Nola.Telegram.send_message(state.id, body) end + {:ok, state} end def handle_info(info, state) do - Logger.info("UNhandled #{inspect info}") + 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), - <> = 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), + <> = 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 @@ -1,124 +1,144 @@ defmodule Tmpl do require Logger defmodule Filter do use Liquex.Filter def repeat(text, val, _) do String.duplicate(text, val) end def rrepeat(text, max, _) do String.duplicate(text, :random.uniform(max)) end def rrepeat(text, var) do rrepeat(text, 20, var) end def bold(text, %{variables: variables}) do unless Map.get(variables, "_no_format") || Map.get(variables, "_no_bold") do <<2>> <> text <> <<2>> else text end end - @colors [:white, :black, :blue, :green, :red, :brown, :purple, :orange, :yellow, :light_green, :cyan, :light_blue, :pink, :grey, :light_grey] + @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) end def unquote(color)(text, %{variables: variables}) do unless Map.get(variables, "_no_format") || Map.get(variables, "_no_colors") do <<3, unquote(code)>> <> text <> <<3>> else text end end end - def account_nick(%{"id" => id, "name" => name}, %{variables: %{"message" => %{"network" => network}}}) do + def account_nick(%{"id" => id, "name" => name}, %{ + variables: %{"message" => %{"network" => network}} + }) do if user = Nola.UserTrack.find_by_account(network, %Nola.Account{id: id}) do user.nick else name end end def account_nick(val, ctx) do "{{account_nick}}" end - end def render(template, msg = %Nola.Message{}, context \\ %{}, safe \\ true) do do_render(template, Map.put(context, "message", msg), safe) end defp do_render(template, context, safe) when is_binary(template) do case Liquex.parse(template) do {:ok, template_ast} -> do_render(template_ast, context, safe) + {:error, err, pos} -> - Logger.debug("Liquid error: #{pos} - #{inspect template}") - "[liquid ast error (at #{pos}): #{inspect err}]" + 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 defp mapify(struct = %{__struct__: _}, safe) do mapify(Map.from_struct(struct), safe) end defp mapify(map = %{}, safe) do map - |> Enum.reduce(Map.new, fn({k,v}, acc) -> + |> Enum.reduce(Map.new(), fn {k, v}, acc -> k = to_string(k) + if safe?(k, safe) do if v = mapify(v, safe) do Map.put(acc, k, v) else acc end else acc end end) end defp mapify(fun, _) when is_function(fun) do nil end defp mapify(atom, _) when is_atom(atom) do to_string(atom) end defp mapify(v, _) do v end defp safe?(_, false) do true end defp safe?("token", true), do: false defp safe?("password", true), do: false defp safe?(_, true), do: true end - diff --git a/lib/untappd.ex b/lib/untappd.ex index e603c25..c563fa9 100644 --- a/lib/untappd.ex +++ b/lib/untappd.ex @@ -1,94 +1,115 @@ 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 def auth_callback(code) 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, "response_type" => code, "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 def maybe_checkin(account, beer_id) do if token = Nola.Account.get_meta(account, "untappd-token") do checkin(token, beer_id) else {:error, :no_token} end 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 auth end end 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 a402519..71fddab 100644 --- a/lib/util.ex +++ b/lib/util.ex @@ -1,86 +1,84 @@ 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() |> to_naive_date_time() end def to_date_time(naive_or_timestamp, timezone \\ "Europe/Paris") def to_date_time(date = %DateTime{}, timezone) do DateTime.shift_zone!(date, timezone, Tzdata.TimeZoneDatabase) end def to_date_time(naive = %NaiveDateTime{}, timezone) do DateTime.from_naive!(naive, timezone, Tzdata.TimeZoneDatabase) end # todo: this is wrong. def to_date_time(timestamp, timezone) when is_integer(timestamp) do timestamp |> DateTime.from_unix!(:millisecond) |> DateTime.shift_zone!(timezone, Tzdata.TimeZoneDatabase) end def plusminus(number) when number > 0, do: "+#{number}" def plusminus(0), do: "0" 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(",", ".") |> Float.parse() end def ets_mutate_select_each(ets, table, spec \\ [{:"$1", [], [:"$1"]}], fun) do ets.safe_fixtable(table, true) first = ets.select(table, spec, 1) do_ets_mutate_select_each(ets, table, fun, first) after 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 defp do_ets_mutate_select_each(ets, table, fun, {objs, continuation}) do for obj <- objs, do: fun.(table, obj) 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) do_ets_mutate_each(ets, table, fun, first) after ets.safe_fixtable(table, false) end defp do_ets_mutate_each(ets, table, fun, key) do case ets.lookup(table, key) do [elem] -> fun.(table, elem) _ -> nil end + do_ets_mutate_each(ets, table, fun, ets.next(table, key)) end - end diff --git a/lib/web.ex b/lib/web.ex index c1720a0..e8cb26d 100644 --- a/lib/web.ex +++ b/lib/web.ex @@ -1,99 +1,102 @@ defmodule NolaWeb do @moduledoc """ The entrypoint for defining your web interface, such as controllers, views, channels and so on. This can be used in your application as: use NolaWeb, :controller use NolaWeb, :view The definitions below will be executed for every view, controller, etc, so keep them short and clean, focused on imports, uses and aliases. Do NOT define functions inside the quoted expressions below. Instead, define any helper function in modules and import those modules here. """ def format_chan("##") do "♯♯" end def format_chan("#") 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 quote do use Phoenix.Controller, namespace: NolaWeb import Plug.Conn import NolaWeb.Router.Helpers import NolaWeb.Gettext alias NolaWeb.Router.Helpers, as: Routes end end 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] # Use all HTML functionality (forms, tags, etc) use Phoenix.HTML import NolaWeb.Router.Helpers import NolaWeb.ErrorHelpers import NolaWeb.Gettext import Phoenix.LiveView.Helpers alias NolaWeb.Router.Helpers, as: Routes end end def router do quote do use Phoenix.Router import Plug.Conn import Phoenix.Controller import Phoenix.LiveView.Router end end def channel do quote do use Phoenix.Channel import NolaWeb.Gettext end end @doc """ When used, dispatch to the appropriate controller/view/etc. """ defmacro __using__(which) when is_atom(which) do apply(__MODULE__, which, []) end end 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 @@ -1,37 +1,37 @@ defmodule NolaWeb.UserSocket do use Phoenix.Socket ## Channels # 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 # be used to verify and authenticate a user. After # verification, you can put default assigns into # the socket that will be set for all channels, ie # # {:ok, assign(socket, :user_id, verified_user_id)} # # To deny connection, return `:error`. # # See `Phoenix.Token` documentation for examples in # performing token verification on connect. def connect(_params, socket) do {:ok, socket} end # Socket id's are topics that allow you to identify all sockets for a given user: # # def id(socket), do: "user_socket:#{socket.assigns.user_id}" # # Would allow you to broadcast a "disconnect" event and terminate # all active sockets and channels for a given user: # # NolaWeb.Endpoint.broadcast("user_socket:#{user.id}", "disconnect", %{}) # # Returning `nil` makes this socket anonymous. def id(_socket), do: nil end 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 @@ -1,44 +1,45 @@ defmodule NolaWeb.Component do use Phoenix.Component @date_time_default_format "%F %H:%M" @date_time_formats %{"time-24-with-seconds" => "%H:%M:%S"} def naive_date_time_utc(assigns = %{at: nil}) do "" end def naive_date_time_utc(assigns = %{format: format}) do assigns = assign(assigns, :format, Map.get(@date_time_formats, format, format)) + ~H""" """ 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 ~H""" <%= @nick %> """ end def nick(assigns = %{self: true}) do ~H""" You """ 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 @@ -1,43 +1,41 @@ defmodule NolaWeb.EventComponent do use Phoenix.Component def content(assigns = %{event: %{type: :day_changed}}) do ~H""" Day changed: <%= Date.to_string(@date) %> """ end def content(assigns = %{event: %{type: :quit}}) do ~H""" has quit: <%= @reason %> """ end def content(assigns = %{event: %{type: :part}}) do ~H""" has left: <%= @reason %> """ end def content(assigns = %{event: %{type: :nick}}) do ~H""" <%= @old_nick %> is now known as """ end def content(assigns = %{event: %{type: :join}}) do ~H""" 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 @@ -1,12 +1,11 @@ defmodule NolaWeb.MessageComponent do use Phoenix.Component def content(assigns) do ~H"""
<%= @message.sender.nick %>
<%= @text %>
""" 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 @@ -1,92 +1,116 @@ defmodule NolaWeb.ContextPlug do import Plug.Conn import Phoenix.Controller def init(opts \\ []) do opts || [] end 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 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 -> conn |> 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 @@ -1,323 +1,439 @@ 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) |> text("Page not found") 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) conn |> put_resp_content_type("application/json") |> text(Jason.encode!(stats)) else conn |> put_status(404) |> json([]) 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") |> text(Jason.encode!(filled)) else conn |> put_status(404) |> json([]) 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") |> text(Jason.encode!(filled)) else conn |> put_status(404) |> json([]) 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()} history = history ++ [last] conn |> put_resp_content_type("application/json") |> text(Jason.encode!(history)) else conn |> put_status(404) |> json([]) 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)) else conn |> put_status(404) |> json([]) end end def index(conn = %{assigns: %{account: account}}, %{"network" => network, "chan" => channel}) do index(conn, account, network, NolaWeb.reformat_chan(channel)) end def index(conn = %{assigns: %{account: account}}, _) 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) # end # irc_conn = if network do # Nola.Irc.Connection.get_network(network, chan) # end # bot = if(irc_conn, do: irc_conn.nick)# # # 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) |> text("invalid value") end else conn |> put_status(:not_found) |> text("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 @@ -1,41 +1,41 @@ defmodule NolaWeb.IcecastSseController do use NolaWeb, :controller require Logger @ping_interval 20_000 def sse(conn, _params) do conn |> put_resp_header("X-Accel-Buffering", "no") |> put_resp_header("content-type", "text/event-stream") |> 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 def subscribe(conn) do :timer.send_interval(@ping_interval, {:event, :ping}) {:ok, _} = Registry.register(Nola.BroadcastRegistry, "icecast", []) conn 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) |> sse_loop() end defp send_sse_message(conn, type, data) do json = Jason.encode!(%{type => data}) {: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 @@ -1,66 +1,76 @@ defmodule NolaWeb.IrcAuthSseController do use NolaWeb, :controller require Logger @ping_interval 20_000 @expire_delay :timer.minutes(3) def sse(conn, params) do - perks = if uri = Map.get(params, "redirect_to") do - {:redirect, uri} - else - nil - end + perks = + if uri = Map.get(params, "redirect_to") do + {:redirect, uri} + else + nil + end + token = String.downcase(EntropyString.random_string(65)) + conn |> assign(:token, token) |> assign(:perks, perks) |> put_resp_header("X-Accel-Buffering", "no") |> put_resp_header("content-type", "text/event-stream") |> send_chunked(200) |> subscribe() |> send_sse_message("token", token) |> sse_loop end def subscribe(conn) do :timer.send_interval(@ping_interval, {:event, :ping}) :timer.send_after(@expire_delay, {:event, :expire}) {:ok, _} = Registry.register(Nola.PubSub, "messages:private", []) conn end def sse_loop(conn) do - {type, event, exit} = receive do - {:event, :ping} -> {"ping", "ping", false} - {:event, :expire} -> {"expire", "expire", true} - {:irc, :text, %{account: account, text: token} = m} -> - if String.downcase(String.trim(token)) == conn.assigns.token do - path = Nola.AuthToken.new_path(account.id, conn.assigns.perks) - m.replyfun.("ok!") - {"authenticated", path, true} - else + {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 else sse_loop(conn) end end defp send_sse_message(conn, type, data) do {:ok, conn} = chunk(conn, "event: #{type}\ndata: #{data}\n\n") conn end - end diff --git a/lib/web/controllers/irc_controller.ex b/lib/web/controllers/irc_controller.ex index a78582e..9de807b 100644 --- a/lib/web/controllers/irc_controller.ex +++ b/lib/web/controllers/irc_controller.ex @@ -1,101 +1,141 @@ 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 - if Kernel.function_exported?(mod, :irc_doc, 0), do: {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) else conn |> put_status(404) |> text("Not found") end else 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") |> render("txt.html", name: txt, data: lines, doc: nil) else conn |> put_status(404) |> text("Not found") end end 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,64 +1,73 @@ 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 def callback(conn, %{"error" => error_code, "error_description" => error}) do Logger.warn("OpenId error: #{error_code} #{error}") render(conn, "error.html", error: error) 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 conn |> put_session(:oidc_id, id) |> put_flash(:info, "Logged in!") |> redirect(to: Routes.path(conn, "/")) else {: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 def callback(conn, _params) do render(conn, "error.html", error: "Unspecified error.") end 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], site: config[:base_url], 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,53 +1,58 @@ 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) {:external_redirect, url} -> redirect(conn, external: url) end else z -> IO.inspect(z) text(conn, "Error: invalid or expired token") end end def index(conn = %{assigns: %{account: account}}, _) do memberships = Nola.Membership.of_account(account) 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 @@ -1,10 +1,9 @@ defmodule NolaWeb.SmsController do use NolaWeb, :controller 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 @@ -1,18 +1,16 @@ 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 {:account, _} -> text(conn, "Error: account not found") :error -> text(conn, "Error: untappd authentication failed") end end - end diff --git a/lib/web/live/chat_live.ex b/lib/web/live/chat_live.ex index 6902250..0d45428 100644 --- a/lib/web/live/chat_live.ex +++ b/lib/web/live/chat_live.ex @@ -1,121 +1,140 @@ defmodule NolaWeb.ChatLive do use Phoenix.LiveView use Phoenix.HTML require Logger def mount(%{"network" => network, "chan" => chan}, %{"account" => account_id}, socket) do chan = NolaWeb.reformat_chan(chan) connection = Nola.Irc.Connection.get_network(network, chan) account = Nola.Account.get(account_id) 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 {:ok, redirect(socket, to: "/")} end end def handle_event("send", %{"message" => %{"text" => text}}, socket) do account = Nola.Account.get(socket.assigns.account_id) Nola.Irc.send_message_as(account, socket.assigns.network, socket.assigns.channel, text, true) {:noreply, assign(socket, :counter, socket.assigns.counter + 1)} end def handle_info({:irc, :event, event = %{type: :join, user_id: id}}, socket) do if user = 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} end end def handle_info({:irc, :event, event = %{type: :nick, user_id: id, nick: nick}}, socket) do - socket = socket - |> assign(:users, update_in(socket.assigns.users, [id, :nick], nick)) - |> append_to_backlog(event) + 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 def handle_info({:irc, :trigger, _, message}, socket) do handle_info({:irc, nil, message}, socket) end # 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) + + 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 defp append_to_backlog(socket, line) do {add, _} = reduce_contextual_event(line, {[], List.last(socket.assigns.backlog)}) assign(socket, :backlog, socket.assigns.backlog ++ add) end defp reduce_contextual_event(line, {acc, nil}) do {[line | acc], line} end + defp reduce_contextual_event(line, {acc, last}) do if NaiveDateTime.to_date(last.at) != NaiveDateTime.to_date(line.at) do {[%{type: :day_changed, date: NaiveDateTime.to_date(line.at), at: nil}, line | acc], line} - else - {[line | acc], line} - end - + else + {[line | acc], line} + end end - end diff --git a/lib/web/router.ex b/lib/web/router.ex index fb0df63..18509ac 100644 --- a/lib/web/router.ex +++ b/lib/web/router.ex @@ -1,89 +1,87 @@ 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 + 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("/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("/api/untappd/callback", UntappdController, :callback, as: :untappd_callback) - get "/-", IrcController, :index - get "/-/txt", IrcController, :txt - get "/-/txt/:name", IrcController, :txt + 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("/-/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 + get("/-/alcoolog", AlcoologController, :index) + get("/-/alcoolog/~/:account_name", AlcoologController, :index) - get "/:network", NetworkController, :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/~: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/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 + 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 + 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 + pipe_through(:matrix_app_service) - put "/transactions/:txn_id", TransactionController, :push + put("/transactions/:txn_id", TransactionController, :push) - get "/users/:user_id", UserController, :query - get "/rooms/*room_alias", RoomController, :query + 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 + 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/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 @@ -1,40 +1,40 @@ defmodule NolaWeb.ErrorHelpers do @moduledoc """ Conveniences for translating and building error messages. """ use Phoenix.HTML @doc """ 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 @doc """ Translates an error message using gettext. """ def translate_error({msg, opts}) do # Because error messages were defined within Ecto, we must # call the Gettext module passing our Gettext backend. We # also use the "errors" domain as translations are placed # in the errors.po file. # Ecto will pass the :count keyword if the error message is # meant to be pluralized. # On your own code and templates, depending on whether you # need the message to be pluralized or not, this could be # written simply as: # # dngettext "errors", "1 file", "%{count} files", count # dgettext "errors", "is invalid" # if count = opts[:count] do Gettext.dngettext(NolaWeb.Gettext, "errors", msg, msg, count, opts) else Gettext.dgettext(NolaWeb.Gettext, "errors", msg, opts) end 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 @@ -1,17 +1,17 @@ defmodule NolaWeb.ErrorView do use NolaWeb, :view def render("404.html", _assigns) do "Page not found" end def render("500.html", _assigns) do "Internal server error" end # 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 @@ -1,81 +1,114 @@ 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!() |> raw() 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 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) now_week = Timex.iso_week(now) 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