diff --git a/lib/irc.ex b/lib/irc.ex index 8d04d50..1592955 100644 --- a/lib/irc.ex +++ b/lib/irc.ex @@ -1,49 +1,49 @@ defmodule Nola.Irc do require Logger - def env(), do: Nola.env(:irc) + 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) 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) 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}") 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? 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]), ] 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/nola/account.ex b/lib/nola/account.ex index 47e46b8..70e9e40 100644 --- a/lib/nola/account.ex +++ b/lib/nola/account.ex @@ -1,263 +1,263 @@ defmodule Nola.Account do alias Nola.UserTrack.User @moduledoc """ Account registry.... Maps a network predicate: * `{net, {:nick, nickname}}` * `{net, {:account, account}}` * `{net, {:mask, user@host}}` to an unique identifier, that can be shared over multiple networks. If a predicate cannot be found for an existing account, a new account will be made in the database. To link two existing accounts from different network onto a different one, a merge operation is provided. """ # FIXME: Ensure uniqueness of name? @derive {Poison.Encoder, except: [:token]} defstruct [:id, :name, :token] @type t :: %__MODULE__{id: id(), name: String.t()} @type id :: String.t() defimpl Inspect, for: __MODULE__ do import Inspect.Algebra def inspect(%{id: id, name: name}, opts) do concat(["#Nola.Account[", id, " ", name, "]"]) end end def file(base) do to_charlist(Nola.data_path() <> "/account_#{base}.dets") end defp from_struct(%__MODULE__{id: id, name: name, token: token}) do {id, name, token} end defp from_tuple({id, name, token}) do %__MODULE__{id: id, name: name, token: token} end def start_link() do GenServer.start_link(__MODULE__, [], [name: __MODULE__]) end def init(_) do {:ok, accounts} = :dets.open_file(file("db"), []) {:ok, meta} = :dets.open_file(file("meta"), []) {:ok, predicates} = :dets.open_file(file("predicates"), [{:type, :set}]) {:ok, %{accounts: accounts, meta: meta, predicates: predicates}} end def get(id) do case :dets.lookup(file("db"), id) do [account] -> from_tuple(account) _ -> nil end end def get_by_name(name) do spec = [{{:_, :"$1", :_}, [{:==, :"$1", {:const, name}}], [:"$_"]}] case :dets.select(file("db"), spec) do [account] -> from_tuple(account) _ -> nil end end def get_meta(%__MODULE__{id: id}, key, default \\ nil) do case :dets.lookup(file("meta"), {id, key}) do [{_, value}] -> (value || default) _ -> default end end @spec find_meta_accounts(String.t()) :: [{account :: t(), value :: String.t()}, ...] @doc "Find all accounts that have a meta of `key`." def find_meta_accounts(key) do spec = [{{{:"$1", :"$2"}, :"$3"}, [{:==, :"$2", {:const, key}}], [{{:"$1", :"$3"}}]}] for {id, val} <- :dets.select(file("meta"), spec), do: {get(id), val} end @doc "Find an account given a specific meta `key` and `value`." @spec find_meta_account(String.t(), String.t()) :: t() | nil def find_meta_account(key, value) do #spec = [{{{:"$1", :"$2"}, :"$3"}, [:andalso, {:==, :"$2", {:const, key}}, {:==, :"$3", {:const, value}}], [:"$1"]}] spec = [{{{:"$1", :"$2"}, :"$3"}, [{:andalso, {:==, :"$2", {:const, key}}, {:==, {:const, value}, :"$3"}}], [:"$1"]}] case :dets.select(file("meta"), spec) do [id] -> get(id) _ -> nil end end def get_all_meta(%__MODULE__{id: id}) do spec = [{{{:"$1", :"$2"}, :"$3"}, [{:==, :"$1", {:const, id}}], [{{:"$2", :"$3"}}]}] :dets.select(file("meta"), spec) end def put_user_meta(account = %__MODULE__{}, key, value) do put_meta(account, "u:"<>key, value) end def put_meta(%__MODULE__{id: id}, key, value) do :dets.insert(file("meta"), {{id, key}, value}) end def delete_meta(%__MODULE__{id: id}, key) do :dets.delete(file("meta"), {id, key}) end def all_accounts() do :dets.traverse(file("db"), fn(obj) -> {:continue, from_tuple(obj)} end) end def all_predicates() do :dets.traverse(file("predicates"), fn(obj) -> {:continue, obj} end) end def all_meta() do :dets.traverse(file("meta"), fn(obj) -> {:continue, obj} end) end def merge_account(old_id, new_id) do if old_id != new_id do spec = [{{:"$1", :"$2"}, [{:==, :"$2", {:const, old_id}}], [:"$1"]}] predicates = :dets.select(file("predicates"), spec) for pred <- predicates, do: :ok = :dets.insert(file("predicates"), {pred, new_id}) spec = [{{{:"$1", :"$2"}, :"$3"}, [{:==, :"$1", {:const, old_id}}], [{{:"$2", :"$3"}}]}] metas = :dets.select(file("meta"), spec) for {k,v} <- metas do :dets.delete(file("meta"), {{old_id, k}}) :ok = :dets.insert(file("meta"), {{new_id, k}, v}) end :dets.delete(file("db"), old_id) Nola.Membership.merge_account(old_id, new_id) Nola.UserTrack.merge_account(old_id, new_id) 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), nil <- do_lookup(%User{network: network, nick: nick}, false), nil <- get_by_name(nick) do nil else %__MODULE__{} = account -> memberships = Nola.Membership.of_account(account) if Enum.any?(memberships, fn({net, ch}) -> (net == network) or (chan && chan == ch) end) do account else nil end end end def find(something) do do_lookup(something, false) end def lookup(something, make_default \\ true) do account = do_lookup(something, make_default) if account && Map.get(something, :nick) do 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 get(account_id) end defp do_lookup(sender = %ExIRC.Who{}, make_default) do if user = Nola.UserTrack.find_by_nick(sender) do lookup(user, make_default) else #FIXME this will never work with continued lookup by other methods as Who isn't compatible lookup_by_nick(sender, :dets.lookup(file("predicates"), {sender.network,{:nick, sender.nick}}), make_default) end end defp do_lookup(sender = %ExIRC.SenderInfo{}, make_default) do lookup(Nola.UserTrack.find_by_nick(sender), make_default) end defp do_lookup(user = %User{account: id}, make_default) when is_binary(id) do get(id) end defp do_lookup(user = %User{network: server, nick: nick}, make_default) do lookup_by_nick(user, :dets.lookup(file("predicates"), {server,{:nick, nick}}), make_default) end defp do_lookup(nil, _) do nil end defp lookup_by_nick(_, [{_, id}], _make_default) do get(id) end defp lookup_by_nick(user, _, make_default) do #authenticate_by_host(user) if make_default, do: new_account(user), else: nil end - def new_account(nick) do + 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: nick, network: server}) do + def new_account(nick) when is_binary(nick) do id = EntropyString.large_id() :dets.insert(file("db"), {id, nick, EntropyString.token()}) - :dets.insert(file("predicates"), {{server, {:nick, nick}}, id}) get(id) end def update_account_name(account = %__MODULE__{id: id}, name) do account = %__MODULE__{account | name: name} :dets.insert(file("db"), from_struct(account)) get(id) end def get_predicates(%__MODULE__{} = account) do spec = [{{:"$1", :"$2"}, [{:==, :"$2", {:const, account.id}}], [:"$1"]}] :dets.select(file("predicates"), spec) end end diff --git a/lib/nola/plugins.ex b/lib/nola/plugins.ex index b0c3ce3..7872cd6 100644 --- a/lib/nola/plugins.ex +++ b/lib/nola/plugins.ex @@ -1,100 +1,136 @@ 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.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, + ] + 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: {__MODULE__,module}, start: {__MODULE__, :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 :ignore -> Logger.warn("Ignored #{module}") :ignore {: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()) 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 :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 module.start_link() else {error, _} -> Logger.info("#{__MODULE__}: #{to_string(module)} ignored start: #{to_string(error)}") :ignore end end end diff --git a/lib/plugins/base.ex b/lib/plugins/base.ex index 0f2c7e5..1baf066 100644 --- a/lib/plugins/base.ex +++ b/lib/plugins/base.ex @@ -1,132 +1,136 @@ 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(mod) -> - mod - |> Macro.underscore() - |> String.split("/", parts: :infinity) - |> List.last() - |> Enum.sort() + |> 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(", ") msg.replyfun.("Enabled plugins: #{enabled_string}") {:noreply, nil} end def handle_info({:irc, :trigger, "plugin", %{trigger: %{type: :query, args: [plugin]}} = m}, _) do module = Module.concat([Nola.Plugins, Macro.camelize(plugin)]) with true <- Code.ensure_loaded?(module), pid when is_pid(pid) <- GenServer.whereis(module) do m.replyfun.("loaded, active: #{inspect(pid)}") else false -> m.replyfun.("not loaded") nil -> msg = case Nola.Plugins.get(module) do :disabled -> "disabled" {_, false, _} -> "disabled" _ -> "not active" end m.replyfun.(msg) end {:noreply, nil} end def handle_info({:irc, :trigger, "plugin", %{trigger: %{type: :plus, args: [plugin]}} = m}, _) do module = Module.concat([Nola.Plugins, Macro.camelize(plugin)]) with true <- Code.ensure_loaded?(module), Nola.Plugins.switch(module, true), {:ok, pid} <- Nola.Plugins.start(module) do m.replyfun.("started: #{inspect(pid)}") else false -> m.replyfun.("not loaded") :ignore -> m.replyfun.("disabled or throttled") {:error, _} -> m.replyfun.("start error") end {:noreply, nil} end def handle_info({:irc, :trigger, "plugin", %{trigger: %{type: :tilde, args: [plugin]}} = m}, _) do module = Module.concat([Nola.Plugins, Macro.camelize(plugin)]) with true <- Code.ensure_loaded?(module), pid when is_pid(pid) <- GenServer.whereis(module), :ok <- GenServer.stop(pid), {:ok, pid} <- Nola.Plugins.start(module) do m.replyfun.("restarted: #{inspect(pid)}") else false -> m.replyfun.("not loaded") nil -> m.replyfun.("not active") end {:noreply, nil} end def handle_info({:irc, :trigger, "plugin", %{trigger: %{type: :minus, args: [plugin]}} = m}, _) do module = Module.concat([Nola.Plugins, Macro.camelize(plugin)]) with true <- Code.ensure_loaded?(module), pid when is_pid(pid) <- GenServer.whereis(module), :ok <- GenServer.stop(pid) do 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)) m.replyfun.("-> #{url}") {:noreply, nil} end def handle_info({:irc, :trigger, "version", message = %{trigger: %{type: :bang}}}, _) do {:ok, vsn} = :application.get_key(:nola, :vsn) ver = List.to_string(vsn) url = NolaWeb.Router.Helpers.irc_url(NolaWeb.Endpoint, :index) elixir_ver = Application.started_applications() |> List.keyfind(:elixir, 0) |> elem(2) |> to_string() otp_ver = :erlang.system_info(:system_version) |> to_string() |> String.trim() system = :erlang.system_info(:system_architecture) |> to_string() brand = Nola.brand(:name) owner = "#{Nola.brand(:owner)} <#{Nola.brand(:owner_email)}>" message.replyfun.([ <<"🤖 I am a robot running", 2, "#{brand}, version #{ver}", 2, " — source: #{Nola.source_url()}">>, "🦾 Elixir #{elixir_ver} #{otp_ver} on #{system}", "👷‍♀️ Owner: h#{owner}", "🌍 Web interface: #{url}" ]) {:noreply, nil} end def handle_info(msg, _) do {:noreply, nil} end end