diff --git a/lib/nola_irc/plugin_supervisor.ex b/lib/nola/plugins.ex similarity index 88% rename from lib/nola_irc/plugin_supervisor.ex rename to lib/nola/plugins.ex index a65ad09..4b55f4c 100644 --- a/lib/nola_irc/plugin_supervisor.ex +++ b/lib/nola/plugins.ex @@ -1,99 +1,99 @@ -defmodule IRC.Plugin do +defmodule Nola.Plugins do require Logger 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: {IRC.Plugin,module}, start: {IRC.Plugin, :start_link, [module, opts]}, name: module, restart: :transient} + spec = %{id: {__MODULE__,module}, start: {__MODULE__, :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 - for mod <- enabled(), do: {mod, IRC.Plugin.Supervisor.start_child(mod)} + 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 start(module, opts \\ []) do - IRC.Plugin.Supervisor.start_child(module) + __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("Plugin: #{to_string(module)} ignored start: #{to_string(error)}") + Logger.info("#{__MODULE__}: #{to_string(module)} ignored start: #{to_string(error)}") :ignore end end end diff --git a/lib/nola_irc/account.ex b/lib/nola_irc/account.ex index 45680f8..a39733c 100644 --- a/lib/nola_irc/account.ex +++ b/lib/nola_irc/account.ex @@ -1,451 +1,263 @@ defmodule IRC.Account do alias IRC.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(["#IRC.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) IRC.Membership.merge_account(old_id, new_id) IRC.UserTrack.merge_account(old_id, new_id) IRC.Connection.dispatch("account", {:account_change, old_id, new_id}) IRC.Connection.dispatch("conn", {:account_change, old_id, new_id}) end :ok end @doc "Find an account by a logged in user" def find_by_nick(network, nick) do do_lookup(%ExIRC.SenderInfo{nick: nick, network: network}, false) end @doc "Always find an account by nickname, even if offline. Uses predicates and then account name." def find_always_by_nick(network, chan, nick) do with \ nil <- find_by_nick(network, nick), nil <- do_lookup(%User{network: network, nick: nick}, false), nil <- get_by_name(nick) do nil else %__MODULE__{} = account -> memberships = IRC.Membership.of_account(account) if Enum.any?(memberships, fn({net, ch}) -> (net == network) or (chan && chan == ch) end) do account else nil end end end def find(something) do do_lookup(something, false) end def lookup(something, make_default \\ true) do account = do_lookup(something, make_default) if account && Map.get(something, :nick) do IRC.Connection.dispatch("account", {:account_auth, Map.get(something, :nick), account.id}) end account end def handle_info(_, state) do {:noreply, state} end def handle_cast(_, state) do {:noreply, state} end def handle_call(_, _, state) do {:noreply, state} end def terminate(_, state) do for {_, dets} <- state do :dets.sync(dets) :dets.close(dets) end end defp do_lookup(message = %IRC.Message{account: account_id}, make_default) when is_binary(account_id) do get(account_id) end defp do_lookup(sender = %ExIRC.Who{}, make_default) do if user = IRC.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(IRC.UserTrack.find_by_nick(sender), make_default) end defp do_lookup(user = %User{account: id}, make_default) when is_binary(id) do get(id) end defp do_lookup(user = %User{network: server, nick: nick}, make_default) do lookup_by_nick(user, :dets.lookup(file("predicates"), {server,{:nick, nick}}), make_default) end defp do_lookup(nil, _) do nil end defp lookup_by_nick(_, [{_, id}], _make_default) do get(id) end defp lookup_by_nick(user, _, make_default) do #authenticate_by_host(user) if make_default, do: new_account(user), else: nil end def new_account(nick) do id = EntropyString.large_id() :dets.insert(file("db"), {id, nick, EntropyString.token()}) get(id) end def new_account(%{nick: nick, network: server}) do id = EntropyString.large_id() :dets.insert(file("db"), {id, nick, EntropyString.token()}) :dets.insert(file("predicates"), {{server, {:nick, nick}}, id}) get(id) end def update_account_name(account = %__MODULE__{id: id}, name) do account = %__MODULE__{account | name: name} :dets.insert(file("db"), from_struct(account)) get(id) end def get_predicates(%__MODULE__{} = account) do spec = [{{:"$1", :"$2"}, [{:==, :"$2", {:const, account.id}}], [:"$1"]}] :dets.select(file("predicates"), spec) end - defmodule AccountPlugin do - @moduledoc """ - # Account - - * **account** Get current account id and token - * **auth `` ``** Authenticate and link the current nickname to an account - * **auth** list authentications methods - * **whoami** list currently authenticated users - * **web** get a one-time login link to web - * **enable-telegram** Link a Telegram account - * **enable-sms** Link a SMS number - * **enable-untappd** Link a Untappd account - * **set-name** set account name - * **setusermeta puppet-nick ``** Set puppet IRC nickname - """ - - def irc_doc, do: @moduledoc - def start_link(), do: GenServer.start_link(__MODULE__, [], name: __MODULE__) - def init(_) do - {:ok, _} = Registry.register(IRC.PubSub, "messages:private", []) - {:ok, nil} - end - - def handle_info({:irc, :text, m = %IRC.Message{account: account, text: "help"}}, state) do - text = [ - "account: show current account and auth token", - "auth: show authentications methods", - "whoami: list authenticated users", - "set-name : set account name", - "web: login to web", - "enable-sms | disable-sms: enable/change or disable sms", - "enable-telegram: link/change telegram", - "enable-untappd: link untappd account", - "getmeta: show meta datas", - "setusermeta: set user meta", - ] - m.replyfun.(text) - {:noreply, state} - end - - def handle_info({:irc, :text, m = %IRC.Message{account: account, text: "auth"}}, state) do - spec = [{{:"$1", :"$2"}, [{:==, :"$2", {:const, account.id}}], [:"$1"]}] - predicates = :dets.select(IRC.Account.file("predicates"), spec) - text = for {net, {key, value}} <- predicates, do: "#{net}: #{to_string(key)}: #{value}" - m.replyfun.(text) - {:noreply, state} - end - - def handle_info({:irc, :text, m = %IRC.Message{account: account, text: "whoami"}}, state) do - users = for user <- IRC.UserTrack.find_by_account(m.account) do - chans = Enum.map(user.privileges, fn({chan, _}) -> chan end) - |> Enum.join(" ") - "#{user.network} - #{user.nick}!#{user.username}@#{user.host} - #{chans}" - end - m.replyfun.(users) - {:noreply, state} - end - - def handle_info({:irc, :text, m = %IRC.Message{account: account, text: "account"}}, state) do - account = IRC.Account.lookup(m.sender) - text = ["Account Id: #{account.id}", - "Authenticate to this account from another network: \"auth #{account.id} #{account.token}\" to the other bot!"] - m.replyfun.(text) - {:noreply, state} - end - - def handle_info({:irc, :text, m = %IRC.Message{sender: sender, text: "auth"<>_}}, state) do - #account = IRC.Account.lookup(m.sender) - case String.split(m.text, " ") do - ["auth", id, token] -> - join_account(m, id, token) - _ -> - m.replyfun.("Invalid parameters") - end - {:noreply, state} - end - - def handle_info({:irc, :text, m = %IRC.Message{account: account, text: "set-name "<>name}}, state) do - IRC.Account.update_account_name(account, name) - m.replyfun.("Name changed: #{name}") - {:noreply, state} - end - - def handle_info({:irc, :text, m = %IRC.Message{text: "disable-sms"}}, state) do - if IRC.Account.get_meta(m.account, "sms-number") do - IRC.Account.delete_meta(m.account, "sms-number") - m.replfyun.("SMS disabled.") - else - m.replyfun.("SMS already disabled.") - end - {:noreply, state} - end - - def handle_info({:irc, :text, m = %IRC.Message{text: "web"}}, state) do - auth_url = Untappd.auth_url() - login_url = Nola.AuthToken.new_url(m.account.id, nil) - m.replyfun.("-> " <> login_url) - {:noreply, state} - end - - def handle_info({:irc, :text, m = %IRC.Message{text: "enable-sms"}}, state) do - code = String.downcase(EntropyString.small_id()) - IRC.Account.put_meta(m.account, "sms-validation-code", code) - IRC.Account.put_meta(m.account, "sms-validation-target", m.network) - number = Nola.IRC.SmsPlugin.my_number() - text = "To enable or change your number for SMS messaging, please send:" - <> " \"enable #{code}\" to #{number}" - m.replyfun.(text) - {:noreply, state} - end - - def handle_info({:irc, :text, m = %IRC.Message{text: "enable-telegram"}}, state) do - code = String.downcase(EntropyString.small_id()) - IRC.Account.delete_meta(m.account, "telegram-id") - IRC.Account.put_meta(m.account, "telegram-validation-code", code) - IRC.Account.put_meta(m.account, "telegram-validation-target", m.network) - text = "To enable or change your number for telegram messaging, please open #{Nola.Telegram.my_path()} and send:" - <> " \"/enable #{code}\"" - m.replyfun.(text) - {:noreply, state} - end - - def handle_info({:irc, :text, m = %IRC.Message{text: "enable-untappd"}}, state) do - auth_url = Untappd.auth_url() - login_url = Nola.AuthToken.new_url(m.account.id, {:external_redirect, auth_url}) - m.replyfun.(["To link your Untappd account, open this URL:", login_url]) - {:noreply, state} - end - - def handle_info({:irc, :text, m = %IRC.Message{text: "getmeta"<>_}}, state) do - result = case String.split(m.text, " ") do - ["getmeta"] -> - for {k, v} <- IRC.Account.get_all_meta(m.account) do - case k do - "u:"<>key -> "(user) #{key}: #{v}" - key -> "#{key}: #{v}" - end - end - ["getmeta", key] -> - value = IRC.Account.get_meta(m.account, key) - text = if value do - "#{key}: #{value}" - else - "#{key} is not defined" - end - _ -> - "usage: getmeta [key]" - end - m.replyfun.(result) - {:noreply, state} - end - - def handle_info({:irc, :text, m = %IRC.Message{text: "setusermet"<>_}}, state) do - result = case String.split(m.text, " ") do - ["setusermeta", key, value] -> - IRC.Account.put_user_meta(m.account, key, value) - "ok" - _ -> - "usage: setusermeta " - end - m.replyfun.(result) - {:noreply, state} - end - - def handle_info(_, state) do - {:noreply, state} - end - - defp join_account(m, id, token) do - old_account = IRC.Account.lookup(m.sender) - new_account = IRC.Account.get(id) - if new_account && token == new_account.token do - case IRC.Account.merge_account(old_account.id, new_account.id) do - :ok -> - if old_account.id == new_account.id do - m.replyfun.("Already authenticated, but hello") - else - m.replyfun.("Accounts merged!") - end - _ -> m.replyfun.("Something failed :(") - end - else - m.replyfun.("Invalid token") - end - end - - end - end diff --git a/lib/nola_plugins/admin_handler.ex b/lib/nola_irc/admin_handler.ex similarity index 95% rename from lib/nola_plugins/admin_handler.ex rename to lib/nola_irc/admin_handler.ex index 9a5d557..282f3c2 100644 --- a/lib/nola_plugins/admin_handler.ex +++ b/lib/nola_irc/admin_handler.ex @@ -1,41 +1,41 @@ -defmodule Nola.IRC.AdminHandler do +defmodule Nola.Irc.AdminHandler do @moduledoc """ # admin !op op; requiert admin """ def irc_doc, do: nil def start_link(client) do GenServer.start_link(__MODULE__, [client]) end def init([client]) do ExIRC.Client.add_handler client, self :ok = IRC.register("op") {:ok, client} end def handle_info({:irc, :trigger, "op", m = %IRC.Message{trigger: %IRC.Trigger{type: :bang}, sender: sender}}, client) do if IRC.admin?(sender) do m.replyfun.({:mode, "+o"}) else m.replyfun.({:kick, "non"}) end {:noreply, client} end def handle_info({:joined, chan, sender}, client) do if IRC.admin?(sender) do ExIRC.Client.mode(client, chan, "+o", sender.nick) end {:noreply, client} end def handle_info(msg, client) do {:noreply, client} end end diff --git a/lib/nola_irc/nola_irc.ex b/lib/nola_irc/nola_irc.ex index f64978a..870b99d 100644 --- a/lib/nola_irc/nola_irc.ex +++ b/lib/nola_irc/nola_irc.ex @@ -1,34 +1,34 @@ defmodule Nola.IRC do require Logger def env(), do: Nola.env(:irc) def env(key, default \\ nil), do: Keyword.get(env(), key, default) def application_childs do import Supervisor.Spec IRC.Connection.setup() - IRC.Plugin.setup() + Nola.Plugins.setup() [ worker(Registry, [[keys: :duplicate, name: IRC.ConnectionPubSub]], id: :registr_irc_conn), worker(Registry, [[keys: :duplicate, name: IRC.PubSub]], id: :registry_irc), worker(IRC.Membership, []), worker(IRC.Account, []), worker(IRC.UserTrack.Storage, []), - worker(IRC.Account.AccountPlugin, []), - supervisor(IRC.Plugin.Supervisor, [], [name: IRC.Plugin.Supervisor]), + worker(Nola.Plugins.Account, []), + supervisor(Nola.Plugins.Supervisor, [], [name: Nola.Plugins.Supervisor]), supervisor(IRC.Connection.Supervisor, [], [name: IRC.Connection.Supervisor]), supervisor(IRC.PuppetConnection.Supervisor, [], [name: IRC.PuppetConnection.Supervisor]), ] end # Start plugins first to let them get on connection events. def after_start() do Logger.info("Starting plugins") - IRC.Plugin.start_all() + Nola.Plugins.start_all() Logger.info("Starting connections") IRC.Connection.start_all() end end diff --git a/lib/nola_plugins/README.md b/lib/nola_plugins/README.md new file mode 100644 index 0000000..82adba8 --- /dev/null +++ b/lib/nola_plugins/README.md @@ -0,0 +1 @@ +## Nola Plugins directory diff --git a/lib/nola_plugins/account.ex b/lib/nola_plugins/account.ex new file mode 100644 index 0000000..c11d077 --- /dev/null +++ b/lib/nola_plugins/account.ex @@ -0,0 +1,188 @@ +defmodule Nola.Plugins.Account do + @moduledoc """ + # Account + + * **account** Get current account id and token + * **auth `` ``** Authenticate and link the current nickname to an account + * **auth** list authentications methods + * **whoami** list currently authenticated users + * **web** get a one-time login link to web + * **enable-telegram** Link a Telegram account + * **enable-sms** Link a SMS number + * **enable-untappd** Link a Untappd account + * **set-name** set account name + * **setusermeta puppet-nick ``** Set puppet IRC nickname + """ + + def irc_doc, do: @moduledoc + def start_link(), do: GenServer.start_link(__MODULE__, [], name: __MODULE__) + def init(_) do + {:ok, _} = Registry.register(IRC.PubSub, "messages:private", []) + {:ok, nil} + end + + def handle_info({:irc, :text, m = %IRC.Message{account: account, text: "help"}}, state) do + text = [ + "account: show current account and auth token", + "auth: show authentications methods", + "whoami: list authenticated users", + "set-name : set account name", + "web: login to web", + "enable-sms | disable-sms: enable/change or disable sms", + "enable-telegram: link/change telegram", + "enable-untappd: link untappd account", + "getmeta: show meta datas", + "setusermeta: set user meta", + ] + m.replyfun.(text) + {:noreply, state} + end + + def handle_info({:irc, :text, m = %IRC.Message{account: account, text: "auth"}}, state) do + spec = [{{:"$1", :"$2"}, [{:==, :"$2", {:const, account.id}}], [:"$1"]}] + predicates = :dets.select(IRC.Account.file("predicates"), spec) + text = for {net, {key, value}} <- predicates, do: "#{net}: #{to_string(key)}: #{value}" + m.replyfun.(text) + {:noreply, state} + end + + def handle_info({:irc, :text, m = %IRC.Message{account: account, text: "whoami"}}, state) do + users = for user <- IRC.UserTrack.find_by_account(m.account) do + chans = Enum.map(user.privileges, fn({chan, _}) -> chan end) + |> Enum.join(" ") + "#{user.network} - #{user.nick}!#{user.username}@#{user.host} - #{chans}" + end + m.replyfun.(users) + {:noreply, state} + end + + def handle_info({:irc, :text, m = %IRC.Message{account: account, text: "account"}}, state) do + account = IRC.Account.lookup(m.sender) + text = ["Account Id: #{account.id}", + "Authenticate to this account from another network: \"auth #{account.id} #{account.token}\" to the other bot!"] + m.replyfun.(text) + {:noreply, state} + end + + def handle_info({:irc, :text, m = %IRC.Message{sender: sender, text: "auth"<>_}}, state) do + #account = IRC.Account.lookup(m.sender) + case String.split(m.text, " ") do + ["auth", id, token] -> + join_account(m, id, token) + _ -> + m.replyfun.("Invalid parameters") + end + {:noreply, state} + end + + def handle_info({:irc, :text, m = %IRC.Message{account: account, text: "set-name "<>name}}, state) do + IRC.Account.update_account_name(account, name) + m.replyfun.("Name changed: #{name}") + {:noreply, state} + end + + def handle_info({:irc, :text, m = %IRC.Message{text: "disable-sms"}}, state) do + if IRC.Account.get_meta(m.account, "sms-number") do + IRC.Account.delete_meta(m.account, "sms-number") + m.replfyun.("SMS disabled.") + else + m.replyfun.("SMS already disabled.") + end + {:noreply, state} + end + + def handle_info({:irc, :text, m = %IRC.Message{text: "web"}}, state) do + auth_url = Untappd.auth_url() + login_url = Nola.AuthToken.new_url(m.account.id, nil) + m.replyfun.("-> " <> login_url) + {:noreply, state} + end + + def handle_info({:irc, :text, m = %IRC.Message{text: "enable-sms"}}, state) do + code = String.downcase(EntropyString.small_id()) + IRC.Account.put_meta(m.account, "sms-validation-code", code) + IRC.Account.put_meta(m.account, "sms-validation-target", m.network) + number = Nola.IRC.Sms.my_number() + text = "To enable or change your number for SMS messaging, please send:" + <> " \"enable #{code}\" to #{number}" + m.replyfun.(text) + {:noreply, state} + end + + def handle_info({:irc, :text, m = %IRC.Message{text: "enable-telegram"}}, state) do + code = String.downcase(EntropyString.small_id()) + IRC.Account.delete_meta(m.account, "telegram-id") + IRC.Account.put_meta(m.account, "telegram-validation-code", code) + IRC.Account.put_meta(m.account, "telegram-validation-target", m.network) + text = "To enable or change your number for telegram messaging, please open #{Nola.Telegram.my_path()} and send:" + <> " \"/enable #{code}\"" + m.replyfun.(text) + {:noreply, state} + end + + def handle_info({:irc, :text, m = %IRC.Message{text: "enable-untappd"}}, state) do + auth_url = Untappd.auth_url() + login_url = Nola.AuthToken.new_url(m.account.id, {:external_redirect, auth_url}) + m.replyfun.(["To link your Untappd account, open this URL:", login_url]) + {:noreply, state} + end + + def handle_info({:irc, :text, m = %IRC.Message{text: "getmeta"<>_}}, state) do + result = case String.split(m.text, " ") do + ["getmeta"] -> + for {k, v} <- IRC.Account.get_all_meta(m.account) do + case k do + "u:"<>key -> "(user) #{key}: #{v}" + key -> "#{key}: #{v}" + end + end + ["getmeta", key] -> + value = IRC.Account.get_meta(m.account, key) + text = if value do + "#{key}: #{value}" + else + "#{key} is not defined" + end + _ -> + "usage: getmeta [key]" + end + m.replyfun.(result) + {:noreply, state} + end + + def handle_info({:irc, :text, m = %IRC.Message{text: "setusermeta"<>_}}, state) do + result = case String.split(m.text, " ") do + ["setusermeta", key, value] -> + IRC.Account.put_user_meta(m.account, key, value) + "ok" + _ -> + "usage: setusermeta " + end + m.replyfun.(result) + {:noreply, state} + end + + def handle_info(_, state) do + {:noreply, state} + end + + defp join_account(m, id, token) do + old_account = IRC.Account.lookup(m.sender) + new_account = IRC.Account.get(id) + if new_account && token == new_account.token do + case IRC.Account.merge_account(old_account.id, new_account.id) do + :ok -> + if old_account.id == new_account.id do + m.replyfun.("Already authenticated, but hello") + else + m.replyfun.("Accounts merged!") + end + _ -> m.replyfun.("Something failed :(") + end + else + m.replyfun.("Invalid token") + end + end + + end + diff --git a/lib/nola_plugins/alcolog_plugin.ex b/lib/nola_plugins/alcoolog.ex similarity index 98% rename from lib/nola_plugins/alcolog_plugin.ex rename to lib/nola_plugins/alcoolog.ex index 145e4fc..7f44ca2 100644 --- a/lib/nola_plugins/alcolog_plugin.ex +++ b/lib/nola_plugins/alcoolog.ex @@ -1,1229 +1,1229 @@ -defmodule Nola.IRC.AlcoologPlugin do +defmodule Nola.Plugins.Alcoolog do require Logger @moduledoc """ # [alcoolog]({{context_path}}/alcoolog) * **!santai `` ` [annotation]`**: enregistre un nouveau verre de `montant` d'une boisson à `degrés d'alcool`. * **!santai `` ``**: enregistre un nouveau verre de `cl` de la bière `beer name`, et checkin sur Untappd.com. * **!moar `[cl]` : enregistre un verre équivalent au dernier !santai. * **-santai**: annule la dernière entrée d'alcoolisme. * **.alcoolisme**: état du channel en temps réel. * **.alcoolisme ``**: points par jour, sur X j. * **!alcoolisme `[pseudo]`**: affiche les points d'alcoolisme. * **!alcoolisme `[pseudo]` ``**: affiche les points d'alcoolisme par jour sur X j. * **+alcoolisme `` `` `[facteur de perte en mg/l (10, 15, 20, 25)]`**: Configure votre profil d'alcoolisme. * **.sobre**: affiche quand la sobriété frappera sur le chan. * **!sobre `[pseudo]`**: affiche quand la sobriété frappera pour `[pseudo]`. * **!sobrepour ``**: affiche tu pourras être sobre pour ``, et si oui, combien de volumes d'alcool peuvent encore être consommés. * **!alcoolog**: ([voir]({{context_path}}/alcoolog)) lien pour voir l'état/statistiques et historique de l'alcoolémie du channel. * **!alcool `` ``**: donne le nombre d'unités d'alcool dans `` à `°`. * **!soif**: c'est quand l'apéro ? 1 point = 1 volume d'alcool. Annotation: champ libre! --- ## `!txt`s * status utilisateur: `alcoolog.user_(sober|legal|legalhigh|high|toohigh|sick)(|_rising)` * mauvaises boissons: `alcoolog.drink_(negative|zero|negative)` * santo: `alcoolog.santo` * santai: `alcoolog.santai` * plus gros, moins gros: `alcoolog.(fatter|thinner)` """ def irc_doc, do: @moduledoc def start_link(), do: GenServer.start_link(__MODULE__, [], name: __MODULE__) # tuple dets: {nick, date, volumes, current_alcohol_level, nom, commentaire} # tuple ets: {{nick, date}, volumes, current, nom, commentaire} # tuple meta dets: {nick, map} # %{:weight => float, :sex => true(h),false(f)} @pubsub ~w(account) @pubsub_triggers ~w(santai moar again bis santo santeau alcoolog sobre sobrepour soif alcoolisme alcool) @default_user_meta %{weight: 77.4, sex: true, loss_factor: 15} def data_state() do dets_filename = (Nola.data_path() <> "/" <> "alcoolisme.dets") |> String.to_charlist dets_meta_filename = (Nola.data_path() <> "/" <> "alcoolisme_meta.dets") |> String.to_charlist %{dets: dets_filename, meta: dets_meta_filename, ets: __MODULE__.ETS} end def init(_) do triggers = for(t <- @pubsub_triggers, do: "trigger:"<>t) for sub <- @pubsub ++ triggers do {:ok, _} = Registry.register(IRC.PubSub, sub, plugin: __MODULE__) end dets_filename = (Nola.data_path() <> "/" <> "alcoolisme.dets") |> String.to_charlist {:ok, dets} = :dets.open_file(dets_filename, [{:type,:bag}]) ets = :ets.new(__MODULE__.ETS, [:ordered_set, :named_table, :protected, {:read_concurrency, true}]) dets_meta_filename = (Nola.data_path() <> "/" <> "alcoolisme_meta.dets") |> String.to_charlist {:ok, meta} = :dets.open_file(dets_meta_filename, [{:type,:set}]) traverse_fun = fn(obj, dets) -> case obj do object = {nick, naive = %NaiveDateTime{}, volumes, active, cl, deg, name, comment} -> date = naive |> DateTime.from_naive!("Etc/UTC") |> DateTime.to_unix() new = {nick, date, volumes, active, cl, deg, name, comment, Map.new()} :dets.delete_object(dets, object) :dets.insert(dets, new) :ets.insert(ets, {{nick, date}, volumes, active, cl, deg, name, comment, Map.new()}) dets object = {nick, naive = %NaiveDateTime{}, volumes, active, cl, deg, name, comment, meta} -> date = naive |> DateTime.from_naive!("Etc/UTC") |> DateTime.to_unix() new = {nick, date, volumes, active, cl, deg, name, comment, Map.new()} :dets.delete_object(dets, object) :dets.insert(dets, new) :ets.insert(ets, {{nick, date}, volumes, active, cl, deg, name, comment, Map.new()}) dets object = {nick, date, volumes, active, cl, deg, name, comment, meta} -> :ets.insert(ets, {{nick, date}, volumes, active, cl, deg, name, comment, meta}) dets _ -> dets end end :dets.foldl(traverse_fun, dets, dets) :dets.sync(dets) state = %{dets: dets, meta: meta, ets: ets} {:ok, state} end @eau ["santo", "santeau"] def handle_info({:irc, :trigger, santeau, m = %IRC.Message{trigger: %IRC.Trigger{args: _, type: :bang}}}, state) when santeau in @eau do - Nola.IRC.TxtPlugin.reply_random(m, "alcoolog.santo") + Nola.Plugins.Txt.reply_random(m, "alcoolog.santo") {:noreply, state} end def handle_info({:irc, :trigger, "soif", m = %IRC.Message{trigger: %IRC.Trigger{args: _, type: :bang}}}, state) do now = DateTime.utc_now() |> Timex.Timezone.convert("Europe/Paris") apero = format_duration_from_now(%DateTime{now | hour: 18, minute: 0, second: 0}, false) day_of_week = Date.day_of_week(now) {txt, apero?} = cond do now.hour >= 0 && now.hour < 6 -> {["apéro tardif ? Je dis OUI ! SANTAI !"], true} now.hour >= 6 && now.hour < 12 -> if day_of_week >= 6 do {["de l'alcool pour le petit dej ? le week-end, pas de problème !"], true} else {["C'est quand même un peu tôt non ? Prochain apéro #{apero}"], false} end now.hour >= 12 && (now.hour < 14) -> {["oui! c'est l'apéro de midi! (et apéro #{apero})", "tu peux attendre #{apero} ou y aller, il est midi !" ], true} now.hour == 17 -> {[ "ÇA APPROCHE !!! Apéro #{apero}", "BIENTÔT !!! Apéro #{apero}", "achetez vite les teilles, apéro dans #{apero}!", "préparez les teilles, apéro dans #{apero}!" ], false} now.hour >= 14 && now.hour < 18 -> weekend = if day_of_week >= 6 do " ... ou maintenant en fait, c'est le week-end!" else "" end {["tiens bon! apéro #{apero}#{weekend}", "courage... apéro dans #{apero}#{weekend}", "pas encore :'( apéro dans #{apero}#{weekend}" ], false} true -> {[ "C'EST L'HEURE DE L'APÉRO !!! SANTAIIIIIIIIIIII !!!!" ], true} end txt = txt |> Enum.shuffle() |> Enum.random() m.replyfun.(txt) stats = get_full_statistics(state, m.account.id) if !apero? && stats.active > 0.1 do m.replyfun.("(... ou continue en fait, je suis pas ta mère !)") end {:noreply, state} end def handle_info({:irc, :trigger, "sobrepour", m = %IRC.Message{trigger: %IRC.Trigger{args: args, type: :bang}}}, state) do args = Enum.join(args, " ") {:ok, now} = DateTime.now("Europe/Paris", Tzdata.TimeZoneDatabase) time = case args do "demain " <> time -> {h, m} = case String.split(time, [":", "h"]) do [hour, ""] -> IO.puts ("h #{inspect hour}") {h, _} = Integer.parse(hour) {h, 0} [hour, min] when min != "" -> {h, _} = Integer.parse(hour) {m, _} = Integer.parse(min) {h, m} [hour] -> IO.puts ("h #{inspect hour}") {h, _} = Integer.parse(hour) {h, 0} _ -> {0, 0} end secs = ((60*60)*24) day = DateTime.add(now, secs, :second, Tzdata.TimeZoneDatabase) %DateTime{day | hour: h, minute: m, second: 0} "après demain " <> time -> secs = 2*((60*60)*24) DateTime.add(now, secs, :second, Tzdata.TimeZoneDatabase) datetime -> case Timex.Parse.DateTime.Parser.parse(datetime, "{}") do {:ok, dt} -> dt _ -> nil end end if time do meta = get_user_meta(state, m.account.id) stats = get_full_statistics(state, m.account.id) duration = round(DateTime.diff(time, now)/60.0) IO.puts "diff #{inspect duration} sober in #{inspect stats.sober_in}" if duration < stats.sober_in do int = stats.sober_in - duration m.replyfun.("désolé, aucune chance! tu seras sobre #{format_minute_duration(int)} après!") else remaining = duration - stats.sober_in if remaining < 30 do m.replyfun.("moins de 30 minutes de sobriété, c'est impossible de boire plus") else loss_per_minute = ((meta.loss_factor/100)/60) remaining_gl = (remaining-30)*loss_per_minute m.replyfun.("marge de boisson: #{inspect remaining} minutes, #{remaining_gl} g/l") end end end {:noreply, state} end def handle_info({:irc, :trigger, "alcoolog", m = %IRC.Message{trigger: %IRC.Trigger{args: [], type: :plus}}}, state) do {:ok, token} = Nola.Token.new({:alcoolog, :index, m.sender.network, m.channel}) url = NolaWeb.Router.Helpers.alcoolog_url(NolaWeb.Endpoint, :index, m.network, NolaWeb.format_chan(m.channel), token) m.replyfun.("-> #{url}") {:noreply, state} end def handle_info({:irc, :trigger, "alcoolog", m = %IRC.Message{trigger: %IRC.Trigger{args: [], type: :bang}}}, state) do url = NolaWeb.Router.Helpers.alcoolog_url(NolaWeb.Endpoint, :index, m.network, NolaWeb.format_chan(m.channel)) m.replyfun.("-> #{url}") {:noreply, state} end def handle_info({:irc, :trigger, "alcool", m = %IRC.Message{trigger: %IRC.Trigger{args: args = [cl, deg], type: :bang}}}, state) do {cl, _} = Util.float_paparse(cl) {deg, _} = Util.float_paparse(deg) points = Alcool.units(cl, deg) meta = get_user_meta(state, m.account.id) k = if meta.sex, do: 0.7, else: 0.6 weight = meta.weight gl = (10*points)/(k*weight) duration = round(gl/((meta.loss_factor/100)/60))+30 sober_in_s = if duration > 0 do duration = Timex.Duration.from_minutes(duration) Timex.Format.Duration.Formatter.lformat(duration, "fr", :humanized) else "" end m.replyfun.("Il y a #{Float.round(points+0.0, 4)} unités d'alcool dans #{cl}cl à #{deg}° (#{Float.round(gl + 0.0, 4)} g/l, #{sober_in_s})") {:noreply, state} end def handle_info({:irc, :trigger, "santai", m = %IRC.Message{trigger: %IRC.Trigger{args: [cl, deg | comment], type: :bang}}}, state) do santai(m, state, cl, deg, comment) {:noreply, state} end @moar [ "{{message.sender.nick}}: la même donc ?", "{{message.sender.nick}}: et voilà la petite sœur !" ] def handle_info({:irc, :trigger, "bis", m = %IRC.Message{trigger: %IRC.Trigger{args: args, type: :bang}}}, state) do handle_info({:irc, :trigger, "moar", m}, state) end def handle_info({:irc, :trigger, "again", m = %IRC.Message{trigger: %IRC.Trigger{args: args, type: :bang}}}, state) do handle_info({:irc, :trigger, "moar", m}, state) end def handle_info({:irc, :trigger, "moar", m = %IRC.Message{trigger: %IRC.Trigger{args: args, type: :bang}}}, state) do case get_statistics_for_nick(state, m.account.id) do {_, obj = {_, _date, _points, _active, cl, deg, _name, comment, _meta}} -> cl = case args do [cls] -> case Util.float_paparse(cls) do {cl, _} -> cl _ -> cl end _ -> cl end moar = @moar |> Enum.shuffle() |> Enum.random() |> Tmpl.render(m) |> m.replyfun.() santai(m, state, cl, deg, comment, auto_set: true) {_, obj = {_, date, points, _last_active, type, descr}} -> case Regex.named_captures(~r/^(?\d+[.]\d+)cl\s+(?\d+[.]\d+)°$/, type) do nil -> m.replyfun.("suce") u -> moar = @moar |> Enum.shuffle() |> Enum.random() |> Tmpl.render(m) |> m.replyfun.() santai(m, state, u["cl"], u["deg"], descr, auto_set: true) end _ -> nil end {:noreply, state} end defp santai(m, state, cl, deg, comment, options \\ []) do comment = cond do comment == [] -> nil is_binary(comment) -> comment comment == nil -> nil true -> Enum.join(comment, " ") end {cl, cl_extra} = case {Util.float_paparse(cl), cl} do {{cl, extra}, _} -> {cl, extra} {:error, "("<>_} -> try do {:ok, result} = Abacus.eval(cl) {result, nil} rescue _ -> {nil, "cl: invalid calc expression"} end {:error, _} -> {nil, "cl: invalid value"} end {deg, comment, auto_set, beer_id} = case Util.float_paparse(deg) do {deg, _} -> {deg, comment, Keyword.get(options, :auto_set, false), nil} :error -> beername = if(comment, do: "#{deg} #{comment}", else: deg) case Untappd.search_beer(beername, limit: 1) do {:ok, %{"response" => %{"beers" => %{"count" => count, "items" => [%{"beer" => beer, "brewery" => brewery} | _]}}}} -> {Map.get(beer, "beer_abv"), "#{Map.get(brewery, "brewery_name")}: #{Map.get(beer, "beer_name")}", true, Map.get(beer, "bid")} _ -> {deg, "could not find beer", false, nil} end end cond do cl == nil -> m.replyfun.(cl_extra) deg == nil -> m.replyfun.(comment) - cl >= 500 || deg >= 100 -> Nola.IRC.TxtPlugin.reply_random(m, "alcoolog.drink_toohuge") - cl == 0 || deg == 0 -> Nola.IRC.TxtPlugin.reply_random(m, "alcoolog.drink_zero") - cl < 0 || deg < 0 -> Nola.IRC.TxtPlugin.reply_random(m, "alcoolog.drink_negative") + cl >= 500 || deg >= 100 -> Nola.Plugins.Txt.reply_random(m, "alcoolog.drink_toohuge") + cl == 0 || deg == 0 -> Nola.Plugins.Txt.reply_random(m, "alcoolog.drink_zero") + cl < 0 || deg < 0 -> Nola.Plugins.Txt.reply_random(m, "alcoolog.drink_negative") true -> points = Alcool.units(cl, deg) now = m.at || DateTime.utc_now() |> DateTime.to_unix(:millisecond) user_meta = get_user_meta(state, m.account.id) name = "#{cl}cl #{deg}°" old_stats = get_full_statistics(state, m.account.id) meta = %{} meta = Map.put(meta, "timestamp", now) meta = Map.put(meta, "weight", user_meta.weight) meta = Map.put(meta, "sex", user_meta.sex) :ok = :dets.insert(state.dets, {m.account.id, now, points, if(old_stats, do: old_stats.active, else: 0), cl, deg, name, comment, meta}) true = :ets.insert(state.ets, {{m.account.id, now}, points, if(old_stats, do: old_stats.active, else: 0),cl, deg, name, comment, meta}) #sante = @santai |> Enum.map(fn(s) -> String.trim(String.upcase(s)) end) |> Enum.shuffle() |> Enum.random() - sante = Nola.IRC.TxtPlugin.random("alcoolog.santai") + sante = Nola.Plugins.Txt.random("alcoolog.santai") k = if user_meta.sex, do: 0.7, else: 0.6 weight = user_meta.weight peak = Float.round((10*points||0.0)/(k*weight), 4) stats = get_full_statistics(state, m.account.id) sober_add = if old_stats && Map.get(old_stats || %{}, :sober_in) do mins = round(stats.sober_in - old_stats.sober_in) " [+#{mins}m]" else "" end nonow = DateTime.utc_now() sober = nonow |> DateTime.add(round(stats.sober_in*60), :second) |> Timex.Timezone.convert("Europe/Paris") at = if nonow.day == sober.day do {:ok, detail} = Timex.Format.DateTime.Formatters.Default.lformat(sober, "aujourd'hui {h24}:{m}", "fr") detail else {:ok, detail} = Timex.Format.DateTime.Formatters.Default.lformat(sober, "{WDfull} {h24}:{m}", "fr") detail end up = if stats.active_drinks > 1 do " " <> Enum.join(for(_ <- 1..stats.active_drinks, do: "▲")) <> "" else "" end since_str = if stats.since && stats.since_min > 180 do "(depuis: #{stats.since_s}) " else "" end msg = fn(nick, extra) -> "#{sante} #{nick} #{extra}#{up} #{format_points(points)} @#{stats.active}g/l [+#{peak} g/l]" <> " (15m: #{stats.active15m}, 30m: #{stats.active30m}, 1h: #{stats.active1h}) #{since_str}(sobriété #{at} (dans #{stats.sober_in_s})#{sober_add}) !" <> " (aujourd'hui #{stats.daily_volumes} points - #{stats.daily_gl} g/l)" end meta = if beer_id do Map.put(meta, "untappd:beer_id", beer_id) else meta end if beer_id do spawn(fn() -> case Untappd.maybe_checkin(m.account, beer_id) do {:ok, body} -> badges = get_in(body, ["badges", "items"]) if badges != [] do badges_s = Enum.map(badges, fn(badge) -> Map.get(badge, "badge_name") end) |> Enum.filter(fn(b) -> b end) |> Enum.intersperse(", ") |> Enum.join("") badge = if(length(badges) > 1, do: "badges", else: "badge") m.replyfun.("\\O/ Unlocked untappd #{badge}: #{badges_s}") end :ok {:error, {:http_error, error}} when is_integer(error) -> m.replyfun.("Checkin to Untappd failed: #{to_string(error)}") {:error, {:http_error, error}} -> m.replyfun.("Checkin to Untappd failed: #{inspect error}") _ -> :error end end) end local_extra = if auto_set do if comment do " #{comment} (#{cl}cl @ #{deg}°)" else "#{cl}cl @ #{deg}°" end else "" end m.replyfun.(msg.(m.sender.nick, local_extra)) notify = IRC.Membership.notify_channels(m.account) -- [{m.network,m.channel}] for {net, chan} <- notify do user = IRC.UserTrack.find_by_account(net, m.account) nick = if(user, do: user.nick, else: m.account.name) extra = " " <> present_type(name, comment) <> "" IRC.Connection.broadcast_message(net, chan, msg.(nick, extra)) end miss = cond do points <= 0.6 -> :small stats.active30m >= 2.9 && stats.active30m < 3 -> :miss3 stats.active30m >= 1.9 && stats.active30m < 2 -> :miss2 stats.active30m >= 0.9 && stats.active30m < 1 -> :miss1 stats.active30m >= 0.45 && stats.active30m < 0.5 -> :miss05 stats.active30m >= 0.20 && stats.active30m < 0.20 -> :miss025 stats.active30m >= 3 && stats.active1h < 3.15 -> :small3 stats.active30m >= 2 && stats.active1h < 2.15 -> :small2 stats.active30m >= 1.5 && stats.active1h < 1.5 -> :small15 stats.active30m >= 1 && stats.active1h < 1.15 -> :small1 stats.active30m >= 0.5 && stats.active1h <= 0.51 -> :small05 stats.active30m >= 0.25 && stats.active30m <= 0.255 -> :small025 true -> nil end if miss do - miss = Nola.IRC.TxtPlugin.random("alcoolog.#{to_string(miss)}") + miss = Nola.Plugins.Txt.random("alcoolog.#{to_string(miss)}") if miss do for {net, chan} <- IRC.Membership.notify_channels(m.account) do user = IRC.UserTrack.find_by_account(net, m.account) nick = if(user, do: user.nick, else: m.account.name) IRC.Connection.broadcast_message(net, chan, "#{nick}: #{miss}") end end end end end def handle_info({:irc, :trigger, "santai", m = %IRC.Message{trigger: %IRC.Trigger{args: _, type: :bang}}}, state) do m.replyfun.("!santai [commentaire]") {:noreply, state} end def get_all_stats() do IRC.Account.all_accounts() |> Enum.map(fn(account) -> {account.id, get_full_statistics(account.id)} end) |> Enum.filter(fn({_nick, status}) -> status && (status.active > 0 || status.active30m > 0) end) |> Enum.sort_by(fn({_, status}) -> status.active end, &>/2) end def get_channel_statistics(account, network, nil) do IRC.Membership.expanded_members_or_friends(account, network, nil) |> Enum.map(fn({account, _, nick}) -> {nick, get_full_statistics(account.id)} end) |> Enum.filter(fn({_nick, status}) -> status && (status.active > 0 || status.active30m > 0) end) |> Enum.sort_by(fn({_, status}) -> status.active end, &>/2) end def get_channel_statistics(_, network, channel), do: get_channel_statistics(network, channel) def get_channel_statistics(network, channel) do IRC.Membership.expanded_members(network, channel) |> Enum.map(fn({account, _, nick}) -> {nick, get_full_statistics(account.id)} end) |> Enum.filter(fn({_nick, status}) -> status && (status.active > 0 || status.active30m > 0) end) |> Enum.sort_by(fn({_, status}) -> status.active end, &>/2) end @spec since() :: %{IRC.Account.id() => DateTime.t()} @doc "Returns the last time the user was at 0 g/l" def since() do :ets.foldr(fn({{acct, timestamp_or_date}, _vol, current, _cl, _deg, _name, _comment, _m}, acc) -> if !Map.get(acc, acct) && current == 0 do date = Util.to_date_time(timestamp_or_date) Map.put(acc, acct, date) else acc end end, %{}, __MODULE__.ETS) end def get_full_statistics(nick) do get_full_statistics(data_state(), nick) end defp get_full_statistics(state, nick) do case get_statistics_for_nick(state, nick) do {count, {_, last_at, last_points, last_active, last_cl, last_deg, last_type, last_descr, _meta}} -> {active, active_drinks} = current_alcohol_level(state, nick) {_, m30} = alcohol_level_rising(state, nick) {rising, m15} = alcohol_level_rising(state, nick, 15) {_, m5} = alcohol_level_rising(state, nick, 5) {_, h1} = alcohol_level_rising(state, nick, 60) trend = if rising do "▲" else "▼" end user_state = cond do active <= 0.0 -> :sober active <= 0.25 -> :low active <= 0.50 -> :legal active <= 1.0 -> :legalhigh active <= 2.5 -> :high active < 3 -> :toohigh true -> :sick end rising_file_key = if rising, do: "_rising", else: "" txt_file = "alcoolog." <> "user_" <> to_string(user_state) <> rising_file_key - user_status = Nola.IRC.TxtPlugin.random(txt_file) + user_status = Nola.Plugins.Txt.random(txt_file) meta = get_user_meta(state, nick) minutes_til_sober = h1/((meta.loss_factor/100)/60) minutes_til_sober = cond do active < 0 -> 0 m15 < 0 -> 15 m30 < 0 -> 30 h1 < 0 -> 60 minutes_til_sober > 0 -> Float.round(minutes_til_sober+60) true -> 0 end duration = Timex.Duration.from_minutes(minutes_til_sober) sober_in_s = if minutes_til_sober > 0 do Timex.Format.Duration.Formatter.lformat(duration, "fr", :humanized) else nil end since = if active > 0 do since() |> Map.get(nick) end since_diff = if since, do: Timex.diff(DateTime.utc_now(), since, :minutes) since_duration = if since, do: Timex.Duration.from_minutes(since_diff) since_s = if since, do: Timex.Format.Duration.Formatter.lformat(since_duration, "fr", :humanized) {total_volumes, total_gl} = user_stats(state, nick) %{active: active, last_at: last_at, last_cl: last_cl, last_deg: last_deg, last_points: last_points, last_type: last_type, last_descr: last_descr, trend_symbol: trend, active5m: m5, active15m: m15, active30m: m30, active1h: h1, rising: rising, active_drinks: active_drinks, user_status: user_status, daily_gl: total_gl, daily_volumes: total_volumes, sober_in: minutes_til_sober, sober_in_s: sober_in_s, since: since, since_min: since_diff, since_s: since_s, } _ -> nil end end def handle_info({:irc, :trigger, "sobre", m = %IRC.Message{trigger: %IRC.Trigger{args: args, type: :dot}}}, state) do nicks = IRC.Membership.expanded_members_or_friends(m.account, m.network, m.channel) |> Enum.map(fn({account, _, nick}) -> {nick, get_full_statistics(state, account.id)} end) |> Enum.filter(fn({_nick, status}) -> status && status.sober_in && status.sober_in > 0 end) |> Enum.sort_by(fn({_, status}) -> status.sober_in end, & Enum.map(fn({nick, stats}) -> now = DateTime.utc_now() sober = now |> DateTime.add(round(stats.sober_in*60), :second) |> Timex.Timezone.convert("Europe/Paris") at = if now.day == sober.day do {:ok, detail} = Timex.Format.DateTime.Formatters.Default.lformat(sober, "aujourd'hui {h24}:{m}", "fr") detail else {:ok, detail} = Timex.Format.DateTime.Formatters.Default.lformat(sober, "{WDfull} {h24}:{m}", "fr") detail end "#{nick} sobre #{at} (dans #{stats.sober_in_s})" end) |> Enum.intersperse(", ") |> Enum.join("") |> (fn(line) -> case line do "" -> "tout le monde est sobre......." line -> line end end).() |> m.replyfun.() {:noreply, state} end def handle_info({:irc, :trigger, "sobre", m = %IRC.Message{trigger: %IRC.Trigger{args: args, type: :bang}}}, state) do account = case args do [nick] -> IRC.Account.find_always_by_nick(m.network, m.channel, nick) [] -> m.account end if account do user = IRC.UserTrack.find_by_account(m.network, account) nick = if(user, do: user.nick, else: account.name) stats = get_full_statistics(state, account.id) if stats && stats.sober_in > 0 do now = DateTime.utc_now() sober = now |> DateTime.add(round(stats.sober_in*60), :second) |> Timex.Timezone.convert("Europe/Paris") at = if now.day == sober.day do {:ok, detail} = Timex.Format.DateTime.Formatters.Default.lformat(sober, "aujourd'hui {h24}:{m}", "fr") detail else {:ok, detail} = Timex.Format.DateTime.Formatters.Default.lformat(sober, "{WDfull} {h24}:{m}", "fr") detail end m.replyfun.("#{nick} sera sobre #{at} (dans #{stats.sober_in_s})!") else m.replyfun.("#{nick} est déjà sobre. aidez le !") end else m.replyfun.("inconnu") end {:noreply, state} end def handle_info({:irc, :trigger, "alcoolisme", m = %IRC.Message{trigger: %IRC.Trigger{args: [], type: :dot}}}, state) do nicks = IRC.Membership.expanded_members_or_friends(m.account, m.network, m.channel) |> Enum.map(fn({account, _, nick}) -> {nick, get_full_statistics(state, account.id)} end) |> Enum.filter(fn({_nick, status}) -> status && (status.active > 0 || status.active30m > 0) end) |> Enum.sort_by(fn({_, status}) -> status.active end, &>/2) |> Enum.map(fn({nick, status}) -> trend_symbol = if status.active_drinks > 1 do Enum.join(for(_ <- 1..status.active_drinks, do: status.trend_symbol)) else status.trend_symbol end since_str = if status.since_min > 180 do "depuis: #{status.since_s} | " else "" end "#{nick} #{status.user_status} #{trend_symbol} #{Float.round(status.active, 4)} g/l [#{since_str}sobre dans: #{status.sober_in_s}]" end) |> Enum.intersperse(", ") |> Enum.join("") msg = if nicks == "" do "wtf?!?! personne n'a bu!" else nicks end m.replyfun.(msg) {:noreply, state} end def handle_info({:irc, :trigger, "alcoolisme", m = %IRC.Message{trigger: %IRC.Trigger{args: [time], type: :dot}}}, state) do time = case time do "semaine" -> 7 string -> case Integer.parse(string) do {time, "j"} -> time {time, "J"} -> time _ -> nil end end if time do aday = time*((24 * 60)*60) now = DateTime.utc_now() before = now |> DateTime.add(-aday, :second) |> DateTime.to_unix(:millisecond) over_time_stats(before, time, m, state) else m.replyfun.(".alcooolisme semaine|Xj") end {:noreply, state} end def user_over_time(account, count) do user_over_time(data_state(), account, count) end def user_over_time(state, account, count) do delay = count*((24 * 60)*60) now = DateTime.utc_now() before = DateTime.utc_now() |> DateTime.shift_zone!("Europe/Paris", Tzdata.TimeZoneDatabase) |> DateTime.add(-delay, :second, Tzdata.TimeZoneDatabase) |> DateTime.to_unix(:millisecond) #[ # {{{:"$1", :"$2"}, :_, :_, :_, :_, :_, :_, :_}, # [{:andalso, {:==, :"$1", :"$1"}, {:<, :"$2", {:const, 3000}}}], [:lol]} #] match = [{{{:"$1", :"$2"}, :_, :_, :_, :_, :_, :_, :_}, [{:andalso, {:>, :"$2", {:const, before}}, {:==, :"$1", {:const, account.id}}}], [:"$_"]} ] :ets.select(state.ets, match) |> Enum.reduce(Map.new, fn({{_, ts}, vol, _, _, _, _, _, _}, acc) -> date = DateTime.from_unix!(ts, :millisecond) |> DateTime.shift_zone!("Europe/Paris", Tzdata.TimeZoneDatabase) date = if date.hour <= 8 do DateTime.add(date, -(60*(60*(date.hour+1))), :second, Tzdata.TimeZoneDatabase) else date end |> DateTime.to_date() Map.put(acc, date, Map.get(acc, date, 0) + vol) end) end def user_over_time_gl(account, count) do state = data_state() meta = get_user_meta(state, account.id) delay = count*((24 * 60)*60) now = DateTime.utc_now() before = DateTime.utc_now() |> DateTime.shift_zone!("Europe/Paris", Tzdata.TimeZoneDatabase) |> DateTime.add(-delay, :second, Tzdata.TimeZoneDatabase) |> DateTime.to_unix(:millisecond) #[ # {{{:"$1", :"$2"}, :_, :_, :_, :_, :_, :_, :_}, # [{:andalso, {:==, :"$1", :"$1"}, {:<, :"$2", {:const, 3000}}}], [:lol]} #] match = [{{{:"$1", :"$2"}, :_, :_, :_, :_, :_, :_, :_}, [{:andalso, {:>, :"$2", {:const, before}}, {:==, :"$1", {:const, account.id}}}], [:"$_"]} ] :ets.select(state.ets, match) |> Enum.reduce(Map.new, fn({{_, ts}, vol, _, _, _, _, _, _}, acc) -> date = DateTime.from_unix!(ts, :millisecond) |> DateTime.shift_zone!("Europe/Paris", Tzdata.TimeZoneDatabase) date = if date.hour <= 8 do DateTime.add(date, -(60*(60*(date.hour+1))), :second, Tzdata.TimeZoneDatabase) else date end |> DateTime.to_date() weight = meta.weight k = if meta.sex, do: 0.7, else: 0.6 gl = (10*vol)/(k*weight) Map.put(acc, date, Map.get(acc, date, 0) + gl) end) end defp over_time_stats(before, j, m, state) do #match = :ets.fun2ms(fn(obj = {{^nick, date}, _, _, _, _, _, _, _}) when date > before -> obj end) match = [{{{:_, :"$1"}, :_, :_, :_, :_, :_, :_, :_}, [{:>, :"$1", {:const, before}}], [:"$_"]} ] # tuple ets: {{nick, date}, volumes, current, nom, commentaire} members = IRC.Membership.members_or_friends(m.account, m.network, m.channel) drinks = :ets.select(state.ets, match) |> Enum.filter(fn({{account, _}, _, _, _, _, _, _, _}) -> Enum.member?(members, account) end) |> Enum.sort_by(fn({{_, ts}, _, _, _, _, _, _, _}) -> ts end, &>/2) top = Enum.reduce(drinks, %{}, fn({{nick, _}, vol, _, _, _, _, _, _}, acc) -> all = Map.get(acc, nick, 0) Map.put(acc, nick, all + vol) end) |> Enum.sort_by(fn({_nick, count}) -> count end, &>/2) |> Enum.map(fn({nick, count}) -> account = IRC.Account.get(nick) user = IRC.UserTrack.find_by_account(m.network, account) nick = if(user, do: user.nick, else: account.name) "#{nick}: #{Float.round(count, 4)}" end) |> Enum.intersperse(", ") m.replyfun.("sur #{j} jours: #{top}") {:noreply, state} end def handle_info({:irc, :trigger, "alcoolisme", m = %IRC.Message{trigger: %IRC.Trigger{args: [], type: :plus}}}, state) do meta = get_user_meta(state, m.account.id) hf = if meta.sex, do: "h", else: "f" m.replyfun.("+alcoolisme sexe: #{hf} poids: #{meta.weight} facteur de perte: #{meta.loss_factor}") {:noreply, state} end def handle_info({:irc, :trigger, "alcoolisme", m = %IRC.Message{trigger: %IRC.Trigger{args: [h, weight | rest], type: :plus}}}, state) do h = case h do "h" -> true "f" -> false _ -> nil end weight = case Util.float_paparse(weight) do {weight, _} -> weight _ -> nil end {factor} = case rest do [factor] -> case Util.float_paparse(factor) do {float, _} -> {float} _ -> {@default_user_meta.loss_factor} end _ -> {@default_user_meta.loss_factor} end if h == nil || weight == nil do m.replyfun.("paramètres invalides") else old_meta = get_user_meta(state, m.account.id) meta = Map.merge(@default_user_meta, %{sex: h, weight: weight, loss_factor: factor}) put_user_meta(state, m.account.id, meta) cond do old_meta.weight < meta.weight -> - Nola.IRC.TxtPlugin.reply_random(m, "alcoolog.fatter") + Nola.Plugins.Txt.reply_random(m, "alcoolog.fatter") old_meta.weight == meta.weight -> m.replyfun.("aucun changement!") true -> - Nola.IRC.TxtPlugin.reply_random(m, "alcoolog.thinner") + Nola.Plugins.Txt.reply_random(m, "alcoolog.thinner") end end {:noreply, state} end def handle_info({:irc, :trigger, "santai", m = %IRC.Message{trigger: %IRC.Trigger{args: args, type: :minus}}}, state) do case get_statistics_for_nick(state, m.account.id) do {_, obj = {_, date, points, _last_active, _cl, _deg, type, descr, _meta}} -> :dets.delete_object(state.dets, obj) :ets.delete(state.ets, {m.account.id, date}) m.replyfun.("supprimé: #{m.sender.nick} #{points} #{type} #{descr}") - Nola.IRC.TxtPlugin.reply_random(m, "alcoolog.delete") + Nola.Plugins.Txt.reply_random(m, "alcoolog.delete") notify = IRC.Membership.notify_channels(m.account) -- [{m.network,m.channel}] for {net, chan} <- notify do user = IRC.UserTrack.find_by_account(net, m.account) nick = if(user, do: user.nick, else: m.account.name) IRC.Connection.broadcast_message(net, chan, "#{nick} -santai #{points} #{type} #{descr}") end {:noreply, state} _ -> {:noreply, state} end end def handle_info({:irc, :trigger, "alcoolisme", m = %IRC.Message{trigger: %IRC.Trigger{args: args, type: :bang}}}, state) do {account, duration} = case args do [nick | rest] -> {IRC.Account.find_always_by_nick(m.network, m.channel, nick), rest} [] -> {m.account, []} end if account do duration = case duration do ["semaine"] -> 7 [j] -> case Integer.parse(j) do {j, "j"} -> j _ -> nil end _ -> nil end user = IRC.UserTrack.find_by_account(m.network, account) nick = if(user, do: user.nick, else: account.name) if duration do if duration > 90 do m.replyfun.("trop gros, ça rentrera pas") else # duration stats stats = user_over_time(state, account, duration) |> Enum.sort_by(fn({k,_v}) -> k end, {:asc, Date}) |> Enum.map(fn({date, count}) -> "#{date.day}: #{Float.round(count, 2)}" end) |> Enum.intersperse(", ") |> Enum.join("") if stats == "" do m.replyfun.("alcoolisme a zéro sur #{duration}j :/") else m.replyfun.("alcoolisme de #{nick}, #{duration} derniers jours: #{stats}") end end else if stats = get_full_statistics(state, account.id) do trend_symbol = if stats.active_drinks > 1 do Enum.join(for(_ <- 1..stats.active_drinks, do: stats.trend_symbol)) else stats.trend_symbol end # TODO: Lookup nick for account_id msg = "#{nick} #{stats.user_status} " <> (if stats.active > 0 || stats.active15m > 0 || stats.active30m > 0 || stats.active1h > 0, do: ": #{trend_symbol} #{Float.round(stats.active, 4)}g/l ", else: "") <> (if stats.active30m > 0 || stats.active1h > 0, do: "(15m: #{stats.active15m}, 30m: #{stats.active30m}, 1h: #{stats.active1h}) ", else: "") <> (if stats.sober_in > 0, do: "— Sobre dans #{stats.sober_in_s} ", else: "") <> (if stats.since && stats.since_min > 180, do: "— Paitai depuis #{stats.since_s} ", else: "") <> "— Dernier verre: #{present_type(stats.last_type, stats.last_descr)} [#{Float.round(stats.last_points+0.0, 4)}] " <> "#{format_duration_from_now(stats.last_at)} " <> (if stats.daily_volumes > 0, do: "— Aujourd'hui: #{stats.daily_volumes} #{stats.daily_gl}g/l", else: "") m.replyfun.(msg) else m.replyfun.("honteux mais #{nick} n'a pas l'air alcoolique du tout. /kick") end end else m.replyfun.("je ne connais pas cet utilisateur") end {:noreply, state} end # Account merge def handle_info({:account_change, old_id, new_id}, state) do spec = [{{:"$1", :_, :_, :_, :_, :_, :_, :_, :_}, [{:==, :"$1", {:const, old_id}}], [:"$_"]}] Util.ets_mutate_select_each(:dets, state.dets, spec, fn(table, obj) -> Logger.debug("alcolog/account_change:: merging #{old_id} -> #{new_id}") rename_object_owner(table, state.ets, obj, old_id, new_id) end) case :dets.lookup(state.meta, {:meta, old_id}) do [{_, meta}] -> :dets.delete(state.meta, {:meta, old_id}) :dets.insert(state.meta, {{:meta, new_id}, meta}) _ -> :ok end {:noreply, state} end def terminate(_, state) do for dets <- [state.dets, state.meta] do :dets.sync(dets) :dets.close(dets) end end defp rename_object_owner(table, ets, object = {old_id, date, volume, current, cl, deg, name, comment, meta}, old_id, new_id) do :dets.delete_object(table, object) :ets.delete(ets, {old_id, date}) :dets.insert(table, {new_id, date, volume, current, cl, deg, name, comment, meta}) :ets.insert(ets, {{new_id, date}, volume, current, cl, deg, name, comment, meta}) end # Account: move from nick to account id def handle_info({:accounts, accounts}, state) do #for x={:account, _, _, _, _} <- accounts, do: handle_info(x, state) #{:noreply, state} mapping = Enum.reduce(accounts, Map.new, fn({:account, _net, _chan, nick, account_id}, acc) -> Map.put(acc, String.downcase(nick), account_id) end) spec = [{{:"$1", :_, :_, :_, :_, :_, :_, :_, :_}, [], [:"$_"]}] Logger.debug("accounts:: mappings #{inspect mapping}") Util.ets_mutate_select_each(:dets, state.dets, spec, fn(table, obj = {nick, _date, _vol, _cur, _cl, _deg, _name, _comment, _meta}) -> #Logger.debug("accounts:: item #{inspect(obj)}") if new_id = Map.get(mapping, nick) do Logger.debug("alcolog/accounts:: merging #{nick} -> #{new_id}") rename_object_owner(table, state.ets, obj, nick, new_id) end end) {:noreply, state} end def handle_info({:account, _net, _chan, nick, account_id}, state) do nick = String.downcase(nick) spec = [{{:"$1", :_, :_, :_, :_, :_, :_, :_, :_}, [{:==, :"$1", {:const, nick}}], [:"$_"]}] Util.ets_mutate_select_each(:dets, state.dets, spec, fn(table, obj) -> Logger.debug("alcoolog/account:: merging #{nick} -> #{account_id}") rename_object_owner(table, state.ets, obj, nick, account_id) end) case :dets.lookup(state.meta, {:meta, nick}) do [{_, meta}] -> :dets.delete(state.meta, {:meta, nick}) :dets.insert(state.meta, {{:meta, account_id}, meta}) _ -> :ok end {:noreply, state} end def handle_info(t, state) do - Logger.debug("AlcoologPlugin: 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}}], [:"$_"]} ] :ets.select(data_state().ets, spec) end defp get_statistics_for_nick(state, account_id) do qvc = :dets.lookup(state.dets, account_id) |> Enum.sort_by(fn({_, ts, _, _, _, _, _, _, _}) -> ts end, & acc + (points||0) end) last = List.last(qvc) || nil {count, last} end def present_type(type, descr) when descr in [nil, ""], do: "#{type}" def present_type(type, description), do: "#{type} (#{description})" def format_points(int) when is_integer(int) and int > 0 do "+#{Integer.to_string(int)}" end def format_points(int) when is_integer(int) and int < 0 do Integer.to_string(int) end def format_points(int) when is_float(int) and int > 0 do "+#{Float.to_string(Float.round(int,4))}" end def format_points(int) when is_float(int) and int < 0 do Float.to_string(Float.round(int,4)) end def format_points(0), do: "0" def format_points(0.0), do: "0" defp format_relative_timestamp(timestamp) do alias Timex.Format.DateTime.Formatters alias Timex.Timezone date = timestamp |> DateTime.from_unix!(:millisecond) |> Timezone.convert("Europe/Paris") {:ok, relative} = Formatters.Relative.relative_to(date, Timex.now("Europe/Paris"), "{relative}", "fr") {:ok, detail} = Formatters.Default.lformat(date, " ({h24}:{m})", "fr") relative <> detail end defp put_user_meta(state, account_id, meta) do :dets.insert(state.meta, {{:meta, account_id}, meta}) :ok end defp get_user_meta(%{meta: meta}, account_id) do case :dets.lookup(meta, {:meta, account_id}) do [{{:meta, _}, meta}] -> Map.merge(@default_user_meta, meta) _ -> @default_user_meta end end # Calcul g/l actuel: # 1. load user meta # 2. foldr ets # for each object # get_current_alcohol # ((object g/l) - 0,15/l/60)* minutes_since_drink # if minutes_since_drink < 10, reduce g/l (?!) # acc + current_alcohol # stop folding when ? # def user_stats(account) do user_stats(data_state(), account.id) end defp user_stats(state = %{ets: ets}, account_id) do meta = get_user_meta(state, account_id) aday = (10 * 60)*60 now = DateTime.utc_now() before = now |> DateTime.add(-aday, :second) |> DateTime.to_unix(:millisecond) #match = :ets.fun2ms(fn(obj = {{^nick, date}, _, _, _, _}) when date > before -> obj end) match = [ {{{:"$1", :"$2"}, :_, :_, :_, :_, :_, :_, :_}, [ {:>, :"$2", {:const, before}}, {:"=:=", {:const, account_id}, :"$1"} ], [:"$_"]} ] # tuple ets: {{nick, date}, volumes, current, nom, commentaire} drinks = :ets.select(ets, match) # {date, single_peak} total_volume = Enum.reduce(drinks, 0.0, fn({{_, date}, volume, _, _, _, _, _, _}, acc) -> acc + volume end) k = if meta.sex, do: 0.7, else: 0.6 weight = meta.weight gl = (10*total_volume)/(k*weight) {Float.round(total_volume + 0.0, 4), Float.round(gl + 0.0, 4)} end defp alcohol_level_rising(state, account_id, minutes \\ 30) do {now, _} = current_alcohol_level(state, account_id) soon_date = DateTime.utc_now |> DateTime.add(minutes*60, :second) {soon, _} = current_alcohol_level(state, account_id, soon_date) soon = cond do soon < 0 -> 0.0 true -> soon end #IO.puts "soon #{soon_date} - #{inspect soon} #{inspect now}" {soon > now, Float.round(soon+0.0, 4)} end defp current_alcohol_level(state = %{ets: ets}, account_id, now \\ nil) do meta = get_user_meta(state, account_id) aday = ((24*7) * 60)*60 now = if now do now else DateTime.utc_now() end before = now |> DateTime.add(-aday, :second) |> DateTime.to_unix(:millisecond) #match = :ets.fun2ms(fn(obj = {{^nick, date}, _, _, _, _}) when date > before -> obj end) match = [ {{{:"$1", :"$2"}, :_, :_, :_, :_, :_, :_, :_}, [ {:>, :"$2", {:const, before}}, {:"=:=", {:const, account_id}, :"$1"} ], [:"$_"]} ] # tuple ets: {{nick, date}, volumes, current, nom, commentaire} drinks = :ets.select(ets, match) |> Enum.sort_by(fn({{_, date}, _, _, _, _, _, _, _}) -> date end, & k = if meta.sex, do: 0.7, else: 0.6 weight = meta.weight peak = (10*volume)/(k*weight) date = case date do ts when is_integer(ts) -> DateTime.from_unix!(ts, :millisecond) date = %NaiveDateTime{} -> DateTime.from_naive!(date, "Etc/UTC") date = %DateTime{} -> date end last_at = last_at || date mins_since = round(DateTime.diff(now, date)/60.0) #IO.puts "Drink: #{inspect({date, volume})} - mins since: #{inspect mins_since} - last drink at #{inspect last_at}" # Apply loss since `last_at` on `all` # all = if last_at do mins_since_last = round(DateTime.diff(date, last_at)/60.0) loss = ((meta.loss_factor/100)/60)*(mins_since_last) #IO.puts "Applying last drink loss: from #{all}, loss of #{inspect loss} (mins since #{inspect mins_since_first})" cond do (all-loss) > 0 -> all - loss true -> 0.0 end else all end #IO.puts "Applying last drink current before drink: #{inspect all}" if mins_since < 30 do per_min = (peak)/30.0 current = (per_min*mins_since) #IO.puts "Applying current drink 30m: from #{peak}, loss of #{inspect per_min}/min (mins since #{inspect mins_since})" {all + current, date, [{date, current} | acc], active_drinks + 1} else {all + peak, date, [{date, peak} | acc], active_drinks} end end) #IO.puts "last drink #{inspect last_drink_at}" mins_since_last = if last_drink_at do round(DateTime.diff(now, last_drink_at)/60.0) else 0 end # Si on a déjà bu y'a déjà moins 15 minutes (big up le binge drinking), on applique plus de perte level = if mins_since_last > 15 do loss = ((meta.loss_factor/100)/60)*(mins_since_last) Float.round(all - loss, 4) else all end #IO.puts "\n LEVEL #{inspect level}\n\n\n\n" cond do level < 0 -> {0.0, 0} true -> {level, active_drinks} end end defp format_duration_from_now(date, with_detail \\ true) do date = if is_integer(date) do date = DateTime.from_unix!(date, :millisecond) |> Timex.Timezone.convert("Europe/Paris") else Util.to_naive_date_time(date) end now = DateTime.utc_now() |> Timex.Timezone.convert("Europe/Paris") {:ok, detail} = Timex.Format.DateTime.Formatters.Default.lformat(date, "({h24}:{m})", "fr") mins_since = round(DateTime.diff(now, date)/60.0) if ago = format_minute_duration(mins_since) do word = if mins_since > 0 do "il y a " else "dans " end word <> ago <> if(with_detail, do: " #{detail}", else: "") else "maintenant #{detail}" end end defp format_minute_duration(minutes) do sober_in_s = if (minutes != 0) do duration = Timex.Duration.from_minutes(minutes) Timex.Format.Duration.Formatter.lformat(duration, "fr", :humanized) else nil end end end diff --git a/lib/nola_plugins/alcoolog_announcer_plugin.ex b/lib/nola_plugins/alcoolog_announcer.ex similarity index 96% rename from lib/nola_plugins/alcoolog_announcer_plugin.ex rename to lib/nola_plugins/alcoolog_announcer.ex index f90dc42..674f19b 100644 --- a/lib/nola_plugins/alcoolog_announcer_plugin.ex +++ b/lib/nola_plugins/alcoolog_announcer.ex @@ -1,272 +1,269 @@ -defmodule Nola.IRC.AlcoologAnnouncerPlugin do +defmodule Nola.Plugins.AlcoologAnnouncer do require Logger @moduledoc """ Annonce changements d'alcoolog """ @channel "#dmz" @seconds 30 @apero [ "C'EST L'HEURE DE L'APÉRRROOOOOOOO !!", "SAAAAANNNNNNNTTTTTTTTAAAAAAAAIIIIIIIIIIIIIIIIIIIIII", "APÉRO ? APÉRO !", {:timed, ["/!\\ à vos tires bouchons…", "… débouchez …", "… BUVEZ! SANTAIII!"]}, "/!\\ ALERTE APÉRO /!\\", "CED !!! VASE DE ROUGE !", "DIDI UN PETIT RICARD™??!", "ALLEZ GUIGUI UNE PETITE BIERE ?", {:timed, ["/!\\ à vos tires bouchons…", "… débouchez …", "… BUVEZ! SANTAIII!"]}, "APPPPAIIIRRREAAUUUUUUUUUUU" ] def irc_doc, do: nil def start_link(), do: GenServer.start_link(__MODULE__, [], name: __MODULE__) def log(account) do dets_filename = (Nola.data_path() <> "/" <> "alcoologlog.dets") |> String.to_charlist {:ok, dets} = :dets.open_file(dets_filename, [{:type,:bag}]) from = ~U[2020-08-23 19:41:40.524154Z] to = ~U[2020-08-24 19:41:40.524154Z] select = [ {{:"$1", :"$2", :_}, [ {:andalso, {:andalso, {:==, :"$1", {:const, account.id}}, {:>, :"$2", {:const, DateTime.to_unix(from)}}}, {:<, :"$2", {:const, DateTime.to_unix(to)}}} ], [:"$_"]} ] res = :dets.select(dets, select) :dets.close(dets) res end def init(_) do {:ok, _} = Registry.register(IRC.PubSub, "account", []) 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 = Nola.IRC.SettingPlugin.declare("alcoolog.alerts", __MODULE__, true, :boolean) - #:ok = Nola.IRC.SettingPlugin.declare("alcoolog.aperoalert", __MODULE__, true, :boolean) - # {:ok, {stats, now(), dets, ets}}#, {:continue, :traverse}} end def handle_continue(:traverse, state = {_, _, dets, ets}) do traverse_fun = fn(obj, dets) -> case obj do {nick, %DateTime{} = dt, active} -> :dets.delete_object(dets, obj) :dets.insert(dets, {nick, DateTime.to_unix(dt), active}) IO.puts("ok #{inspect obj}") dets {nick, ts, value} -> :ets.insert(ets, { {nick, ts}, value }) dets end end :dets.foldl(traverse_fun, dets, dets) :dets.sync(dets) IO.puts("alcoolog announcer fixed") {:noreply, state} end def alcohol_reached(old, new, level) do (old.active < level && new.active >= level) && (new.active5m >= level) end def alcohol_below(old, new, level) do (old.active > level && new.active <= level) && (new.active5m <= level) end def handle_info(:stats, {old_stats, old_now, dets, ets}) do stats = get_stats() now = now() if old_now.hour < 18 && now.hour == 18 do apero = Enum.shuffle(@apero) |> Enum.random() case apero do {:timed, list} -> spawn(fn() -> for line <- list do IRC.Connection.broadcast_message("evolu.net", "#dmz", line) :timer.sleep(:timer.seconds(5)) end end) string -> IRC.Connection.broadcast_message("evolu.net", "#dmz", string) end end #IO.puts "newstats #{inspect stats}" events = for {acct, old} <- old_stats do new = Map.get(stats, acct, nil) #IO.puts "#{acct}: #{inspect(old)} -> #{inspect(new)}" now = DateTime.to_unix(DateTime.utc_now()) if new && new[:active] do :dets.insert(dets, {acct, now, new[:active]}) :ets.insert(ets, {{acct, now}, new[:active]}) else :dets.insert(dets, {acct, now, 0.0}) :ets.insert(ets, {{acct, now}, new[:active]}) end event = cond do old == nil -> nil (old.active > 0) && (new == nil) -> :sober new == nil -> nil alcohol_reached(old, new, 0.5) -> :stopconduire alcohol_reached(old, new, 1.0) -> :g1 alcohol_reached(old, new, 2.0) -> :g2 alcohol_reached(old, new, 3.0) -> :g3 alcohol_reached(old, new, 4.0) -> :g4 alcohol_reached(old, new, 5.0) -> :g5 alcohol_reached(old, new, 6.0) -> :g6 alcohol_reached(old, new, 7.0) -> :g7 alcohol_reached(old, new, 10.0) -> :g10 alcohol_reached(old, new, 13.74) -> :record alcohol_below(old, new, 0.5) -> :conduire alcohol_below(old, new, 1.0) -> :fini1g alcohol_below(old, new, 2.0) -> :fini2g alcohol_below(old, new, 3.0) -> :fini3g alcohol_below(old, new, 4.0) -> :fini4g (old.rising) && (!new.rising) -> :lowering true -> nil end {acct, event} end for {acct, event} <- events do message = case event do :g1 -> [ "[vigicuite jaune] LE GRAMME! LE GRAMME O/", "début de vigicuite jaune ! LE GRAMME ! \\O/", "waiiiiiiii le grammmeee", "bourraiiiiiiiiiiide 1 grammeeeeeeeeeee", ] :g2 -> [ "[vigicuite orange] \\o_YAY 2 GRAMMES ! _o/", "PAITAIIIIIIIIII DEUX GRAMMEESSSSSSSSSSSSSSSSS", "bourrrrrraiiiiiiiiiiiiiiiide 2 grammeeeeeeeeeees", ] :g3 -> [ "et un ! et deux ! et TROIS GRAMMEEESSSSSSS", "[vigicuite rouge] _o/ BOURRAIIDDDEEEE 3 GRAMMESSSSSSSSS \\o/ \\o/" ] :g4 -> [ "[vigicuite écarlate] et un, et deux, et trois, ET QUATRES GRAMMEESSSSSSSSSSSSSSSSSSSssssss" ] :g5 -> "[vigicuite écarlate+] PUTAIN 5 GRAMMES !" :g6 -> "[vigicuite écarlate++] 6 grammes ? Vous pouvez joindre Alcool info service au 0 980 980 930" :g7 -> "[vigicuite c'est la merde] 7 grammes. Le SAMU, c'est le 15." :g10 -> "BORDLE 10 GRAMMES" :record -> "RECORD DU MONDE BATTU ! >13.74g/l !!" :fini1g -> [ "fin d'alerte vigicuite jaune, passage en vert (<1g/l)", "/!\\ alerte moins de 1g/l /!\\" ] :fini2g -> [ "t'as moins de 2 g/l, faut se reprendre là [vigicuite jaune]" ] :fini3g -> [ "fin d'alerte vigicuite rouge, passage en orange (<3g/l)" ] :fini4g -> [ "fin d'alerte vigicuite écarlate, passage en rouge (<4g/l)" ] :lowering -> [ "attention ça baisse!", "tu vas quand même pas en rester là ?", "IL FAUT CONTINUER À BOIRE !", "t'abandonnes déjà ?", "!santai ?", "faut pas en rester là", "il faut se resservir", "coucou faut reboire", "encore un petit verre ?", "abwaaaaaaaaaaaaarrrrrrrrrrrrrr", "taux d'alcoolémie en chute ! agissez avant qu'il soit trop tard!", "ÇA BAISSE !!" ] :stopconduire -> [ "0.5g! bientot le gramme?", "tu peux plus prendre la route... mais... tu peux prendre la route du gramme! !santai !", "fini la conduite!", "0.5! continues faut pas en rester là!", "beau début, continues !", "ça monte! 0.5g/l!" ] :conduire -> [ "tu peux conduire, ou recommencer à boire! niveau critique!", "!santai ?", "tu peux reprendre la route, ou reprendre la route du gramme..", "attention, niveau critique!", "il faut boire !!", "trop de sang dans ton alcool, c'est mauvais pour la santé", "faut pas en rester là !", ] :sober -> [ "sobre…", "/!\\ alerte sobriété /!\\", "... sobre?!?!", "sobre :(", "attention, t'es sobre :/", "danger, alcoolémie à 0.0 !", "sobre! c'était bien on recommence quand ?", "sobre ? Faut recommencer...", "T'es sobre. Ne te laisses pas abattre- ton caviste peut aider.", "Vous êtes sobre ? Ceci n'est pas une fatalité - resservez vous vite !" ] _ -> nil end message = case message do m when is_binary(m) -> m m when is_list(m) -> m |> Enum.shuffle() |> Enum.random() nil -> nil end if message do #IO.puts("#{acct}: #{message}") account = IRC.Account.get(acct) for {net, chan} <- IRC.Membership.notify_channels(account) do user = IRC.UserTrack.find_by_account(net, account) nick = if(user, do: user.nick, else: account.name) IRC.Connection.broadcast_message(net, chan, "#{nick}: #{message}") end end end timer() #IO.puts "tick stats ok" {:noreply, {stats,now,dets,ets}} end def handle_info(_, state) do {:noreply, state} end defp now() do DateTime.utc_now() |> Timex.Timezone.convert("Europe/Paris") end defp get_stats() do - Enum.into(Nola.IRC.AlcoologPlugin.get_all_stats(), %{}) + 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/nola_plugins/base_plugin.ex b/lib/nola_plugins/base.ex similarity index 83% rename from lib/nola_plugins/base_plugin.ex rename to lib/nola_plugins/base.ex index a2b9ffb..b7b5e16 100644 --- a/lib/nola_plugins/base_plugin.ex +++ b/lib/nola_plugins/base.ex @@ -1,131 +1,132 @@ -defmodule Nola.IRC.BasePlugin do +defmodule Nola.Plugins.Base do def irc_doc, do: nil def start_link() do GenServer.start_link(__MODULE__, [], name: __MODULE__) end def init([]) do regopts = [plugin: __MODULE__] {:ok, _} = Registry.register(IRC.PubSub, "trigger:version", regopts) {:ok, _} = Registry.register(IRC.PubSub, "trigger:help", regopts) {:ok, _} = Registry.register(IRC.PubSub, "trigger:liquidrender", regopts) {:ok, _} = Registry.register(IRC.PubSub, "trigger:plugin", regopts) {:ok, _} = Registry.register(IRC.PubSub, "trigger:plugins", regopts) {:ok, nil} end def handle_info({:irc, :trigger, "plugins", msg = %{trigger: %{type: :bang, args: []}}}, _) do - enabled_string = IRC.Plugin.enabled() + enabled_string = Nola.Plugins.enabled() |> Enum.map(fn(mod) -> mod |> Macro.underscore() |> String.split("/", parts: :infinity) |> List.last() - |> String.replace("_plugin", "") |> Enum.sort() end) |> Enum.join(", ") msg.replyfun.("Enabled plugins: #{enabled_string}") {:noreply, nil} end def handle_info({:irc, :trigger, "plugin", %{trigger: %{type: :query, args: [plugin]}} = m}, _) do - module = Module.concat([Nola.IRC, Macro.camelize(plugin<>"_plugin")]) + 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 IRC.Plugin.get(module) do + 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.IRC, Macro.camelize(plugin<>"_plugin")]) + module = Module.concat([Nola.Plugins, Macro.camelize(plugin)]) with true <- Code.ensure_loaded?(module), - IRC.Plugin.switch(module, true), - {:ok, pid} <- IRC.Plugin.start(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.IRC, Macro.camelize(plugin<>"_plugin")]) + 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} <- IRC.Plugin.start(module) + {: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.IRC, Macro.camelize(plugin<>"_plugin")]) + module = Module.concat([Nola.Plugins, Macro.camelize(plugin)]) with true <- Code.ensure_loaded?(module), pid when is_pid(pid) <- GenServer.whereis(module), :ok <- GenServer.stop(pid) do IRC.Plugin.switch(module, false) m.replyfun.("stopped: #{inspect(pid)}") else false -> m.replyfun.("not loaded") nil -> m.replyfun.("not active") end {:noreply, nil} end def handle_info({:irc, :trigger, "liquidrender", m = %{trigger: %{args: args}}}, _) do template = Enum.join(args, " ") m.replyfun.(Tmpl.render(template, m)) {:noreply, nil} end def handle_info({:irc, :trigger, "help", m = %{trigger: %{type: :bang}}}, _) do url = NolaWeb.Router.Helpers.irc_url(NolaWeb.Endpoint, :index, m.network, NolaWeb.format_chan(m.channel)) m.replyfun.("-> #{url}") {:noreply, nil} end def handle_info({:irc, :trigger, "version", message = %{trigger: %{type: :bang}}}, _) do {:ok, vsn} = :application.get_key(:nola, :vsn) ver = List.to_string(vsn) url = NolaWeb.Router.Helpers.irc_url(NolaWeb.Endpoint, :index) elixir_ver = Application.started_applications() |> List.keyfind(:elixir, 0) |> elem(2) |> to_string() otp_ver = :erlang.system_info(:system_version) |> to_string() |> String.trim() system = :erlang.system_info(:system_architecture) |> to_string() + brand = Nola.brand(:name) + owner = "#{Nola.brand(:owner)} <#{Nola.brand(:owner_email)}>" message.replyfun.([ - <<"🤖 I am a robot running", 2, "beautte, version #{ver}", 2, " — source: #{Nola.source_url()}">>, + <<"🤖 I am a robot running", 2, "#{brand}, version #{ver}", 2, " — source: #{Nola.source_url()}">>, "🦾 Elixir #{elixir_ver} #{otp_ver} on #{system}", - "👷‍♀️ Owner: href ", + "👷‍♀️ Owner: h#{owner}", "🌍 Web interface: #{url}" ]) {:noreply, nil} end def handle_info(msg, _) do {:noreply, nil} end end diff --git a/lib/nola_plugins/bourosama_plugin.ex b/lib/nola_plugins/boursorama.ex similarity index 97% rename from lib/nola_plugins/bourosama_plugin.ex rename to lib/nola_plugins/boursorama.ex index dd05144..f872584 100644 --- a/lib/nola_plugins/bourosama_plugin.ex +++ b/lib/nola_plugins/boursorama.ex @@ -1,58 +1,58 @@ -defmodule Nola.IRC.BoursoramaPlugin do +defmodule Nola.Plugins.Boursorama do def irc_doc() do """ # bourses Un peu comme [finance](#finance), mais en un peu mieux, et un peu moins bien. Source: [boursorama.com](https://boursorama.com) * **!caca40** affiche l'état du cac40 """ end def start_link() do GenServer.start_link(__MODULE__, [], name: __MODULE__) end @cac40_url "https://www.boursorama.com/bourse/actions/palmares/france/?france_filter%5Bmarket%5D=1rPCAC&france_filter%5Bsector%5D=&france_filter%5Bvariation%5D=50002&france_filter%5Bperiod%5D=1&france_filter%5Bfilter%5D=" def init(_) do regopts = [plugin: __MODULE__] {:ok, _} = Registry.register(IRC.PubSub, "trigger:cac40", regopts) {:ok, _} = Registry.register(IRC.PubSub, "trigger:caca40", regopts) {:ok, nil} end def handle_info({:irc, :trigger, cac, m = %IRC.Message{trigger: %IRC.Trigger{type: :bang}}}, state) when cac in ["cac40", "caca40"] do case HTTPoison.get(@cac40_url, [], []) do {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> html = Floki.parse(body) board = Floki.find(body, "div.c-tradingboard") cac40 = Floki.find(board, ".c-tradingboard__main > .c-tradingboard__infos") instrument = Floki.find(cac40, ".c-instrument") last = Floki.find(instrument, "span[data-ist-last]") |> Floki.text() |> String.replace(" ", "") variation = Floki.find(instrument, "span[data-ist-variation]") |> Floki.text() sign = case variation do "-"<>_ -> "▼" "+" -> "▲" _ -> "" end m.replyfun.("caca40: #{sign} #{variation} #{last}") {:error, %HTTPoison.Response{status_code: code}} -> m.replyfun.("caca40: erreur http #{code}") _ -> m.replyfun.("caca40: erreur http") end end end diff --git a/lib/nola_plugins/buffer_plugin.ex b/lib/nola_plugins/buffer.ex similarity index 97% rename from lib/nola_plugins/buffer_plugin.ex rename to lib/nola_plugins/buffer.ex index eece34e..67aea35 100644 --- a/lib/nola_plugins/buffer_plugin.ex +++ b/lib/nola_plugins/buffer.ex @@ -1,44 +1,44 @@ -defmodule Nola.IRC.BufferPlugin do +defmodule Nola.Plugins.Buffer do @table __MODULE__.ETS def irc_doc, do: nil def table(), do: @table def select_buffer(network, channel, limit \\ 50) do import Ex2ms spec = fun do {{n, c, _}, m} when n == ^network and (c == ^channel or is_nil(c)) -> m end :ets.select(@table, spec, limit) end def start_link() do GenServer.start_link(__MODULE__, [], name: __MODULE__) end def init(_) do for e <- ~w(messages triggers events outputs) do {:ok, _} = Registry.register(IRC.PubSub, e, plugin: __MODULE__) end {:ok, :ets.new(@table, [:named_table, :ordered_set, :protected])} end def handle_info({:irc, :trigger, _, message}, ets), do: handle_message(message, ets) def handle_info({:irc, :text, message}, ets), do: handle_message(message, ets) def handle_info({:irc, :event, event}, ets), do: handle_message(event, ets) defp handle_message(message = %{network: network}, ets) do key = {network, Map.get(message, :channel), ts(message.at)} :ets.insert(ets, {key, message}) {:noreply, ets} end defp ts(nil), do: ts(NaiveDateTime.utc_now()) defp ts(naive = %NaiveDateTime{}) do ts = naive |> DateTime.from_naive!("Etc/UTC") |> DateTime.to_unix() -ts end end diff --git a/lib/nola_plugins/calc_plugin.ex b/lib/nola_plugins/calc.ex similarity index 96% rename from lib/nola_plugins/calc_plugin.ex rename to lib/nola_plugins/calc.ex index 264370c..91fbbae 100644 --- a/lib/nola_plugins/calc_plugin.ex +++ b/lib/nola_plugins/calc.ex @@ -1,37 +1,37 @@ -defmodule Nola.IRC.CalcPlugin do +defmodule Nola.Plugins.Calc do @moduledoc """ # calc * **!calc ``**: évalue l'expression mathématique ``. """ def irc_doc, do: @moduledoc def start_link() do GenServer.start_link(__MODULE__, [], name: __MODULE__) end def init(_) do {:ok, _} = Registry.register(IRC.PubSub, "trigger:calc", [plugin: __MODULE__]) {:ok, nil} end def handle_info({:irc, :trigger, "calc", message = %IRC.Message{trigger: %IRC.Trigger{type: :bang, args: expr_list}}}, state) do expr = Enum.join(expr_list, " ") result = try do case Abacus.eval(expr) do {:ok, result} -> result error -> inspect(error) end rescue error -> if(error[:message], do: "#{error.message}", else: "erreur") end message.replyfun.("#{message.sender.nick}: #{expr} = #{result}") {:noreply, state} end def handle_info(msg, state) do {:noreply, state} end end diff --git a/lib/nola_plugins/coronavirus_plugin.ex b/lib/nola_plugins/coronavirus.ex similarity index 99% rename from lib/nola_plugins/coronavirus_plugin.ex rename to lib/nola_plugins/coronavirus.ex index d04d8f9..624bae7 100644 --- a/lib/nola_plugins/coronavirus_plugin.ex +++ b/lib/nola_plugins/coronavirus.ex @@ -1,172 +1,172 @@ -defmodule Nola.IRC.CoronavirusPlugin do +defmodule Nola.Plugins.Coronavirus do require Logger NimbleCSV.define(CovidCsv, separator: ",", escape: "\"") @moduledoc """ # Corona Virus Données de [Johns Hopkins University](https://github.com/CSSEGISandData/COVID-19) et mises à jour a peu près tous les jours. * `!coronavirus [France | Country]`: :-) * `!coronavirus`: top 10 confirmés et non guéris * `!coronavirus confirmés`: top 10 confirmés * `!coronavirus morts`: top 10 morts * `!coronavirus soignés`: top 10 soignés """ def irc_doc, do: @moduledoc def start_link() do GenServer.start_link(__MODULE__, [], name: __MODULE__) end def init(_) do {:ok, _} = Registry.register(IRC.PubSub, "trigger:coronavirus", [plugin: __MODULE__]) {:ok, nil, {:continue, :init}} :ignore end def handle_continue(:init, _) do date = Date.add(Date.utc_today(), -2) {data, _} = fetch_data(%{}, date) {data, next} = fetch_data(data) :timer.send_after(next, :update) {:noreply, %{data: data}} end def handle_info(:update, state) do {data, next} = fetch_data(state.data) :timer.send_after(next, :update) {:noreply, %{data: data}} end def handle_info({:irc, :trigger, "coronavirus", m = %IRC.Message{trigger: %{type: :bang, args: args}}}, state) when args in [ [], ["morts"], ["confirmés"], ["soignés"], ["malades"], ["n"], ["nmorts"], ["nsoignés"], ["nconfirmés"]] do {field, name} = case args do ["confirmés"] -> {:confirmed, "confirmés"} ["morts"] -> {:deaths, "morts"} ["soignés"] -> {:recovered, "soignés"} ["nmorts"] -> {:new_deaths, "nouveaux morts"} ["nconfirmés"] -> {:new_confirmed, "nouveaux confirmés"} ["n"] -> {:new_current, "nouveaux malades"} ["nsoignés"] -> {:new_recovered, "nouveaux soignés"} _ -> {:current, "malades"} end IO.puts("FIELD #{inspect field}") field_evol = String.to_atom("new_#{field}") sorted = state.data |> Enum.filter(fn({_, %{region: region}}) -> region == true end) |> Enum.map(fn({location, data}) -> {location, Map.get(data, field, 0), Map.get(data, field_evol, 0)} end) |> Enum.sort_by(fn({_,count,_}) -> count end, &>=/2) |> Enum.take(10) |> Enum.with_index() |> Enum.map(fn({{location, count, evol}, index}) -> ev = if String.starts_with?(name, "nouveaux") do "" else " (#{Util.plusminus(evol)})" end "##{index+1}: #{location} #{count}#{ev}" end) |> Enum.intersperse(" - ") |> Enum.join() m.replyfun.("CORONAVIRUS TOP10 #{name}: " <> sorted) {:noreply, state} end def handle_info({:irc, :trigger, "coronavirus", m = %IRC.Message{trigger: %{type: :bang, args: location}}}, state) do location = Enum.join(location, " ") |> String.downcase() if data = Map.get(state.data, location) do m.replyfun.("coronavirus: #{location}: " <> "#{data.current} malades (#{Util.plusminus(data.new_current)}), " <> "#{data.confirmed} confirmés (#{Util.plusminus(data.new_confirmed)}), " <> "#{data.deaths} morts (#{Util.plusminus(data.new_deaths)}), " <> "#{data.recovered} soignés (#{Util.plusminus(data.new_recovered)}) (@ #{data.update})") end {:noreply, state} end def handle_info({:irc, :trigger, "coronavirus", m = %IRC.Message{trigger: %{type: :query, args: location}}}, state) do m.replyfun.("https://github.com/CSSEGISandData/COVID-19") {:noreply, state} end # 1. Try to fetch data for today # 2. Fetch yesterday if no results defp fetch_data(current_data, date \\ nil) do now = Date.utc_today() url = fn(date) -> "https://github.com/CSSEGISandData/COVID-19/raw/master/csse_covid_19_data/csse_covid_19_daily_reports/#{date}.csv" end request_date = date || now Logger.debug("Coronavirus check date: #{inspect request_date}") {:ok, date_s} = Timex.format({request_date.year, request_date.month, request_date.day}, "%m-%d-%Y", :strftime) cur_url = url.(date_s) Logger.debug "Fetching URL #{cur_url}" case HTTPoison.get(cur_url, [], follow_redirect: true) do {:ok, %HTTPoison.Response{status_code: 200, body: csv}} -> # Parse CSV update data data = csv |> CovidCsv.parse_string() |> Enum.reduce(%{}, fn(line, acc) -> case line do # FIPS,Admin2,Province_State,Country_Region,Last_Update,Lat,Long_,Confirmed,Deaths,Recovered,Active,Combined_Key #0FIPS,Admin2,Province_State,Country_Region,Last_Update,Lat,Long_,Confirmed,Deaths,Recovered,Active,Combined_Key,Incidence_Rate,Case-Fatality_Ratio [_, _, state, region, update, _lat, _lng, confirmed, deaths, recovered, _active, _combined_key, _incidence_rate, _fatality_ratio] -> state = String.downcase(state) region = String.downcase(region) confirmed = String.to_integer(confirmed) deaths = String.to_integer(deaths) recovered = String.to_integer(recovered) current = (confirmed - recovered) - deaths entry = %{update: update, confirmed: confirmed, deaths: deaths, recovered: recovered, current: current, region: region} region_entry = Map.get(acc, region, %{update: nil, confirmed: 0, deaths: 0, recovered: 0, current: 0}) region_entry = %{ update: region_entry.update || update, confirmed: region_entry.confirmed + confirmed, deaths: region_entry.deaths + deaths, current: region_entry.current + current, recovered: region_entry.recovered + recovered, region: true } changes = if old = Map.get(current_data, region) do %{ new_confirmed: region_entry.confirmed - old.confirmed, new_current: region_entry.current - old.current, new_deaths: region_entry.deaths - old.deaths, new_recovered: region_entry.recovered - old.recovered, } else %{new_confirmed: 0, new_current: 0, new_deaths: 0, new_recovered: 0} end region_entry = Map.merge(region_entry, changes) acc = Map.put(acc, region, region_entry) acc = if state && state != "" do Map.put(acc, state, entry) else acc end other -> Logger.info("Coronavirus line failed: #{inspect line}") acc end end) Logger.info "Updated coronavirus database" {data, :timer.minutes(60)} {:ok, %HTTPoison.Response{status_code: 404}} -> Logger.debug "Corona 404 #{cur_url}" date = Date.add(date || now, -1) fetch_data(current_data, date) other -> Logger.error "Coronavirus: Update failed #{inspect other}" {current_data, :timer.minutes(5)} end end end diff --git a/lib/nola_plugins/correction_plugin.ex b/lib/nola_plugins/correction.ex similarity index 97% rename from lib/nola_plugins/correction_plugin.ex rename to lib/nola_plugins/correction.ex index 5f9b278..03968c8 100644 --- a/lib/nola_plugins/correction_plugin.ex +++ b/lib/nola_plugins/correction.ex @@ -1,59 +1,59 @@ -defmodule Nola.IRC.CorrectionPlugin do +defmodule Nola.Plugins.Correction do @moduledoc """ # correction * `s/pattern/replace` replace `pattern` by `replace` in the last matching message """ def irc_doc, do: @moduledoc def start_link() do GenServer.start_link(__MODULE__, [], name: __MODULE__) end def init(_) do {:ok, _} = Registry.register(IRC.PubSub, "messages", [plugin: __MODULE__]) {:ok, _} = Registry.register(IRC.PubSub, "triggers", [plugin: __MODULE__]) {:ok, %{}} end # Trigger fallback def handle_info({:irc, :trigger, _, m = %IRC.Message{}}, state) do {:noreply, correction(m, state)} end def handle_info({:irc, :text, m = %IRC.Message{}}, state) do {:noreply, correction(m, state)} end def correction(m, state) do history = Map.get(state, key(m), []) if String.starts_with?(m.text, "s/") do case String.split(m.text, "/") do ["s", match, replace | _] -> case Regex.compile(match) do {:ok, reg} -> repl = Enum.find(history, fn(m) -> Regex.match?(reg, m.text) end) if repl do new_text = String.replace(repl.text, reg, replace) m.replyfun.("correction: <#{repl.sender.nick}> #{new_text}") end _ -> m.replyfun.("correction: invalid regex") end _ -> m.replyfun.("correction: invalid regex format") end state else history = if length(history) > 100 do {_, history} = List.pop_at(history, 99) [m | history] else [m | history] end Map.put(state, key(m), history) end end defp key(%{network: net, channel: chan}), do: "#{net}/#{chan}" end diff --git a/lib/nola_plugins/dice_plugin.ex b/lib/nola_plugins/dice.ex similarity index 98% rename from lib/nola_plugins/dice_plugin.ex rename to lib/nola_plugins/dice.ex index b5e7649..2c1515b 100644 --- a/lib/nola_plugins/dice_plugin.ex +++ b/lib/nola_plugins/dice.ex @@ -1,66 +1,66 @@ -defmodule Nola.IRC.DicePlugin do +defmodule Nola.Plugins.Dice do require Logger @moduledoc """ # dice * **!dice `[1 | lancés]` `[6 | faces]`**: lance une ou plusieurs fois un dé de 6 ou autre faces """ @default_faces 6 @default_rolls 1 @max_rolls 50 def short_irc_doc, do: "!dice (jeter un dé)" defstruct client: nil, dets: nil def irc_doc, do: @moduledoc def start_link() do GenServer.start_link(__MODULE__, [], name: __MODULE__) end def init([]) do {:ok, _} = Registry.register(IRC.PubSub, "trigger:dice", [plugin: __MODULE__]) {:ok, %__MODULE__{}} end def handle_info({:irc, :trigger, _, message = %{trigger: %{type: :bang, args: args}}}, state) do to_integer = fn(string, default) -> case Integer.parse(string) do {int, _} -> int _ -> default end end {rolls, faces} = case args do [] -> {@default_rolls, @default_faces} [faces, rolls] -> {to_integer.(rolls, @default_rolls), to_integer.(faces, @default_faces)} [rolls] -> {to_integer.(rolls, @default_rolls), @default_faces} end roll(state, message, faces, rolls) {:noreply, state} end def handle_info(info, state) do {:noreply, state} end defp roll(state, message, faces, 1) when faces > 0 do random = :crypto.rand_uniform(1, faces+1) message.replyfun.("#{message.sender.nick} dice: #{random}") end defp roll(state, message, faces, rolls) when faces > 0 and rolls > 0 and rolls <= @max_rolls do {results, acc} = Enum.map_reduce(Range.new(1, rolls), 0, fn(i, acc) -> random = :crypto.rand_uniform(1, faces+1) {random, acc + random} end) results = Enum.join(results, "; ") message.replyfun.("#{message.sender.nick} dice: [#{acc}] #{results}") end defp roll(_, _, _, _, _), do: nil end diff --git a/lib/nola_plugins/finance_plugin.ex b/lib/nola_plugins/finance.ex similarity index 99% rename from lib/nola_plugins/finance_plugin.ex rename to lib/nola_plugins/finance.ex index 16d06ee..d6f890a 100644 --- a/lib/nola_plugins/finance_plugin.ex +++ b/lib/nola_plugins/finance.ex @@ -1,190 +1,190 @@ -defmodule Nola.IRC.FinancePlugin do +defmodule Nola.Plugins.Finance do require Logger @moduledoc """ # finance Données de [alphavantage.co](https://alphavantage.co). ## forex / monnaies / crypto-monnaies * **`!forex [MONNAIE2]`**: taux de change entre deux monnaies. * **`!forex `**: converti `montant` entre deux monnaies * **`?currency `**: recherche une monnaie Utiliser le symbole des monnaies (EUR, USD, ...). ## bourses * **`!stocks `** * **`?stocks `** cherche un symbole Pour les symboles non-US, ajouter le suffixe (RNO Paris: RNO.PAR). """ @currency_list "http://www.alphavantage.co/physical_currency_list/" @crypto_list "http://www.alphavantage.co/digital_currency_list/" HTTPoison.start() load_currency = fn(url) -> resp = HTTPoison.get!(url) resp.body |> String.strip() |> String.split("\n") |> Enum.drop(1) |> Enum.map(fn(line) -> [symbol, name] = line |> String.strip() |> String.split(",", parts: 2) {symbol, name} end) |> Enum.into(Map.new) end fiat = load_currency.(@currency_list) crypto = load_currency.(@crypto_list) @currencies Map.merge(fiat, crypto) def irc_doc, do: @moduledoc def start_link() do GenServer.start_link(__MODULE__, [], name: __MODULE__) end def init([]) do regopts = [plugin: __MODULE__] {:ok, _} = Registry.register(IRC.PubSub, "trigger:forex", regopts) {:ok, _} = Registry.register(IRC.PubSub, "trigger:currency", regopts) {:ok, _} = Registry.register(IRC.PubSub, "trigger:stocks", regopts) {:ok, nil} end def handle_info({:irc, :trigger, "stocks", message = %{trigger: %{type: :query, args: args = search}}}, state) do search = Enum.join(search, "%20") url = "https://www.alphavantage.co/query?function=SYMBOL_SEARCH&keywords=#{search}&apikey=#{api_key()}" case HTTPoison.get(url) do {:ok, %HTTPoison.Response{status_code: 200, body: data}} -> data = Poison.decode!(data) if error = Map.get(data, "Error Message") do Logger.error("AlphaVantage API invalid request #{url} - #{inspect error}") message.replyfun.("stocks: requête invalide") else items = for item <- Map.get(data, "bestMatches") do symbol = Map.get(item, "1. symbol") name = Map.get(item, "2. name") type = Map.get(item, "3. type") region = Map.get(item, "4. region") currency = Map.get(item, "8. currency") "#{symbol}: #{name} (#{region}; #{currency}; #{type})" end |> Enum.join(", ") items = if items == "" do "no results!" else items end message.replyfun.(items) end {:ok, resp = %HTTPoison.Response{status_code: code}} -> Logger.error "AlphaVantage API error: #{code} #{url} - #{inspect resp}" message.replyfun.("forex: erreur (api #{code})") {:error, %HTTPoison.Error{reason: error}} -> Logger.error "AlphaVantage HTTP error: #{inspect error}" message.replyfun.("forex: erreur (http #{inspect error})") end {:noreply, state} end def handle_info({:irc, :trigger, "stocks", message = %{trigger: %{type: :bang, args: args = [symbol]}}}, state) do url = "https://www.alphavantage.co/query?function=GLOBAL_QUOTE&symbol=#{symbol}&apikey=#{api_key()}" case HTTPoison.get(url) do {:ok, %HTTPoison.Response{status_code: 200, body: data}} -> data = Poison.decode!(data) if error = Map.get(data, "Error Message") do Logger.error("AlphaVantage API invalid request #{url} - #{inspect error}") message.replyfun.("stocks: requête invalide") else data = Map.get(data, "Global Quote") open = Map.get(data, "02. open") high = Map.get(data, "03. high") low = Map.get(data, "04. low") price = Map.get(data, "05. price") volume = Map.get(data, "06. volume") prev_close = Map.get(data, "08. previous close") change = Map.get(data, "09. change") change_pct = Map.get(data, "10. change percent") msg = "#{symbol}: #{price} #{change} [#{change_pct}] (high: #{high}, low: #{low}, open: #{open}, prev close: #{prev_close}) (volume: #{volume})" message.replyfun.(msg) end {:ok, resp = %HTTPoison.Response{status_code: code}} -> Logger.error "AlphaVantage API error: #{code} #{url} - #{inspect resp}" message.replyfun.("stocks: erreur (api #{code})") {:error, %HTTPoison.Error{reason: error}} -> Logger.error "AlphaVantage HTTP error: #{inspect error}" message.replyfun.("stocks: erreur (http #{inspect error})") end {:noreply, state} end def handle_info({:irc, :trigger, "forex", message = %{trigger: %{type: :bang, args: args = [_ | _]}}}, state) do {amount, from, to} = case args do [amount, from, to] -> {amount, _} = Float.parse(amount) {amount, from, to} [from, to] -> {1, from, to} [from] -> {1, from, "EUR"} end url = "https://www.alphavantage.co/query?function=CURRENCY_EXCHANGE_RATE&from_currency=#{from}&to_currency=#{to}&apikey=#{api_key()}" case HTTPoison.get(url) do {:ok, %HTTPoison.Response{status_code: 200, body: data}} -> data = Poison.decode!(data) if error = Map.get(data, "Error Message") do Logger.error("AlphaVantage API invalid request #{url} - #{inspect error}") message.replyfun.("forex: requête invalide") else data = Map.get(data, "Realtime Currency Exchange Rate") from_name = Map.get(data, "2. From_Currency Name") to_name = Map.get(data, "4. To_Currency Name") rate = Map.get(data, "5. Exchange Rate") {rate, _} = Float.parse(rate) value = amount*rate message.replyfun.("#{amount} #{from} (#{from_name}) -> #{value} #{to} (#{to_name}) (#{rate})") end {:ok, resp = %HTTPoison.Response{status_code: code}} -> Logger.error "AlphaVantage API error: #{code} #{url} - #{inspect resp}" message.replyfun.("forex: erreur (api #{code})") {:error, %HTTPoison.Error{reason: error}} -> Logger.error "AlphaVantage HTTP error: #{inspect error}" message.replyfun.("forex: erreur (http #{inspect error})") end {:noreply, state} end def handle_info({:irc, :trigger, "currency", message = %{trigger: %{type: :query, args: args = search}}}, state) do search = Enum.join(search, " ") results = Enum.filter(@currencies, fn({symbol, name}) -> String.contains?(String.downcase(name), String.downcase(search)) || String.contains?(String.downcase(symbol), String.downcase(search)) end) |> Enum.map(fn({symbol, name}) -> "#{symbol}: #{name}" end) |> Enum.join(", ") if results == "" do message.replyfun.("no results!") else message.replyfun.(results) end {:noreply, state} end defp api_key() do Application.get_env(:nola, :alphavantage, []) |> Keyword.get(:api_key, "demo") end end diff --git a/lib/nola_plugins/gpt_plugin.ex b/lib/nola_plugins/gpt.ex similarity index 98% rename from lib/nola_plugins/gpt_plugin.ex rename to lib/nola_plugins/gpt.ex index 2c8f182..3d2a66d 100644 --- a/lib/nola_plugins/gpt_plugin.ex +++ b/lib/nola_plugins/gpt.ex @@ -1,259 +1,259 @@ -defmodule Nola.IRC.GptPlugin do +defmodule Nola.Plugins.Gpt do require Logger - import Irc.Plugin.TempRef + import Nola.Plugins.TempRefHelper def irc_doc() do """ # OpenAI GPT Uses OpenAI's GPT-3 API to bring natural language prompts to your IRC channel. _prompts_ are pre-defined prompts and parameters defined in the bot' CouchDB. _Runs_ (results of the inference of a _prompt_) are also stored in CouchDB and may be resumed. * **!gpt** list GPT prompts * **!gpt `[prompt]` ``** run a prompt * **+gpt `[short ref|run id]` ``** continue a prompt * **?gpt offensive ``** is content offensive ? * **?gpt show `[short ref|run id]`** run information and web link * **?gpt `[prompt]`** prompt information and web link """ end @couch_db "bot-plugin-openai-prompts" @couch_run_db "bot-plugin-gpt-history" @trigger "gpt" def start_link() do GenServer.start_link(__MODULE__, [], name: __MODULE__) end defstruct [:temprefs] def get_result(id) do Couch.get(@couch_run_db, id) end def get_prompt(id) do Couch.get(@couch_db, id) end def init(_) do regopts = [plugin: __MODULE__] {:ok, _} = Registry.register(IRC.PubSub, "trigger:#{@trigger}", regopts) {:ok, %__MODULE__{temprefs: new_temp_refs()}} end def handle_info({:irc, :trigger, @trigger, m = %IRC.Message{trigger: %IRC.Trigger{type: :bang, args: [prompt | args]}}}, state) do case Couch.get(@couch_db, prompt) do {:ok, prompt} -> {:noreply, prompt(m, prompt, Enum.join(args, " "), state)} {:error, :not_found} -> m.replyfun.("gpt: prompt '#{prompt}' does not exists") {:noreply, state} error -> Logger.info("gpt: prompt load error: #{inspect error}") m.replyfun.("gpt: database error") {:noreply, state} end end def handle_info({:irc, :trigger, @trigger, m = %IRC.Message{trigger: %IRC.Trigger{type: :bang, args: []}}}, state) do case Couch.get(@couch_db, "_all_docs") do {:ok, %{"rows" => []}} -> m.replyfun.("gpt: no prompts available") {:ok, %{"rows" => prompts}} -> prompts = prompts |> Enum.map(fn(prompt) -> Map.get(prompt, "id") end) |> Enum.join(", ") m.replyfun.("gpt: prompts: #{prompts}") error -> Logger.info("gpt: prompt load error: #{inspect error}") m.replyfun.("gpt: database error") end {:noreply, state} end def handle_info({:irc, :trigger, @trigger, m = %IRC.Message{trigger: %IRC.Trigger{type: :plus, args: [ref_or_id | args]}}}, state) do id = lookup_temp_ref(ref_or_id, state.temprefs, ref_or_id) case Couch.get(@couch_run_db, id) do {:ok, run} -> Logger.debug("+gpt run: #{inspect run}") {:noreply, continue_prompt(m, run, Enum.join(args, " "), state)} {:error, :not_found} -> m.replyfun.("gpt: ref or id not found or expired: #{inspect ref_or_id} (if using short ref, try using full id)") {:noreply, state} error -> Logger.info("+gpt: run load error: #{inspect error}") m.replyfun.("gpt: database error") {:noreply, state} end end def handle_info({:irc, :trigger, @trigger, m = %IRC.Message{trigger: %IRC.Trigger{type: :query, args: ["offensive" | text]}}}, state) do text = Enum.join(text, " ") {moderate?, moderation} = moderation(text, m.account.id) reply = cond do moderate? -> "⚠️ #{Enum.join(moderation, ", ")}" !moderate? && moderation -> "👍" !moderate? -> "☠️ error" end m.replyfun.(reply) {:noreply, state} end def handle_info({:irc, :trigger, @trigger, m = %IRC.Message{trigger: %IRC.Trigger{type: :query, args: ["show", ref_or_id]}}}, state) do id = lookup_temp_ref(ref_or_id, state.temprefs, ref_or_id) url = if m.channel do NolaWeb.Router.Helpers.gpt_url(NolaWeb.Endpoint, :result, m.network, NolaWeb.format_chan(m.channel), id) else NolaWeb.Router.Helpers.gpt_url(NolaWeb.Endpoint, :result, id) end m.replyfun.("→ #{url}") {:noreply, state} end def handle_info({:irc, :trigger, @trigger, m = %IRC.Message{trigger: %IRC.Trigger{type: :query, args: [prompt]}}}, state) do url = if m.channel do NolaWeb.Router.Helpers.gpt_url(NolaWeb.Endpoint, :prompt, m.network, NolaWeb.format_chan(m.channel), prompt) else NolaWeb.Router.Helpers.gpt_url(NolaWeb.Endpoint, :prompt, prompt) end m.replyfun.("→ #{url}") {:noreply, state} end def handle_info(info, state) do Logger.debug("gpt: unhandled info: #{inspect info}") {:noreply, state} end defp continue_prompt(msg, run, content, state) do prompt_id = Map.get(run, "prompt_id") prompt_rev = Map.get(run, "prompt_rev") original_prompt = case Couch.get(@couch_db, prompt_id, rev: prompt_rev) do {:ok, prompt} -> prompt _ -> nil end if original_prompt do continue_prompt = %{"_id" => prompt_id, "_rev" => prompt_rev, "type" => Map.get(original_prompt, "type"), "parent_run_id" => Map.get(run, "_id"), "openai_params" => Map.get(run, "request") |> Map.delete("prompt")} continue_prompt = if prompt_string = Map.get(original_prompt, "continue_prompt") do full_text = get_in(run, ~w(request prompt)) <> "\n" <> Map.get(run, "response") continue_prompt |> Map.put("prompt", prompt_string) |> Map.put("prompt_format", "liquid") |> Map.put("prompt_liquid_variables", %{"previous" => full_text}) else prompt_content_tag = if content != "", do: " {{content}}", else: "" string = get_in(run, ~w(request prompt)) <> "\n" <> Map.get(run, "response") <> prompt_content_tag continue_prompt |> Map.put("prompt", string) |> Map.put("prompt_format", "liquid") end prompt(msg, continue_prompt, content, state) else msg.replyfun.("gpt: cannot continue this prompt: original prompt not found #{prompt_id}@v#{prompt_rev}") state end end defp prompt(msg, prompt = %{"type" => "completions", "prompt" => prompt_template}, content, state) do - Logger.debug("gpt_plugin:prompt/4 #{inspect prompt}") + Logger.debug("gpt:prompt/4 #{inspect prompt}") prompt_text = case Map.get(prompt, "prompt_format", "liquid") do "liquid" -> Tmpl.render(prompt_template, msg, Map.merge(Map.get(prompt, "prompt_liquid_variables", %{}), %{"content" => content})) "norender" -> prompt_template end args = Map.get(prompt, "openai_params") |> Map.put("prompt", prompt_text) |> Map.put("user", msg.account.id) {moderate?, moderation} = moderation(content, msg.account.id) if moderate?, do: msg.replyfun.("⚠️ offensive input: #{Enum.join(moderation, ", ")}") Logger.debug("GPT: request #{inspect args}") case OpenAi.post("/v1/completions", args) do {:ok, %{"choices" => [%{"text" => text, "finish_reason" => finish_reason} | _], "usage" => usage, "id" => gpt_id, "created" => created}} -> text = String.trim(text) {o_moderate?, o_moderation} = moderation(text, msg.account.id) if o_moderate?, do: msg.replyfun.("🚨 offensive output: #{Enum.join(o_moderation, ", ")}") msg.replyfun.(text) doc = %{"id" => FlakeId.get(), "prompt_id" => Map.get(prompt, "_id"), "prompt_rev" => Map.get(prompt, "_rev"), "network" => msg.network, "channel" => msg.channel, "nick" => msg.sender.nick, "account_id" => (if msg.account, do: msg.account.id), "request" => args, "response" => text, "message_at" => msg.at, "reply_at" => DateTime.utc_now(), "gpt_id" => gpt_id, "gpt_at" => created, "gpt_usage" => usage, "type" => "completions", "parent_run_id" => Map.get(prompt, "parent_run_id"), "moderation" => %{"input" => %{flagged: moderate?, categories: moderation}, "output" => %{flagged: o_moderate?, categories: o_moderation} } } Logger.debug("Saving result to couch: #{inspect doc}") {id, ref, temprefs} = case Couch.post(@couch_run_db, doc) do {:ok, id, _rev} -> {ref, temprefs} = put_temp_ref(id, state.temprefs) {id, ref, temprefs} error -> Logger.error("Failed to save to Couch: #{inspect error}") {nil, nil, state.temprefs} end stop = cond do finish_reason == "stop" -> "" finish_reason == "length" -> " — truncated" true -> " — #{finish_reason}" end ref_and_prefix = if Map.get(usage, "completion_tokens", 0) == 0 do "GPT had nothing else to say :( ↪ #{ref || "✗"}" else " ↪ #{ref || "✗"}" end msg.replyfun.(ref_and_prefix <> stop <> " — #{Map.get(usage, "total_tokens", 0)}" <> " (#{Map.get(usage, "prompt_tokens", 0)}/#{Map.get(usage, "completion_tokens", 0)}) tokens" <> " — #{id || "save failed"}") %__MODULE__{state | temprefs: temprefs} {:error, atom} when is_atom(atom) -> Logger.error("gpt error: #{inspect atom}") msg.replyfun.("gpt: ☠️ #{to_string(atom)}") state error -> Logger.error("gpt error: #{inspect error}") msg.replyfun.("gpt: ☠️ ") state end end defp moderation(content, user_id) do case OpenAi.post("/v1/moderations", %{"input" => content, "user" => user_id}) do {:ok, %{"results" => [%{"flagged" => true, "categories" => categories} | _]}} -> cat = categories |> Enum.filter(fn({_key, value}) -> value end) |> Enum.map(fn({key, _}) -> key end) {true, cat} {:ok, moderation} -> Logger.debug("gpt: moderation: not flagged, #{inspect moderation}") {false, true} error -> Logger.error("gpt: moderation error: #{inspect error}") {false, false} end end end diff --git a/lib/nola_irc/plugin/temp_ref.ex b/lib/nola_plugins/helpers/temp_ref.ex similarity index 98% rename from lib/nola_irc/plugin/temp_ref.ex rename to lib/nola_plugins/helpers/temp_ref.ex index 923fa1a..160169d 100644 --- a/lib/nola_irc/plugin/temp_ref.ex +++ b/lib/nola_plugins/helpers/temp_ref.ex @@ -1,95 +1,95 @@ -defmodule Irc.Plugin.TempRef do +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]) } 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 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/nola_plugins/kick_roulette_plugin.ex b/lib/nola_plugins/kick_roulette.ex similarity index 94% rename from lib/nola_plugins/kick_roulette_plugin.ex rename to lib/nola_plugins/kick_roulette.ex index 55b7da4..f6d2ba2 100644 --- a/lib/nola_plugins/kick_roulette_plugin.ex +++ b/lib/nola_plugins/kick_roulette.ex @@ -1,32 +1,32 @@ -defmodule Nola.IRC.KickRoulettePlugin do +defmodule Nola.Plugins.KickRoulette do @moduledoc """ # kick roulette * **!kick**, tentez votre chance… """ def irc_doc, do: @moduledoc def start_link() do GenServer.start_link(__MODULE__, [], name: __MODULE__) end def init([]) do {:ok, _} = Registry.register(IRC.PubSub, "trigger:kick", [plugin: __MODULE__]) {:ok, nil} end def handle_info({:irc, :trigger, "kick", message = %{trigger: %{type: :bang, args: []}}}, _) do if 5 == :crypto.rand_uniform(1, 6) do spawn(fn() -> :timer.sleep(:crypto.rand_uniform(200, 10_000)) message.replyfun.({:kick, message.sender.nick, "perdu"}) end) end {:noreply, nil} end def handle_info(msg, _) do {:noreply, nil} end end diff --git a/lib/nola_plugins/last_fm_plugin.ex b/lib/nola_plugins/last_fm.ex similarity index 99% rename from lib/nola_plugins/last_fm_plugin.ex rename to lib/nola_plugins/last_fm.ex index 03df675..1a9b7dd 100644 --- a/lib/nola_plugins/last_fm_plugin.ex +++ b/lib/nola_plugins/last_fm.ex @@ -1,187 +1,187 @@ -defmodule Nola.IRC.LastFmPlugin do +defmodule Nola.Plugins.LastFm do require Logger @moduledoc """ # last.fm * **!lastfm|np `[nick|username]`** * **.lastfm|np** * **+lastfm, -lastfm `; ?lastfm`** Configurer un nom d'utilisateur last.fm """ @single_trigger ~w(lastfm np) @pubsub_topics ~w(trigger:lastfm trigger:np) defstruct dets: nil def irc_doc, do: @moduledoc def start_link() do GenServer.start_link(__MODULE__, [], name: __MODULE__) end def init([]) do regopts = [type: __MODULE__] for t <- @pubsub_topics, do: {:ok, _} = Registry.register(IRC.PubSub, t, type: __MODULE__) dets_filename = (Nola.data_path() <> "/" <> "lastfm.dets") |> String.to_charlist {:ok, dets} = :dets.open_file(dets_filename, []) {:ok, %__MODULE__{dets: dets}} end def handle_info({:irc, :trigger, "lastfm", message = %{trigger: %{type: :plus, args: [username]}}}, state) do username = String.strip(username) :ok = :dets.insert(state.dets, {message.account.id, username}) message.replyfun.("#{message.sender.nick}: nom d'utilisateur last.fm configuré: \"#{username}\".") {:noreply, state} end def handle_info({:irc, :trigger, "lastfm", message = %{trigger: %{type: :minus, args: []}}}, state) do text = case :dets.lookup(state.dets, message.account.id) do [{_nick, _username}] -> :dets.delete(state.dets, message.account.id) message.replyfun.("#{message.sender.nick}: nom d'utilisateur last.fm enlevé.") _ -> nil end {:noreply, state} end def handle_info({:irc, :trigger, "lastfm", message = %{trigger: %{type: :query, args: []}}}, state) do text = case :dets.lookup(state.dets, message.account.id) do [{_nick, username}] -> message.replyfun.("#{message.sender.nick}: #{username}.") _ -> nil end {:noreply, state} end def handle_info({:irc, :trigger, _, message = %{trigger: %{type: :bang, args: []}}}, state) do irc_now_playing(message.account.id, message, state) {:noreply, state} end def handle_info({:irc, :trigger, _, message = %{trigger: %{type: :bang, args: [nick_or_user]}}}, state) do irc_now_playing(nick_or_user, message, state) {:noreply, state} end def handle_info({:irc, :trigger, _, message = %{trigger: %{type: :dot}}}, state) do members = IRC.Membership.members(message.network, message.channel) foldfun = fn({nick, user}, acc) -> [{nick,user}|acc] end usernames = :dets.foldl(foldfun, [], state.dets) |> Enum.uniq() |> Enum.filter(fn({acct,_}) -> Enum.member?(members, acct) end) |> Enum.map(fn({_, u}) -> u end) for u <- usernames, do: irc_now_playing(u, message, state) {:noreply, state} end def handle_info(info, state) do {:noreply, state} end def terminate(_reason, state) do if state.dets do :dets.sync(state.dets) :dets.close(state.dets) end :ok end defp irc_now_playing(nick_or_user, message, state) do nick_or_user = String.strip(nick_or_user) id_or_user = if account = IRC.Account.get(nick_or_user) || IRC.Account.find_always_by_nick(message.network, message.channel, nick_or_user) do account.id else nick_or_user end username = case :dets.lookup(state.dets, id_or_user) do [{_, username}] -> username _ -> id_or_user end case now_playing(username) do {:error, text} when is_binary(text) -> message.replyfun.(text) {:ok, map} when is_map(map) -> track = fetch_track(username, map) text = format_now_playing(map, track) user = if account = IRC.Account.get(id_or_user) do user = IRC.UserTrack.find_by_account(message.network, account) if(user, do: user.nick, else: account.name) else username end if user && text do message.replyfun.("#{user} #{text}") else message.replyfun.("#{username}: pas de résultat") end other -> message.replyfun.("erreur :(") end end defp now_playing(user) do api = Application.get_env(:nola, :lastfm)[:api_key] url = "http://ws.audioscrobbler.com/2.0/?method=user.getrecenttracks&format=json&limit=1&extended=1" <> "&api_key=" <> api <> "&user="<> user case HTTPoison.get(url) do {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> Jason.decode(body) {:ok, %HTTPoison.Response{status_code: 404}} -> {:error, "last.fm: utilisateur \"#{user}\" inexistant"} {:ok, %HTTPoison.Response{status_code: code}} -> {:error, "last.fm: erreur #{to_string(code)}"} error -> Logger.error "Lastfm http error: #{inspect error}" :error end end defp fetch_track(user, %{"recenttracks" => %{"track" => [ t = %{"name" => name, "artist" => %{"name" => artist}} | _]}}) do api = Application.get_env(:nola, :lastfm)[:api_key] url = "http://ws.audioscrobbler.com/2.0/?method=track.getInfo&format=json" <> "&api_key=" <> api <> "&username="<> user <> "&artist="<>URI.encode(artist)<>"&track="<>URI.encode(name) case HTTPoison.get(url) do {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> case Jason.decode(body) do {:ok, body} -> body["track"] || %{} _ -> %{} end error -> Logger.error "Lastfm http error: #{inspect error}" :error end end defp format_now_playing(%{"recenttracks" => %{"track" => [track = %{"@attr" => %{"nowplaying" => "true"}} | _]}}, et) do format_track(true, track, et) end defp format_now_playing(%{"recenttracks" => %{"track" => [track | _]}}, et) do format_track(false, track, et) end defp format_now_playing(%{"error" => err, "message" => message}, _) do "last.fm error #{err}: #{message}" end defp format_now_playing(miss) do nil end defp format_track(np, track, extended) do artist = track["artist"]["name"] album = if track["album"]["#text"], do: " (" <> track["album"]["#text"] <> ")", else: "" name = track["name"] <> album action = if np, do: "écoute ", else: "a écouté" love = if track["loved"] != "0", do: "❤️" count = if x = extended["userplaycount"], do: "x#{x} #{love}" tags = (get_in(extended, ["toptags", "tag"]) || []) |> Enum.map(fn(tag) -> tag["name"] end) |> Enum.filter(& &1) |> Enum.join(", ") [action, artist, name, count, tags, track["url"]] |> Enum.filter(& &1) |> Enum.map(&String.trim(&1)) |> Enum.join(" - ") end end diff --git a/lib/nola_plugins/link_plugin.ex b/lib/nola_plugins/link.ex similarity index 96% rename from lib/nola_plugins/link_plugin.ex rename to lib/nola_plugins/link.ex index dee78e8..381bdab 100644 --- a/lib/nola_plugins/link_plugin.ex +++ b/lib/nola_plugins/link.ex @@ -1,271 +1,271 @@ -defmodule Nola.IRC.LinkPlugin do +defmodule Nola.Plugins.Link do @moduledoc """ # Link Previewer An extensible link previewer for IRC. To extend the supported sites, create a new handler implementing the callbacks. - See `link_plugin/` directory for examples. The first in list handler that returns true to the `match/2` callback will be used, + See `link/` directory. The first in list handler that returns true to the `match/2` callback will be used, and if the handler returns `:error` or crashes, will fallback to the default preview. Unsupported websites will use the default link preview method, which is for html document the title, otherwise it'll use the mimetype and size. ## Configuration: ``` - config :nola, Nola.IRC.LinkPlugin, + config :nola, Nola.Plugins.Link, handlers: [ - Nola.IRC.LinkPlugin.Youtube: [ + Nola.Plugins.Link.Youtube: [ invidious: true ], - Nola.IRC.LinkPlugin.Twitter: [], - Nola.IRC.LinkPlugin.Imgur: [], + Nola.Plugins.Link.Twitter: [], + Nola.Plugins.Link.Imgur: [], ] ``` """ @ircdoc """ # Link preview Previews links (just post a link!). Announces real URL after redirections and provides extended support for YouTube, Twitter and Imgur. """ def short_irc_doc, do: false def irc_doc, do: @ircdoc require Logger def start_link() do GenServer.start_link(__MODULE__, [], name: __MODULE__) end @callback match(uri :: URI.t, options :: Keyword.t) :: {true, params :: Map.t} | false @callback expand(uri :: URI.t, params :: Map.t, options :: Keyword.t) :: {:ok, lines :: [] | String.t} | :error @callback post_match(uri :: URI.t, content_type :: binary, headers :: [], opts :: Keyword.t) :: {:body | :file, params :: Map.t} | false @callback post_expand(uri :: URI.t, body :: binary() | Path.t, params :: Map.t, options :: Keyword.t) :: {:ok, lines :: [] | String.t} | :error @optional_callbacks [expand: 3, post_expand: 4] defstruct [:client] def init([]) do {:ok, _} = Registry.register(IRC.PubSub, "messages", [plugin: __MODULE__]) #{:ok, _} = Registry.register(IRC.PubSub, "messages:telegram", [plugin: __MODULE__]) Logger.info("Link handler started") {:ok, %__MODULE__{}} end def handle_info({:irc, :text, message = %{text: text}}, state) do String.split(text) |> Enum.map(fn(word) -> if String.starts_with?(word, "http://") || String.starts_with?(word, "https://") do uri = URI.parse(word) if uri.scheme && uri.host do spawn(fn() -> :timer.kill_after(:timer.seconds(30)) case expand_link([uri]) do {:ok, uris, text} -> text = case uris do [uri] -> text [luri | _] -> if luri.host == uri.host && luri.path == luri.path do text else ["-> #{URI.to_string(luri)}", text] end end if is_list(text) do for line <- text, do: message.replyfun.(line) else message.replyfun.(text) end _ -> nil end end) end end end) {:noreply, state} end def handle_info(msg, state) do {:noreply, state} end def terminate(_reason, state) do :ok end # 1. Match the first valid handler # 2. Try to run the handler # 3. If :error or crash, default link. # If :skip, nothing # 4. ? # Over five redirections: cancel. def expand_link(acc = [_, _, _, _, _ | _]) do {:ok, acc, "link redirects more than five times"} end def expand_link(acc=[uri | _]) do Logger.debug("link: expanding: #{inspect uri}") handlers = Keyword.get(Application.get_env(:nola, __MODULE__, [handlers: []]), :handlers) handler = Enum.reduce_while(handlers, nil, fn({module, opts}, acc) -> Logger.debug("link: attempt expanding: #{inspect module} for #{inspect uri}") module = Module.concat([module]) case module.match(uri, opts) do {true, params} -> {:halt, {module, params, opts}} false -> {:cont, acc} end end) run_expand(acc, handler) end def run_expand(acc, nil) do expand_default(acc) end def run_expand(acc=[uri|_], {module, params, opts}) do Logger.debug("link: expanding #{inspect uri} with #{inspect module}") case module.expand(uri, params, opts) do {:ok, data} -> {:ok, acc, data} :error -> expand_default(acc) :skip -> nil end rescue e -> Logger.error("link: rescued #{inspect uri} with #{inspect module}: #{inspect e}") Logger.error(Exception.format(:error, e, __STACKTRACE__)) expand_default(acc) catch e, b -> Logger.error("link: catched #{inspect uri} with #{inspect module}: #{inspect {e, b}}") expand_default(acc) end defp get(url, headers \\ [], options \\ []) do get_req(url, :hackney.get(url, headers, <<>>, options)) end defp get_req(_, {:error, reason}) do {:error, reason} end defp get_req(url, {:ok, 200, headers, client}) do headers = Enum.reduce(headers, %{}, fn({key, value}, acc) -> Map.put(acc, String.downcase(key), value) end) content_type = Map.get(headers, "content-type", "application/octect-stream") length = Map.get(headers, "content-length", "0") {length, _} = Integer.parse(length) handlers = Keyword.get(Application.get_env(:nola, __MODULE__, [handlers: []]), :handlers) handler = Enum.reduce_while(handlers, false, fn({module, opts}, acc) -> module = Module.concat([module]) try do case module.post_match(url, content_type, headers, opts) do {mode, params} when mode in [:body, :file] -> {:halt, {module, params, opts, mode}} false -> {:cont, acc} end rescue e -> Logger.error(inspect(e)) {:cont, false} catch e, b -> Logger.error(inspect({b})) {:cont, false} end end) cond do handler != false and length <= 30_000_000 -> case get_body(url, 30_000_000, client, handler, <<>>) do {:ok, _} = ok -> ok :error -> {:ok, "file: #{content_type}, size: #{human_size(length)}"} end #String.starts_with?(content_type, "text/html") && length <= 30_000_000 -> # get_body(url, 30_000_000, client, <<>>) true -> :hackney.close(client) {:ok, "file: #{content_type}, size: #{human_size(length)}"} end end defp get_req(_, {:ok, redirect, headers, client}) when redirect in 300..399 do headers = Enum.reduce(headers, %{}, fn({key, value}, acc) -> Map.put(acc, String.downcase(key), value) end) location = Map.get(headers, "location") :hackney.close(client) {:redirect, location} end defp get_req(_, {:ok, status, headers, client}) do :hackney.close(client) {:error, status, headers} end defp get_body(url, len, client, {handler, params, opts, mode} = h, acc) when len >= byte_size(acc) do case :hackney.stream_body(client) do {:ok, data} -> get_body(url, len, client, h, << acc::binary, data::binary >>) :done -> body = case mode do :body -> acc :file -> {:ok, tmpfile} = Plug.Upload.random_file("linkplugin") File.write!(tmpfile, acc) tmpfile end handler.post_expand(url, body, params, opts) {:error, reason} -> {:ok, "failed to fetch body: #{inspect reason}"} end end defp get_body(_, len, client, h, _acc) do :hackney.close(client) IO.inspect(h) {:ok, "Error: file over 30"} end def expand_default(acc = [uri = %URI{scheme: scheme} | _]) when scheme in ["http", "https"] do Logger.debug("link: expanding #{uri} with default") headers = [{"user-agent", "DmzBot (like TwitterBot)"}] options = [follow_redirect: false, max_body_length: 30_000_000] case get(URI.to_string(uri), headers, options) do {:ok, text} -> {:ok, acc, text} {:redirect, link} -> new_uri = URI.parse(link) #new_uri = %URI{new_uri | scheme: scheme, authority: uri.authority, host: uri.host, port: uri.port} expand_link([new_uri | acc]) {:error, status, _headers} -> text = Plug.Conn.Status.reason_phrase(status) {:ok, acc, "Error: HTTP #{text} (#{status})"} {:error, {:tls_alert, {:handshake_failure, err}}} -> {:ok, acc, "TLS Error: #{to_string(err)}"} {:error, reason} -> {:ok, acc, "Error: #{to_string(reason)}"} end end # Unsupported scheme, came from a redirect. def expand_default(acc = [uri | _]) do {:ok, [uri], "-> #{URI.to_string(uri)}"} end defp human_size(bytes) do bytes |> FileSize.new(:b) |> FileSize.scale() |> FileSize.format() end end diff --git a/lib/nola_plugins/link_plugin/github.ex b/lib/nola_plugins/link/github.ex similarity index 95% rename from lib/nola_plugins/link_plugin/github.ex rename to lib/nola_plugins/link/github.ex index 93e0892..0069a40 100644 --- a/lib/nola_plugins/link_plugin/github.ex +++ b/lib/nola_plugins/link/github.ex @@ -1,49 +1,49 @@ -defmodule Nola.IRC.LinkPlugin.Github do - @behaviour Nola.IRC.LinkPlugin +defmodule Nola.Plugins.Link.Github do + @behaviour Nola.Plugins.Link @impl true def match(uri = %URI{host: "github.com", path: path}, _) do case String.split(path, "/") do ["", user, repo] -> {true, %{user: user, repo: repo, path: "#{user}/#{repo}"}} _ -> false end end def match(_, _), do: false @impl true def post_match(_, _, _, _), do: false @impl true def expand(_uri, %{user: user, repo: repo}, _opts) do case HTTPoison.get("https://api.github.com/repos/#{user}/#{repo}") do {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> {:ok, json} = Jason.decode(body) src = json["source"]["full_name"] disabled = if(json["disabled"], do: " (disabled)", else: "") archived = if(json["archived"], do: " (archived)", else: "") fork = if src && src != json["full_name"] do " (⑂ #{json["source"]["full_name"]})" else "" end start = "#{json["full_name"]}#{disabled}#{archived}#{fork} - #{json["description"]}" tags = for(t <- json["topics"]||[], do: "##{t}") |> Enum.intersperse(", ") |> Enum.join("") lang = if(json["language"], do: "#{json["language"]} - ", else: "") issues = if(json["open_issues_count"], do: "#{json["open_issues_count"]} issues - ", else: "") last_push = if at = json["pushed_at"] do {:ok, date, _} = DateTime.from_iso8601(at) " - last pushed #{DateTime.to_string(date)}" else "" end network = "#{lang}#{issues}#{json["stargazers_count"]} stars - #{json["subscribers_count"]} watchers - #{json["forks_count"]} forks#{last_push}" {:ok, [start, tags, network]} other -> :error end end end diff --git a/lib/nola_plugins/link_plugin/html.ex b/lib/nola_plugins/link/html.ex similarity index 97% rename from lib/nola_plugins/link_plugin/html.ex rename to lib/nola_plugins/link/html.ex index 56a8ceb..9b44319 100644 --- a/lib/nola_plugins/link_plugin/html.ex +++ b/lib/nola_plugins/link/html.ex @@ -1,106 +1,106 @@ -defmodule Nola.IRC.LinkPlugin.HTML do - @behaviour Nola.IRC.LinkPlugin +defmodule Nola.Plugins.Link.HTML do + @behaviour Nola.Plugins.Link @impl true def match(_, _), do: false @impl true def post_match(_url, "text/html"<>_, _header, _opts) do {:body, nil} end def post_match(_, _, _, _), do: false @impl true def post_expand(url, body, _params, _opts) do html = Floki.parse(body) title = collect_title(html) opengraph = collect_open_graph(html) itemprops = collect_itemprops(html) text = if Map.has_key?(opengraph, "title") && Map.has_key?(opengraph, "description") do sitename = if sn = Map.get(opengraph, "site_name") do "#{sn}" else "" end paywall? = if Map.get(opengraph, "article:content_tier", Map.get(itemprops, "article:content_tier", "free")) == "free" do "" else "[paywall] " end section = if section = Map.get(opengraph, "article:section", Map.get(itemprops, "article:section", nil)) do ": #{section}" else "" end date = case DateTime.from_iso8601(Map.get(opengraph, "article:published_time", Map.get(itemprops, "article:published_time", ""))) do {:ok, date, _} -> "#{Timex.format!(date, "%d/%m/%y", :strftime)}. " _ -> "" end uri = URI.parse(url) prefix = "#{paywall?}#{Map.get(opengraph, "site_name", uri.host)}#{section}" prefix = unless prefix == "" do "#{prefix} — " else "" end [clean_text("#{prefix}#{Map.get(opengraph, "title")}")] ++ IRC.splitlong(clean_text("#{date}#{Map.get(opengraph, "description")}")) else clean_text(title) end {:ok, text} end defp collect_title(html) do case Floki.find(html, "title") do [{"title", [], [title]} | _] -> String.trim(title) _ -> nil end end defp collect_open_graph(html) do Enum.reduce(Floki.find(html, "head meta"), %{}, fn(tag, acc) -> case tag do {"meta", values, []} -> name = List.keyfind(values, "property", 0, {nil, nil}) |> elem(1) content = List.keyfind(values, "content", 0, {nil, nil}) |> elem(1) case name do "og:" <> key -> Map.put(acc, key, content) "article:"<>_ -> Map.put(acc, name, content) _other -> acc end _other -> acc end end) end defp collect_itemprops(html) do Enum.reduce(Floki.find(html, "[itemprop]"), %{}, fn(tag, acc) -> case tag do {"meta", values, []} -> name = List.keyfind(values, "itemprop", 0, {nil, nil}) |> elem(1) content = List.keyfind(values, "content", 0, {nil, nil}) |> elem(1) case name do "article:" <> key -> Map.put(acc, name, content) _other -> acc end _other -> acc end end) end defp clean_text(text) do text |> String.replace("\n", " ") |> HtmlEntities.decode() end end diff --git a/lib/nola_plugins/link_plugin/imgur.ex b/lib/nola_plugins/link/imgur.ex similarity index 97% rename from lib/nola_plugins/link_plugin/imgur.ex rename to lib/nola_plugins/link/imgur.ex index 5d74956..9fe9354 100644 --- a/lib/nola_plugins/link_plugin/imgur.ex +++ b/lib/nola_plugins/link/imgur.ex @@ -1,96 +1,96 @@ -defmodule Nola.IRC.LinkPlugin.Imgur do - @behaviour Nola.IRC.LinkPlugin +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 {true, %{album_id: album_id}} end 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 [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 {: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/nola_plugins/link_plugin/pdf.ex b/lib/nola_plugins/link/pdf.ex similarity index 92% rename from lib/nola_plugins/link_plugin/pdf.ex rename to lib/nola_plugins/link/pdf.ex index 5f72ef5..e91dcc2 100644 --- a/lib/nola_plugins/link_plugin/pdf.ex +++ b/lib/nola_plugins/link/pdf.ex @@ -1,39 +1,39 @@ -defmodule Nola.IRC.LinkPlugin.PDF do +defmodule Nola.Plugins.Link.PDF do require Logger - @behaviour Nola.IRC.LinkPlugin + @behaviour Nola.Plugins.Link @impl true def match(_, _), do: false @impl true 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() if text == "" do :error else basename = Path.basename(url, ".pdf") text = "[#{basename}] " <> text |> String.split("\n") {:ok, text} end {_, 127} -> 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}") :error end end end diff --git a/lib/nola_plugins/link_plugin/redacted.ex b/lib/nola_plugins/link/redacted.ex similarity index 81% rename from lib/nola_plugins/link_plugin/redacted.ex rename to lib/nola_plugins/link/redacted.ex index 7a6229d..a7cfe74 100644 --- a/lib/nola_plugins/link_plugin/redacted.ex +++ b/lib/nola_plugins/link/redacted.ex @@ -1,18 +1,18 @@ -defmodule Nola.IRC.LinkPlugin.Redacted do - @behaviour Nola.IRC.LinkPlugin +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 %{"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/nola_plugins/link_plugin/reddit.ex b/lib/nola_plugins/link/reddit.ex similarity index 98% rename from lib/nola_plugins/link_plugin/reddit.ex rename to lib/nola_plugins/link/reddit.ex index 79102e0..016e025 100644 --- a/lib/nola_plugins/link_plugin/reddit.ex +++ b/lib/nola_plugins/link/reddit.ex @@ -1,119 +1,119 @@ -defmodule Nola.IRC.LinkPlugin.Reddit do - @behaviour Nola.IRC.LinkPlugin +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}} _ -> 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" 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) 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_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 title = "#{nsfw}#{sr}: #{flair}#{Map.get(op, "title")}" state_str = if(state, do: "#{state}, ") content = "by u/#{Map.get(op, "author")} - #{state_str}#{up} up, #{down} down, #{comments} comments - #{self_str}" {:ok, [title, content]} err -> :error end end end diff --git a/lib/nola_plugins/link_plugin/twitter.ex b/lib/nola_plugins/link/twitter.ex similarity index 98% rename from lib/nola_plugins/link_plugin/twitter.ex rename to lib/nola_plugins/link/twitter.ex index 640b193..e7f3e63 100644 --- a/lib/nola_plugins/link_plugin/twitter.ex +++ b/lib/nola_plugins/link/twitter.ex @@ -1,158 +1,158 @@ -defmodule Nola.IRC.LinkPlugin.Twitter do - @behaviour Nola.IRC.LinkPlugin +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 case String.split(path, "/", parts: 4) do ["", _username, "status", status_id] -> {status_id, _} = Integer.parse(status_id) {true, %{status_id: status_id}} _ -> 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 = IRC.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 quoted = if tweet.quoted_status do full_text = tweet.quoted_status |> expand_twitter_text(opts) |> IRC.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 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) 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 end) |> Enum.join(" ") String.replace(text, entity.url, url) end) |> 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 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 = meta |> Enum.filter(& &1) |> Enum.join(" - ") meta = <<3, 15, meta::binary, " → #{link}", 3>> <> end end diff --git a/lib/nola_plugins/link_plugin/youtube.ex b/lib/nola_plugins/link/youtube.ex similarity index 96% rename from lib/nola_plugins/link_plugin/youtube.ex rename to lib/nola_plugins/link/youtube.ex index f7c7541..1b14221 100644 --- a/lib/nola_plugins/link_plugin/youtube.ex +++ b/lib/nola_plugins/link/youtube.ex @@ -1,72 +1,72 @@ -defmodule Nola.IRC.LinkPlugin.YouTube do - @behaviour Nola.IRC.LinkPlugin +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 {true, %{video_id: video_id}} end 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 [] end {:ok, line ++ ["#{snippet["title"]}", "— #{duration} — uploaded by #{snippet["channelTitle"]} — #{date}" <> " — #{item["statistics"]["viewCount"]} views, #{item["statistics"]["likeCount"]} likes"]} else :error end _ -> :error end end end end diff --git a/lib/nola_plugins/logger_plugin.ex b/lib/nola_plugins/logger.ex similarity index 94% rename from lib/nola_plugins/logger_plugin.ex rename to lib/nola_plugins/logger.ex index b13f33a..69cac66 100644 --- a/lib/nola_plugins/logger_plugin.ex +++ b/lib/nola_plugins/logger.ex @@ -1,70 +1,71 @@ -defmodule Nola.IRC.LoggerPlugin do +defmodule Nola.Plugins.Logger do require Logger @couch_db "bot-logs" def irc_doc(), do: nil def start_link() do GenServer.start_link(__MODULE__, [], name: __MODULE__) end def init([]) do regopts = [plugin: __MODULE__] {:ok, _} = Registry.register(IRC.PubSub, "triggers", regopts) {:ok, _} = Registry.register(IRC.PubSub, "messages", regopts) + {:ok, _} = Registry.register(IRC.PubSub, "messages:telegram", regopts) {:ok, _} = Registry.register(IRC.PubSub, "irc:outputs", regopts) {:ok, _} = Registry.register(IRC.PubSub, "messages:private", regopts) {:ok, nil} end def handle_info({:irc, :trigger, _, m}, state) do {:noreply, log(m, state)} end def handle_info({:irc, :text, m}, state) do {:noreply, log(m, state)} end def handle_info(info, state) do Logger.debug("logger_plugin: unhandled info: #{info}") {:noreply, state} end def log(entry, state) do case Couch.post(@couch_db, format_to_db(entry)) do {:ok, id, _rev} -> Logger.debug("logger_plugin: saved: #{inspect id}") state error -> Logger.error("logger_plugin: save failed: #{inspect error}") end rescue e -> Logger.error("logger_plugin: rescued processing for #{inspect entry}: #{inspect e}") Logger.error(Exception.format(:error, e, __STACKTRACE__)) state catch e, b -> Logger.error("logger_plugin: catched processing for #{inspect entry}: #{inspect e}") Logger.error(Exception.format(e, b, __STACKTRACE__)) state end def format_to_db(msg = %IRC.Message{id: id}) do msg |> Poison.encode!() |> Map.drop("id") %{"_id" => id || FlakeId.get(), "type" => "irc.message/v1", "object" => msg} end def format_to_db(anything) do %{"_id" => FlakeId.get(), "type" => "object", "object" => anything} end end diff --git a/lib/nola_plugins/outline_plugin.ex b/lib/nola_plugins/outline.ex similarity index 98% rename from lib/nola_plugins/outline_plugin.ex rename to lib/nola_plugins/outline.ex index 820500e..f0ccfd2 100644 --- a/lib/nola_plugins/outline_plugin.ex +++ b/lib/nola_plugins/outline.ex @@ -1,108 +1,108 @@ -defmodule Nola.IRC.OutlinePlugin do +defmodule Nola.Plugins.Outline do @moduledoc """ # outline auto-link Envoie un lien vers Outline quand un lien est envoyé. * **!outline ``** crée un lien outline pour ``. * **+outline ``** active outline pour ``. * **-outline ``** désactive outline pour ``. """ def short_irc_doc, do: false def irc_doc, do: @moduledoc require Logger def start_link() do GenServer.start_link(__MODULE__, [], name: __MODULE__) end defstruct [:file, :hosts] def init([]) do regopts = [plugin: __MODULE__] {:ok, _} = Registry.register(IRC.PubSub, "trigger:outline", regopts) {:ok, _} = Registry.register(IRC.PubSub, "messages", regopts) file = Path.join(Nola.data_path, "/outline.txt") hosts = case File.read(file) do {:error, :enoent} -> [] {:ok, lines} -> String.split(lines, "\n", trim: true) end {:ok, %__MODULE__{file: file, hosts: hosts}} end def handle_info({:irc, :trigger, "outline", message = %IRC.Message{trigger: %IRC.Trigger{type: :plus, args: [host]}}}, state) do state = %{state | hosts: [host | state.hosts]} save(state) message.replyfun.("ok") {:noreply, state} end def handle_info({:irc, :trigger, "outline", message = %IRC.Message{trigger: %IRC.Trigger{type: :minus, args: [host]}}}, state) do state = %{state | hosts: List.delete(state.hosts, host)} save(state) message.replyfun.("ok") {:noreply, state} end def handle_info({:irc, :trigger, "outline", message = %IRC.Message{trigger: %IRC.Trigger{type: :bang, args: [url]}}}, state) do line = "-> #{outline(url)}" message.replyfun.(line) end def handle_info({:irc, :text, message = %IRC.Message{text: text}}, state) do String.split(text) |> Enum.map(fn(word) -> if String.starts_with?(word, "http://") || String.starts_with?(word, "https://") do uri = URI.parse(word) if uri.scheme && uri.host do if Enum.any?(state.hosts, fn(host) -> String.ends_with?(uri.host, host) end) do outline_url = outline(word) line = "-> #{outline_url}" message.replyfun.(line) end end end end) {:noreply, state} end def handle_info(msg, state) do {:noreply, state} end def save(state = %{file: file, hosts: hosts}) do string = Enum.join(hosts, "\n") File.write(file, string) end def outline(url) do unexpanded = "https://outline.com/#{url}" headers = [ {"User-Agent", "Mozilla/5.0 (X11; Linux x86_64; rv:77.0) Gecko/20100101 Firefox/77.0"}, {"Accept", "*/*"}, {"Accept-Language", "en-US,en;q=0.5"}, {"Origin", "https://outline.com"}, {"DNT", "1"}, {"Referer", unexpanded}, {"Pragma", "no-cache"}, {"Cache-Control", "no-cache"} ] params = %{"source_url" => url} case HTTPoison.get("https://api.outline.com/v3/parse_article", headers, params: params) do {:ok, %HTTPoison.Response{status_code: 200, body: json}} -> body = Poison.decode!(json) if Map.get(body, "success") do code = get_in(body, ["data", "short_code"]) "https://outline.com/#{code}" else unexpanded end error -> Logger.info("outline.com error: #{inspect error}") unexpanded end end end diff --git a/lib/nola_plugins/preums_plugin.ex b/lib/nola_plugins/preums.ex similarity index 98% rename from lib/nola_plugins/preums_plugin.ex rename to lib/nola_plugins/preums.ex index f250e85..80c5af0 100644 --- a/lib/nola_plugins/preums_plugin.ex +++ b/lib/nola_plugins/preums.ex @@ -1,276 +1,276 @@ -defmodule Nola.IRC.PreumsPlugin do +defmodule Nola.Plugins.Preums do @moduledoc """ # preums !!! * `!preums`: affiche le preums du jour * `.preums`: stats des preums """ # WIP Scores # L'idée c'est de donner un score pour mettre un peu de challenge en pénalisant les preums faciles. # # Un preums ne vaut pas 1 point, mais plutôt 0.10 ou 0.05, et on arrondi au plus proche. C'est un jeu sur le long # terme. Un gros bonus pourrait apporter beaucoup de points. # # Il faudrait ces données: # - moyenne des preums # - activité récente du channel et par nb actifs d'utilisateurs # (aggréger memberships+usertrack last_active ?) # (faire des stats d'activité habituelle (un peu a la pisg) ?) # - preums consécutifs # # Malus: # - est proche de la moyenne en faible activité # - trop consécutif de l'utilisateur sauf si activité # # Bonus: # - plus le preums est éloigné de la moyenne # - après 18h double # - plus l'activité est élévée, exponentiel selon la moyenne # - derns entre 4 et 6 (pourrait être adapté selon les stats d'activité) # # WIP Badges: # - derns # - streaks # - faciles # - ? require Logger @perfects [~r/preum(s|)/i] # dets {{chan, day = {yyyy, mm, dd}}, nick, now, perfect?, text} def all(dets) do :dets.foldl(fn(i, acc) -> [i|acc] end, [], dets) end def all(dets, channel) do fun = fn({{chan, date}, account_id, time, perfect, text}, acc) -> if channel == chan do [%{date: date, account_id: account_id, time: time, perfect: perfect, text: text} | acc] else acc end end :dets.foldl(fun, [], dets) end def topnicks(dets, channel, options \\ []) do sort_elem = case Keyword.get(options, :sort_by, :score) do :score -> 1 :count -> 0 end fun = fn(x = {{chan, date}, account_id, time, perfect, text}, acc) -> if (channel == nil and chan) or (channel == chan) do {count, points} = Map.get(acc, account_id, {0, 0}) score = score(chan, account_id, time, perfect, text) Map.put(acc, account_id, {count + 1, points + score}) else acc end end :dets.foldl(fun, %{}, dets) |> Enum.sort_by(fn({_account_id, value}) -> elem(value, sort_elem) end, &>=/2) end def irc_doc, do: @moduledoc def start_link() do GenServer.start_link(__MODULE__, [], name: __MODULE__) end def dets do (Nola.data_path() <> "/preums.dets") |> String.to_charlist() end def init([]) do regopts = [plugin: __MODULE__] {:ok, _} = Registry.register(IRC.PubSub, "account", regopts) {:ok, _} = Registry.register(IRC.PubSub, "messages", regopts) {:ok, _} = Registry.register(IRC.PubSub, "triggers", regopts) {:ok, dets} = :dets.open_file(dets(), [{:repair, :force}]) Util.ets_mutate_select_each(:dets, dets, [{:"$1", [], [:"$1"]}], fn(table, obj) -> {key, nick, now, perfect, text} = obj case key do {{net, {bork,chan}}, date} -> :dets.delete(table, key) nick = if IRC.Account.get(nick) do nick else if acct = IRC.Account.find_always_by_nick(net, nil, nick) do acct.id else nick end end :dets.insert(table, { { {net,chan}, date }, nick, now, perfect, text}) {{_net, nil}, _} -> :dets.delete(table, key) {{net, chan}, date} -> if !IRC.Account.get(nick) do if acct = IRC.Account.find_always_by_nick(net, chan, nick) do :dets.delete(table, key) :dets.insert(table, { { {net,chan}, date }, acct.id, now, perfect, text}) end end _ -> Logger.debug("DID NOT FIX: #{inspect key}") end end) {:ok, %{dets: dets}} end # Latest def handle_info({:irc, :trigger, "preums", m = %IRC.Message{trigger: %IRC.Trigger{type: :bang}}}, state) do channelkey = {m.network, m.channel} state = handle_preums(m, state) tz = timezone(channelkey) {:ok, now} = DateTime.now(tz, Tzdata.TimeZoneDatabase) date = {now.year, now.month, now.day} key = {channelkey, date} chan_cache = Map.get(state, channelkey, %{}) item = if i = Map.get(chan_cache, date) do i else case :dets.lookup(state.dets, key) do [item = {^key, _account_id, _now, _perfect, _text}] -> item _ -> nil end end if item do {_, account_id, date, _perfect, text} = item h = "#{date.hour}:#{date.minute}:#{date.second}" account = IRC.Account.get(account_id) user = IRC.UserTrack.find_by_account(m.network, account) nick = if(user, do: user.nick, else: account.name) m.replyfun.("preums: #{nick} à #{h}: “#{text}”") end {:noreply, state} end # Stats def handle_info({:irc, :trigger, "preums", m = %IRC.Message{trigger: %IRC.Trigger{type: :dot}}}, state) do channel = {m.network, m.channel} state = handle_preums(m, state) top = topnicks(state.dets, channel, sort_by: :score) |> Enum.map(fn({account_id, {count, score}}) -> account = IRC.Account.get(account_id) user = IRC.UserTrack.find_by_account(m.network, account) nick = if(user, do: user.nick, else: account.name) "#{nick}: #{score} (#{count})" end) |> Enum.intersperse(", ") |> Enum.join("") msg = unless top == "" do "top preums: #{top}" else "vous êtes tous nuls" end m.replyfun.(msg) {:noreply, state} end # Help def handle_info({:irc, :trigger, "preums", m = %IRC.Message{trigger: %IRC.Trigger{type: :query}}}, state) do state = handle_preums(m, state) msg = "!preums - preums du jour, .preums top preumseurs" m.replymsg.(msg) {:noreply, state} end # Trigger fallback def handle_info({:irc, :trigger, _, m = %IRC.Message{}}, state) do state = handle_preums(m, state) {:noreply, state} end # Message fallback def handle_info({:irc, :text, m = %IRC.Message{}}, state) do {:noreply, handle_preums(m, state)} end # Account def handle_info({:account_change, old_id, new_id}, state) do spec = [{{:_, :"$1", :_, :_, :_}, [{:==, :"$1", {:const, old_id}}], [:"$_"]}] Util.ets_mutate_select_each(:dets, state.dets, spec, fn(table, obj) -> rename_object_owner(table, obj, new_id) end) {:noreply, state} end # Account: move from nick to account id # FIXME: Doesn't seem to work. def handle_info({:accounts, accounts}, state) do for x={:account, _net, _chan, _nick, _account_id} <- accounts do handle_info(x, state) end {:noreply, state} end def handle_info({:account, _net, _chan, nick, account_id}, state) do nick = String.downcase(nick) spec = [{{:_, :"$1", :_, :_, :_}, [{:==, :"$1", {:const, nick}}], [:"$_"]}] Util.ets_mutate_select_each(:dets, state.dets, spec, fn(table, obj) -> Logger.debug("account:: merging #{nick} -> #{account_id}") rename_object_owner(table, obj, account_id) end) {:noreply, state} end def handle_info(_, dets) do {:noreply, dets} end defp rename_object_owner(table, object = {key, _, now, perfect, time}, new_id) do :dets.delete_object(table, key) :dets.insert(table, {key, new_id, now, perfect, time}) end defp timezone(channel) do - env = Application.get_env(:nola, Nola.IRC.PreumsPlugin, []) + env = Application.get_env(:nola, Nola.Plugins.Preums, []) channels = Keyword.get(env, :channels, %{}) channel_settings = Map.get(channels, channel, []) default = Keyword.get(env, :default_tz, "Europe/Paris") Keyword.get(channel_settings, :tz, default) || default end defp handle_preums(%IRC.Message{channel: nil}, state) do state end defp handle_preums(m = %IRC.Message{text: text, sender: sender}, state) do channel = {m.network, m.channel} tz = timezone(channel) {:ok, now} = DateTime.now(tz, Tzdata.TimeZoneDatabase) date = {now.year, now.month, now.day} key = {channel, date} chan_cache = Map.get(state, channel, %{}) unless i = Map.get(chan_cache, date) do case :dets.lookup(state.dets, key) do [item = {^key, _nick, _now, _perfect, _text}] -> # Preums lost, but wasn't cached Map.put(state, channel, %{date => item}) [] -> # Preums won! perfect? = Enum.any?(@perfects, fn(perfect) -> Regex.match?(perfect, text) end) item = {key, m.account.id, now, perfect?, text} :dets.insert(state.dets, item) :dets.sync(state.dets) Map.put(state, channel, %{date => item}) {:error, _} = error -> Logger.error("#{__MODULE__} dets lookup failed: #{inspect error}") state end else state end end def score(_chan, _account, _time, _perfect, _text) do 1 end end diff --git a/lib/nola_plugins/quatre_cent_vingt_plugin.ex b/lib/nola_plugins/quatre_cent_vingt.ex similarity index 99% rename from lib/nola_plugins/quatre_cent_vingt_plugin.ex rename to lib/nola_plugins/quatre_cent_vingt.ex index 8953ea3..3331625 100644 --- a/lib/nola_plugins/quatre_cent_vingt_plugin.ex +++ b/lib/nola_plugins/quatre_cent_vingt.ex @@ -1,149 +1,149 @@ -defmodule Nola.IRC.QuatreCentVingtPlugin do +defmodule Nola.Plugins.QuatreCentVingt do require Logger @moduledoc """ # 420 * **!420**: recorde un nouveau 420. * **!420*x**: recorde un nouveau 420*x (*2 = 840, ...) (à vous de faire la multiplication). * **!420 pseudo**: stats du pseudo. """ @achievements %{ 1 => ["[le premier… il faut bien commencer un jour]"], 10 => ["T'en es seulement à 10 ? ╭∩╮(Ο_Ο)╭∩╮"], 42 => ["Bravo, et est-ce que autant de pétards t'on aidés à trouver la Réponse ? ٩(- ̮̮̃-̃)۶ [42]"], 100 => ["°º¤ø,¸¸,ø¤º°`°º¤ø,¸,ø¤°º¤ø,¸¸,ø¤º°`°º¤ø,¸ 100 °º¤ø,¸¸,ø¤º°`°º¤ø,¸,ø¤°º¤ø,¸¸,ø¤º°`°º¤ø,¸"], 115 => [" ۜ\(סּںסּَ` )/ۜ 115!!"] } @emojis [ "\\o/", "~o~", "~~o∞~~", "*\\o/*", "**\\o/**", "*ô*", ] @coeffs Range.new(1, 100) def irc_doc, do: @moduledoc def start_link, do: GenServer.start_link(__MODULE__, [], name: __MODULE__) def init(_) do for coeff <- @coeffs do {:ok, _} = Registry.register(IRC.PubSub, "trigger:#{420*coeff}", [plugin: __MODULE__]) end {:ok, _} = Registry.register(IRC.PubSub, "account", [plugin: __MODULE__]) dets_filename = (Nola.data_path() <> "/420.dets") |> String.to_charlist {:ok, dets} = :dets.open_file(dets_filename, [{:type,:bag},{:repair,:force}]) {:ok, dets} :ignore end for coeff <- @coeffs do qvc = to_string(420 * coeff) def handle_info({:irc, :trigger, unquote(qvc), m = %IRC.Message{trigger: %IRC.Trigger{args: [], type: :bang}}}, dets) do {count, last} = get_statistics_for_nick(dets, m.account.id) count = count + unquote(coeff) text = achievement_text(count) now = DateTime.to_unix(DateTime.utc_now())-1 # this is ugly for i <- Range.new(1, unquote(coeff)) do :ok = :dets.insert(dets, {m.account.id, now+i}) end last_s = if last do last_s = format_relative_timestamp(last) " (le dernier était #{last_s})" else "" end m.replyfun.("#{m.sender.nick} 420 +#{unquote(coeff)} #{text}#{last_s}") {:noreply, dets} end end def handle_info({:irc, :trigger, "420", m = %IRC.Message{trigger: %IRC.Trigger{args: [nick], type: :bang}}}, dets) do account = IRC.Account.find_by_nick(m.network, nick) if account do text = case get_statistics_for_nick(dets, m.account.id) do {0, _} -> "#{nick} n'a jamais !420 ... honte à lui." {count, last} -> last_s = format_relative_timestamp(last) "#{nick} 420: total #{count}, le dernier #{last_s}" end m.replyfun.(text) else m.replyfun.("je connais pas de #{nick}") end {:noreply, dets} end # Account def handle_info({:account_change, old_id, new_id}, dets) do spec = [{{:"$1", :_}, [{:==, :"$1", {:const, old_id}}], [:"$_"]}] Util.ets_mutate_select_each(:dets, dets, spec, fn(table, obj) -> rename_object_owner(table, obj, new_id) end) {:noreply, dets} end # Account: move from nick to account id def handle_info({:accounts, accounts}, dets) do for x={:account, _net, _chan, _nick, _account_id} <- accounts do handle_info(x, dets) end {:noreply, dets} end def handle_info({:account, _net, _chan, nick, account_id}, dets) do nick = String.downcase(nick) spec = [{{:"$1", :_}, [{:==, :"$1", {:const, nick}}], [:"$_"]}] Util.ets_mutate_select_each(:dets, dets, spec, fn(table, obj) -> Logger.debug("account:: merging #{nick} -> #{account_id}") rename_object_owner(table, obj, account_id) end) {:noreply, dets} end def handle_info(_, dets) do {:noreply, dets} end defp rename_object_owner(table, object = {_, at}, account_id) do :dets.delete_object(table, object) :dets.insert(table, {account_id, at}) end defp format_relative_timestamp(timestamp) do alias Timex.Format.DateTime.Formatters alias Timex.Timezone date = timestamp |> DateTime.from_unix! |> Timezone.convert("Europe/Paris") {:ok, relative} = Formatters.Relative.relative_to(date, Timex.now("Europe/Paris"), "{relative}", "fr") {:ok, detail} = Formatters.Default.lformat(date, " ({h24}:{m})", "fr") relative <> detail end defp get_statistics_for_nick(dets, acct) do qvc = :dets.lookup(dets, acct) |> Enum.sort count = Enum.reduce(qvc, 0, fn(_, acc) -> acc + 1 end) {_, last} = List.last(qvc) || {nil, nil} {count, last} end @achievements_keys Map.keys(@achievements) defp achievement_text(count) when count in @achievements_keys do Enum.random(Map.get(@achievements, count)) end defp achievement_text(count) do emoji = Enum.random(@emojis) "#{emoji} [#{count}]" end end diff --git a/lib/nola_plugins/radio_france_plugin.ex b/lib/nola_plugins/radio_france.ex similarity index 99% rename from lib/nola_plugins/radio_france_plugin.ex rename to lib/nola_plugins/radio_france.ex index c2e966f..2096de8 100644 --- a/lib/nola_plugins/radio_france_plugin.ex +++ b/lib/nola_plugins/radio_france.ex @@ -1,133 +1,133 @@ -defmodule Nola.IRC.RadioFrancePlugin do +defmodule Nola.Plugins.RadioFrance do require Logger def irc_doc() do """ # radio france Qu'est ce qu'on écoute sur radio france ? * **!radiofrance `[station]`, !rf `[station]`** * **!fip, !inter, !info, !bleu, !culture, !musique, !fip `[sous-station]`, !bleu `[région]`** """ end def start_link() do GenServer.start_link(__MODULE__, [], name: __MODULE__) end @trigger "radiofrance" @shortcuts ~w(fip inter info bleu culture musique) def init(_) do regopts = [plugin: __MODULE__] {:ok, _} = Registry.register(IRC.PubSub, "trigger:radiofrance", regopts) {:ok, _} = Registry.register(IRC.PubSub, "trigger:rf", regopts) for s <- @shortcuts do {:ok, _} = Registry.register(IRC.PubSub, "trigger:#{s}", regopts) end {:ok, nil} end def handle_info({:irc, :trigger, "rf", m = %IRC.Message{trigger: %IRC.Trigger{type: :bang}}}, state) do handle_info({:irc, :trigger, "radiofrance", m}, state) end def handle_info({:irc, :trigger, @trigger, m = %IRC.Message{trigger: %IRC.Trigger{type: :bang, args: []}}}, state) do m.replyfun.("radiofrance: précisez la station!") {:noreply, state} end def handle_info({:irc, :trigger, @trigger, m = %IRC.Message{trigger: %IRC.Trigger{type: :bang, args: args}}}, state) do now(args_to_station(args), m) {:noreply, state} end def handle_info({:irc, :trigger, trigger, m = %IRC.Message{trigger: %IRC.Trigger{type: :bang, args: args}}}, state) when trigger in @shortcuts do now(args_to_station([trigger | args]), m) {:noreply, state} end defp args_to_station(args) do args |> Enum.map(&unalias/1) |> Enum.map(&String.downcase/1) |> Enum.join("_") end def handle_info(info, state) do Logger.debug("unhandled info: #{inspect info}") {:noreply, state} end defp now(station, m) when is_binary(station) do case HTTPoison.get(np_url(station), [], []) do {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> json = Poison.decode!(body) song? = !!get_in(json, ["now", "song"]) station = reformat_station_name(get_in(json, ["now", "stationName"])) now_title = get_in(json, ["now", "firstLine", "title"]) now_subtitle = get_in(json, ["now", "secondLine", "title"]) next_title = get_in(json, ["next", "firstLine", "title"]) next_subtitle = get_in(json, ["next", "secondLine", "title"]) next_song? = !!get_in(json, ["next", "song"]) next_at = get_in(json, ["next", "startTime"]) now = format_title(song?, now_title, now_subtitle) prefix = if song?, do: "🎶", else: "🎤" m.replyfun.("#{prefix} #{station}: #{now}") next = format_title(song?, next_title, next_subtitle) if next do next_prefix = if next_at do next_date = DateTime.from_unix!(next_at) in_seconds = DateTime.diff(next_date, DateTime.utc_now()) in_minutes = ceil(in_seconds / 60) if in_minutes >= 5 do if next_song?, do: "#{in_minutes}m 🔜", else: "dans #{in_minutes} minutes:" else if next_song?, do: "🔜", else: "suivi de:" end else if next_song?, do: "🔜", else: "à suivre:" end m.replyfun.("#{next_prefix} #{next}") end {:error, %HTTPoison.Response{status_code: 404}} -> m.replyfun.("radiofrance: la radio \"#{station}\" n'existe pas") {:error, %HTTPoison.Response{status_code: code}} -> m.replyfun.("radiofrance: erreur http #{code}") _ -> m.replyfun.("radiofrance: ça n'a pas marché, rip") end end defp np_url(station), do: "https://www.radiofrance.fr/api/v2.0/stations/#{station}/live" defp unalias("inter"), do: "franceinter" defp unalias("info"), do: "franceinfo" defp unalias("bleu"), do: "francebleu" defp unalias("culture"), do: "franceculture" defp unalias("musique"), do: "francemusique" defp unalias(station), do: station defp format_title(_, nil, nil) do nil end defp format_title(true, title, artist) do [artist, title] |> Enum.filter(& &1) |> Enum.join(" - ") end defp format_title(false, show, section) do [show, section] |> Enum.filter(& &1) |> Enum.join(": ") end defp reformat_station_name(station) do station |> String.replace("france", "france ") |> String.replace("_", " ") end end diff --git a/lib/nola_plugins/say_plugin.ex b/lib/nola_plugins/say.ex similarity index 98% rename from lib/nola_plugins/say_plugin.ex rename to lib/nola_plugins/say.ex index 915b0f6..91ffacc 100644 --- a/lib/nola_plugins/say_plugin.ex +++ b/lib/nola_plugins/say.ex @@ -1,73 +1,73 @@ -defmodule Nola.IRC.SayPlugin do +defmodule Nola.Plugins.Say do def irc_doc do """ # say Say something... * **!say `` ``** say something on `channel` * **!asay `` ``** same but anonymously You must be a member of the channel. """ end def start_link() do GenServer.start_link(__MODULE__, [], name: __MODULE__) end def init([]) do regopts = [type: __MODULE__] {:ok, _} = Registry.register(IRC.PubSub, "trigger:say", regopts) {:ok, _} = Registry.register(IRC.PubSub, "trigger:asay", regopts) {:ok, _} = Registry.register(IRC.PubSub, "messages:private", regopts) {:ok, nil} end def handle_info({:irc, :trigger, "say", m = %{trigger: %{type: :bang, args: [target | text]}}}, state) do text = Enum.join(text, " ") say_for(m.account, target, text, true) {:noreply, state} end def handle_info({:irc, :trigger, "asay", m = %{trigger: %{type: :bang, args: [target | text]}}}, state) do text = Enum.join(text, " ") say_for(m.account, target, text, false) {:noreply, state} end def handle_info({:irc, :text, m = %{text: "say "<>rest}}, state) do case String.split(rest, " ", parts: 2) do [target, text] -> say_for(m.account, target, text, true) _ -> nil end {:noreply, state} end def handle_info({:irc, :text, m = %{text: "asay "<>rest}}, state) do case String.split(rest, " ", parts: 2) do [target, text] -> say_for(m.account, target, text, false) _ -> nil end {:noreply, state} end def handle_info(_, state) do {:noreply, state} end defp say_for(account, target, text, with_nick?) do for {net, chan} <- IRC.Membership.of_account(account) do chan2 = String.replace(chan, "#", "") if (target == "#{net}/#{chan}" || target == "#{net}/#{chan2}" || target == chan || target == chan2) do if with_nick? do IRC.send_message_as(account, net, chan, text) else IRC.Connection.broadcast_message(net, chan, text) end end end end end diff --git a/lib/nola_plugins/script_plugin.ex b/lib/nola_plugins/script.ex similarity index 96% rename from lib/nola_plugins/script_plugin.ex rename to lib/nola_plugins/script.ex index 94d4edf..d383abb 100644 --- a/lib/nola_plugins/script_plugin.ex +++ b/lib/nola_plugins/script.ex @@ -1,42 +1,42 @@ -defmodule Nola.IRC.ScriptPlugin do +defmodule Nola.Plugins.Script do require Logger @moduledoc """ Allows to run outside scripts. Scripts are expected to be long running and receive/send data as JSON over stdin/stdout. """ @ircdoc """ # script Allows to run an outside script. * **+script `` `[command]`** défini/lance un script * **-script ``** arrête un script * **-script del ``** supprime un script """ def irc_doc, do: @ircdoc def start_link() do GenServer.start_link(__MODULE__, [], name: __MODULE__) end def init([]) do {:ok, _} = Registry.register(IRC.PubSub, "trigger:script", [plugin: __MODULE__]) dets_filename = (Nola.data_path() <> "/" <> "scripts.dets") |> String.to_charlist {:ok, dets} = :dets.open_file(dets_filename, []) {:ok, %{dets: dets}} end def handle_info({:irc, :trigger, "script", m = %{trigger: %{type: :plus, args: [name | args]}}}, state) do end def handle_info({:irc, :trigger, "script", m = %{trigger: %{type: :minus, args: args}}}, state) do case args do ["del", name] -> :ok #prout [name] -> :ok#stop end end end diff --git a/lib/nola_plugins/seen_plugin.ex b/lib/nola_plugins/seen.ex similarity index 98% rename from lib/nola_plugins/seen_plugin.ex rename to lib/nola_plugins/seen.ex index 2a4d0dd..65af0c1 100644 --- a/lib/nola_plugins/seen_plugin.ex +++ b/lib/nola_plugins/seen.ex @@ -1,59 +1,59 @@ -defmodule Nola.IRC.SeenPlugin do +defmodule Nola.Plugins.Seen do @moduledoc """ # seen * **!seen ``** """ def irc_doc, do: @moduledoc def start_link() do GenServer.start_link(__MODULE__, [], name: __MODULE__) end def init([]) do regopts = [plugin: __MODULE__] {:ok, _} = Registry.register(IRC.PubSub, "triggers", regopts) {:ok, _} = Registry.register(IRC.PubSub, "messages", regopts) dets_filename = (Nola.data_path() <> "/seen.dets") |> String.to_charlist() {:ok, dets} = :dets.open_file(dets_filename, []) {:ok, %{dets: dets}} end def handle_info({:irc, :trigger, "seen", m = %IRC.Message{trigger: %IRC.Trigger{type: :bang, args: [nick]}}}, state) do witness(m, state) m.replyfun.(last_seen(m.channel, nick, state)) {:noreply, state} end def handle_info({:irc, :trigger, _, m}, state) do witness(m, state) {:noreply, state} end def handle_info({:irc, :text, m}, state) do witness(m, state) {:noreply, state} end defp witness(%IRC.Message{channel: channel, text: text, sender: %{nick: nick}}, %{dets: dets}) do :dets.insert(dets, {{channel, nick}, DateTime.utc_now(), text}) :ok end defp last_seen(channel, nick, %{dets: dets}) do case :dets.lookup(dets, {channel, nick}) do [{_, date, text}] -> diff = round(DateTime.diff(DateTime.utc_now(), date)/60) cond do diff >= 30 -> duration = Timex.Duration.from_minutes(diff) format = Timex.Format.Duration.Formatter.lformat(duration, "fr", :humanized) "#{nick} a parlé pour la dernière fois il y a #{format}: “#{text}”" true -> "#{nick} est là..." end [] -> "je ne connais pas de #{nick}" end end end diff --git a/lib/nola_plugins/sms_plugin.ex b/lib/nola_plugins/sms.ex similarity index 99% rename from lib/nola_plugins/sms_plugin.ex rename to lib/nola_plugins/sms.ex index d8f7387..ad16f78 100644 --- a/lib/nola_plugins/sms_plugin.ex +++ b/lib/nola_plugins/sms.ex @@ -1,165 +1,165 @@ -defmodule Nola.IRC.SmsPlugin do +defmodule Nola.Plugins.Sms do @moduledoc """ ## sms * **!sms `` ``** envoie un SMS. """ def short_irc_doc, do: false def irc_doc, do: @moduledoc require Logger def incoming(from, "enable "<>key) do key = String.trim(key) account = IRC.Account.find_meta_account("sms-validation-code", String.downcase(key)) if account do net = IRC.Account.get_meta(account, "sms-validation-target") IRC.Account.put_meta(account, "sms-number", from) IRC.Account.delete_meta(account, "sms-validation-code") IRC.Account.delete_meta(account, "sms-validation-number") IRC.Account.delete_meta(account, "sms-validation-target") IRC.Connection.broadcast_message(net, account, "SMS Number #{from} added!") send_sms(from, "Yay! Number linked to account #{account.name}") end end def incoming(from, message) do account = IRC.Account.find_meta_account("sms-number", from) if account do reply_fun = fn(text) -> send_sms(from, text) end trigger_text = if Enum.any?(IRC.Connection.triggers(), fn({trigger, _}) -> String.starts_with?(message, trigger) end) do message else "!"<>message end message = %IRC.Message{ id: FlakeId.get(), transport: :sms, network: "sms", channel: nil, text: message, account: account, sender: %ExIRC.SenderInfo{nick: account.name}, replyfun: reply_fun, trigger: IRC.Connection.extract_trigger(trigger_text) } Logger.debug("converted sms to message: #{inspect message}") IRC.Connection.publish(message, ["messages:sms"]) message end end def my_number() do Keyword.get(Application.get_env(:nola, :sms, []), :number, "+33000000000") end def start_link() do GenServer.start_link(__MODULE__, [], name: __MODULE__) end def path() do account = Keyword.get(Application.get_env(:nola, :sms), :account) "https://eu.api.ovh.com/1.0/sms/#{account}" end def path(rest) do Path.join(path(), rest) end def send_sms(number, text) do url = path("/virtualNumbers/#{my_number()}/jobs") body = %{ "message" => text, "receivers" => [number], #"senderForResponse" => true, #"noStopClause" => true, "charset" => "UTF-8", "coding" => "8bit" } |> Poison.encode!() headers = [{"content-type", "application/json"}] ++ sign("POST", url, body) options = [] case HTTPoison.post(url, body, headers, options) do {:ok, %HTTPoison.Response{status_code: 200}} -> :ok {:ok, %HTTPoison.Response{status_code: code} = resp} -> Logger.error("SMS Error: #{inspect resp}") {:error, code} {:error, error} -> {:error, error} end end def init([]) do {:ok, _} = Registry.register(IRC.PubSub, "trigger:sms", [plugin: __MODULE__]) :ok = register_ovh_callback() {:ok, %{}} :ignore end def handle_info({:irc, :trigger, "sms", m = %IRC.Message{trigger: %IRC.Trigger{type: :bang, args: [nick | text]}}}, state) do with \ {:tree, false} <- {:tree, m.sender.nick == "Tree"}, {_, %IRC.Account{} = account} <- {:account, IRC.Account.find_always_by_nick(m.network, m.channel, nick)}, {_, number} when not is_nil(number) <- {:number, IRC.Account.get_meta(account, "sms-number")} do text = Enum.join(text, " ") sender = if m.channel do "#{m.channel} <#{m.sender.nick}> " else "<#{m.sender.nick}> " end case send_sms(number, sender<>text) do :ok -> m.replyfun.("sent!") {:error, error} -> m.replyfun.("not sent, error: #{inspect error}") end else {:tree, _} -> m.replyfun.("Tree: va en enfer") {:account, _} -> m.replyfun.("#{nick} not known") {:number, _} -> m.replyfun.("#{nick} have not enabled sms") end {:noreply, state} end def handle_info(msg, state) do {:noreply, state} end defp register_ovh_callback() do url = path() body = %{ "callBack" =>NolaWeb.Router.Helpers.sms_url(NolaWeb.Endpoint, :ovh_callback), "smsResponse" => %{ "cgiUrl" => NolaWeb.Router.Helpers.sms_url(NolaWeb.Endpoint, :ovh_callback), "responseType" => "cgi" } } |> Poison.encode!() headers = [{"content-type", "application/json"}] ++ sign("PUT", url, body) options = [] case HTTPoison.put(url, body, headers, options) do {:ok, %HTTPoison.Response{status_code: 200}} -> :ok error -> error end end defp sign(method, url, body) do ts = DateTime.utc_now() |> DateTime.to_unix() as = env(:app_secret) ck = env(:consumer_key) sign = Enum.join([as, ck, String.upcase(method), url, body, ts], "+") sign_hex = :crypto.hash(:sha, sign) |> Base.encode16(case: :lower) headers = [{"X-OVH-Application", env(:app_key)}, {"X-OVH-Timestamp", ts}, {"X-OVH-Signature", "$1$"<>sign_hex}, {"X-Ovh-Consumer", ck}] end def parse_number(num) do {:error, :todo} end defp env() do Application.get_env(:nola, :sms) end defp env(key) do Keyword.get(env(), key) end end diff --git a/lib/nola_plugins/tell_plugin.ex b/lib/nola_plugins/tell.ex similarity index 99% rename from lib/nola_plugins/tell_plugin.ex rename to lib/nola_plugins/tell.ex index ecc98df..8978c82 100644 --- a/lib/nola_plugins/tell_plugin.ex +++ b/lib/nola_plugins/tell.ex @@ -1,106 +1,106 @@ -defmodule Nola.IRC.TellPlugin do +defmodule Nola.Plugins.Tell do use GenServer @moduledoc """ # Tell * **!tell `` ``**: tell `message` to `nick` when they reconnect. """ def irc_doc, do: @moduledoc def start_link() do GenServer.start_link(__MODULE__, [], name: __MODULE__) end def dets do (Nola.data_path() <> "/tell.dets") |> String.to_charlist() end def tell(m, target, message) do GenServer.cast(__MODULE__, {:tell, m, target, message}) end def init([]) do regopts = [plugin: __MODULE__] {:ok, _} = Registry.register(IRC.PubSub, "account", regopts) {:ok, _} = Registry.register(IRC.PubSub, "trigger:tell", regopts) {:ok, dets} = :dets.open_file(dets(), [type: :bag]) {:ok, %{dets: dets}} end def handle_cast({:tell, m, target, message}, state) do do_tell(state, m, target, message) {:noreply, state} end def handle_info({:irc, :trigger, "tell", m = %IRC.Message{trigger: %IRC.Trigger{type: :bang, args: [target | message]}}}, state) do do_tell(state, m, target, message) {:noreply, state} end def handle_info({:account, network, channel, nick, account_id}, state) do messages = :dets.lookup(state.dets, {network, channel, account_id}) if messages != [] do strs = Enum.map(messages, fn({_, from, message, at}) -> account = IRC.Account.get(from) user = IRC.UserTrack.find_by_account(network, account) fromnick = if user, do: user.nick, else: account.name "#{nick}: <#{fromnick}> #{message}" end) Enum.each(strs, fn(s) -> IRC.Connection.broadcast_message(network, channel, s) end) :dets.delete(state.dets, {network, channel, account_id}) end {:noreply, state} end def handle_info({:account_change, old_id, new_id}, state) do #:ets.fun2ms(fn({ {_net, _chan, target_id}, from_id, _, _} = obj) when (target_id == old_id) or (from_id == old_id) -> obj end) spec = [{{{:"$1", :"$2", :"$3"}, :"$4", :_, :_}, [{:orelse, {:==, :"$3", {:const, old_id}}, {:==, :"$4", {:const, old_id}}}], [:"$_"]}] Util.ets_mutate_select_each(:dets, state.dets, spec, fn(table, obj) -> case obj do { {net, chan, ^old_id}, from_id, message, at } = obj -> :dets.delete(obj) :dets.insert(table, {{net, chan, new_id}, from_id, message, at}) {key, ^old_id, message, at} = obj -> :dets.delete(table, obj) :dets.insert(table, {key, new_id, message, at}) _ -> :ok end end) {:noreply, state} end def handle_info(info, state) do {:noreply, state} end def terminate(_, state) do :dets.close(state.dets) :ok end defp do_tell(state, m, nick_target, message) do target = IRC.Account.find_always_by_nick(m.network, m.channel, nick_target) message = Enum.join(message, " ") with \ {:target, %IRC.Account{} = target} <- {:target, target}, {:same, false} <- {:same, target.id == m.account.id}, target_user = IRC.UserTrack.find_by_account(m.network, target), target_nick = if(target_user, do: target_user.nick, else: target.name), present? = if(target_user, do: Map.has_key?(target_user.last_active, m.channel)), {:absent, true, _} <- {:absent, !present?, target_nick}, {:message, message} <- {:message, message} do obj = { {m.network, m.channel, target.id}, m.account.id, message, NaiveDateTime.utc_now()} :dets.insert(state.dets, obj) m.replyfun.("will tell to #{target_nick}") else {:same, _} -> m.replyfun.("are you so stupid that you need a bot to tell yourself things ?") {:target, _} -> m.replyfun.("#{nick_target} unknown") {:absent, _, nick} -> m.replyfun.("#{nick} is here, tell yourself!") {:message, _} -> m.replyfun.("can't tell without a message") end end end diff --git a/lib/nola_plugins/txt_plugin.ex b/lib/nola_plugins/txt.ex similarity index 99% rename from lib/nola_plugins/txt_plugin.ex rename to lib/nola_plugins/txt.ex index cab912a..f6e71a3 100644 --- a/lib/nola_plugins/txt_plugin.ex +++ b/lib/nola_plugins/txt.ex @@ -1,556 +1,556 @@ -defmodule Nola.IRC.TxtPlugin do +defmodule Nola.Plugins.Txt do alias IRC.UserTrack require Logger @moduledoc """ # [txt]({{context_path}}/txt) * **.txt**: liste des fichiers et statistiques. Les fichiers avec une `*` sont vérrouillés. [Voir sur le web]({{context_path}}/txt). * **!txt**: lis aléatoirement une ligne dans tous les fichiers. * **!txt ``**: recherche une ligne dans tous les fichiers. * **~txt**: essaie de générer une phrase (markov). * **~txt ``**: essaie de générer une phrase commencant par ``. * **!`FICHIER`**: lis aléatoirement une ligne du fichier `FICHIER`. * **!`FICHIER` ``**: lis la ligne `` du fichier `FICHIER`. * **!`FICHIER` ``**: recherche une ligne contenant `` dans `FICHIER`. * **+txt `**: crée le fichier ``. * **+`FICHIER` ``**: ajoute une ligne `` dans le fichier `FICHIER`. * **-`FICHIER` ``**: supprime la ligne `` du fichier `FICHIER`. * **-txtrw, +txtrw**. op seulement. active/désactive le mode lecture seule. * **+txtlock ``, -txtlock ``**. op seulement. active/désactive le verrouillage d'un fichier. Insérez `\\\\` pour faire un saut de ligne. """ def short_irc_doc, do: "!txt https://sys.115ans.net/irc/txt " def irc_doc, do: @moduledoc def start_link() do GenServer.start_link(__MODULE__, [], name: __MODULE__) end defstruct triggers: %{}, rw: true, locks: nil, markov_handler: nil, markov: nil def random(file) do GenServer.call(__MODULE__, {:random, file}) end def reply_random(message, file) do if line = random(file) do line |> format_line(nil, message) |> message.replyfun.() line end end def init([]) do dets_locks_filename = (Nola.data_path() <> "/" <> "txtlocks.dets") |> String.to_charlist {:ok, locks} = :dets.open_file(dets_locks_filename, []) - markov_handler = Keyword.get(Application.get_env(:nola, __MODULE__, []), :markov_handler, Nola.IRC.TxtPlugin.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(IRC.PubSub, "triggers", [plugin: __MODULE__]) {:ok, %__MODULE__{locks: locks, markov_handler: markov_handler, markov: markov, triggers: load()}} end def handle_info({:received, "!reload", _, chan}, state) do {:noreply, %__MODULE__{state | triggers: load()}} end # # ADMIN: RW/RO # def handle_info({:irc, :trigger, "txtrw", msg = %{channel: channel, trigger: %{type: :plus}}}, state = %{rw: false}) do if channel && UserTrack.operator?(msg.network, channel, msg.sender.nick) do msg.replyfun.("txt: écriture réactivée") {:noreply, %__MODULE__{state | rw: true}} else {:noreply, state} end end def handle_info({:irc, :trigger, "txtrw", msg = %{channel: channel, trigger: %{type: :minus}}}, state = %{rw: true}) do if channel && UserTrack.operator?(msg.network, channel, msg.sender.nick) do msg.replyfun.("txt: écriture désactivée") {:noreply, %__MODULE__{state | rw: false}} else {:noreply, state} end end # # ADMIN: LOCKS # def handle_info({:irc, :trigger, "txtlock", msg = %{trigger: %{type: :plus, args: [trigger]}}}, state) do with \ {trigger, _} <- clean_trigger(trigger), true <- UserTrack.operator?(msg.network, msg.channel, msg.sender.nick) do :dets.insert(state.locks, {trigger}) msg.replyfun.("txt: #{trigger} verrouillé") end {:noreply, state} end def handle_info({:irc, :trigger, "txtlock", msg = %{trigger: %{type: :minus, args: [trigger]}}}, state) do with \ {trigger, _} <- clean_trigger(trigger), true <- UserTrack.operator?(msg.network, msg.channel, msg.sender.nick), true <- :dets.member(state.locks, trigger) do :dets.delete(state.locks, trigger) msg.replyfun.("txt: #{trigger} déverrouillé") end {:noreply, state} end # # FILE LIST # def handle_info({:irc, :trigger, "txt", msg = %{trigger: %{type: :dot}}}, state) do map = Enum.map(state.triggers, fn({key, data}) -> ignore? = String.contains?(key, ".") locked? = case :dets.lookup(state.locks, key) do [{trigger}] -> "*" _ -> "" end unless ignore?, do: "#{key}: #{to_string(Enum.count(data))}#{locked?}" end) |> Enum.filter(& &1) total = Enum.reduce(state.triggers, 0, fn({_, data}, acc) -> acc + Enum.count(data) end) detail = Enum.join(map, ", ") total = ". total: #{Enum.count(state.triggers)} fichiers, #{to_string(total)} lignes. Détail: https://sys.115ans.net/irc/txt" ro = if !state.rw, do: " (lecture seule activée)", else: "" (detail<>total<>ro) |> msg.replyfun.() {:noreply, state} end # # GLOBAL: RANDOM # def handle_info({:irc, :trigger, "txt", msg = %{trigger: %{type: :bang, args: []}}}, state) do result = Enum.reduce(state.triggers, [], fn({trigger, data}, acc) -> Enum.reduce(data, acc, fn({l, _}, acc) -> [{trigger, l} | acc] end) end) |> Enum.shuffle() if !Enum.empty?(result) do {source, line} = Enum.random(result) msg.replyfun.(format_line(line, "#{source}: ", msg)) end {:noreply, state} end def handle_info({:irc, :trigger, "txt", msg = %{trigger: %{type: :bang, args: args}}}, state) do grep = Enum.join(args, " ") |> String.downcase |> :unicode.characters_to_nfd_binary() result = with_stateful_results(msg, {:bang,"txt",msg.network,msg.channel,grep}, fn() -> Enum.reduce(state.triggers, [], fn({trigger, data}, acc) -> if !String.contains?(trigger, ".") do Enum.reduce(data, acc, fn({l, _}, acc) -> [{trigger, l} | acc] end) else acc end end) |> Enum.filter(fn({_, line}) -> line |> String.downcase() |> :unicode.characters_to_nfd_binary() |> String.contains?(grep) end) |> Enum.shuffle() end) if result do {source, line} = result msg.replyfun.(["#{source}: " | line]) end {:noreply, state} end def with_stateful_results(msg, key, initfun) do me = self() scope = {msg.network, msg.channel || msg.sender.nick} key = {__MODULE__, me, scope, key} with_stateful_results(key, initfun) end def with_stateful_results(key, initfun) do pid = case :global.whereis_name(key) do :undefined -> start_stateful_results(key, initfun.()) pid -> pid end if pid, do: wait_stateful_results(key, initfun, pid) end def start_stateful_results(key, []) do nil end def start_stateful_results(key, list) do me = self() {pid, _} = spawn_monitor(fn() -> Process.monitor(me) stateful_results(me, list) end) :yes = :global.register_name(key, pid) pid end def wait_stateful_results(key, initfun, pid) do send(pid, :get) receive do {:stateful_results, line} -> line {:DOWN, _ref, :process, ^pid, reason} -> with_stateful_results(key, initfun) after 5000 -> nil end end defp stateful_results(owner, []) do send(owner, :empty) :ok end @stateful_results_expire :timer.minutes(30) defp stateful_results(owner, [line | rest] = acc) do receive do :get -> send(owner, {:stateful_results, line}) stateful_results(owner, rest) {:DOWN, _ref, :process, ^owner, _} -> :ok after @stateful_results_expire -> :ok end end # # GLOBAL: MARKOV # def handle_info({:irc, :trigger, "txt", msg = %{trigger: %{type: :tilde, args: []}}}, state) do case state.markov_handler.sentence(state.markov) do {:ok, line} -> msg.replyfun.(line) error -> Logger.error "Txt Markov error: "<>inspect error end {:noreply, state} end def handle_info({:irc, :trigger, "txt", msg = %{trigger: %{type: :tilde, args: complete}}}, state) do complete = Enum.join(complete, " ") case state.markov_handler.complete_sentence(complete, state.markov) do {:ok, line} -> msg.replyfun.(line) error -> Logger.error "Txt Markov error: "<>inspect error end {:noreply, state} end # # TXT CREATE # def handle_info({:irc, :trigger, "txt", msg = %{trigger: %{type: :plus, args: [trigger]}}}, state) do with \ {trigger, _} <- clean_trigger(trigger), true <- can_write?(state, msg, trigger), :ok <- create_file(trigger) do msg.replyfun.("#{trigger}.txt créé. Ajouter: `+#{trigger} …` ; Lire: `!#{trigger}`") {:noreply, %__MODULE__{state | triggers: load()}} else _ -> {:noreply, state} end end # # TXT: RANDOM # def handle_info({:irc, :trigger, trigger, m = %{trigger: %{type: :query, args: opts}}}, state) do {trigger, _} = clean_trigger(trigger) if Map.get(state.triggers, trigger) do url = if m.channel do NolaWeb.Router.Helpers.irc_url(NolaWeb.Endpoint, :txt, m.network, NolaWeb.format_chan(m.channel), trigger) else NolaWeb.Router.Helpers.irc_url(NolaWeb.Endpoint, :txt, trigger) end m.replyfun.("-> #{url}") end {:noreply, state} end def handle_info({:irc, :trigger, trigger, msg = %{trigger: %{type: :bang, args: opts}}}, state) do {trigger, _} = clean_trigger(trigger) line = get_random(msg, state.triggers, trigger, String.trim(Enum.join(opts, " "))) if line do msg.replyfun.(format_line(line, nil, msg)) end {:noreply, state} end # # TXT: ADD # def handle_info({:irc, :trigger, trigger, msg = %{trigger: %{type: :plus, args: content}}}, state) do with \ true <- can_write?(state, msg, trigger), {:ok, idx} <- add(state.triggers, msg.text) do msg.replyfun.("#{msg.sender.nick}: ajouté à #{trigger}. (#{idx})") {:noreply, %__MODULE__{state | triggers: load()}} else {:error, {:jaro, string, idx}} -> msg.replyfun.("#{msg.sender.nick}: doublon #{trigger}##{idx}: #{string}") error -> Logger.debug("txt add failed: #{inspect error}") {:noreply, state} end end # # TXT: DELETE # def handle_info({:irc, :trigger, trigger, msg = %{trigger: %{type: :minus, args: [id]}}}, state) do with \ true <- can_write?(state, msg, trigger), data <- Map.get(state.triggers, trigger), {id, ""} <- Integer.parse(id), {text, _id} <- Enum.find(data, fn({_, idx}) -> id-1 == idx end) do data = data |> Enum.into(Map.new) data = Map.delete(data, text) msg.replyfun.("#{msg.sender.nick}: #{trigger}.txt##{id} supprimée: #{text}") dump(trigger, data) {:noreply, %__MODULE__{state | triggers: load()}} else _ -> {:noreply, state} end end def handle_info(:reload_markov, state=%__MODULE__{triggers: triggers, markov: markov}) do state.markov_handler.reload(state.triggers, state.markov) {:noreply, state} end def handle_info(msg, state) do {:noreply, state} end def handle_call({:random, file}, _from, state) do random = get_random(nil, state.triggers, file, []) {:reply, random, state} end def terminate(_reason, state) do if state.locks do :dets.sync(state.locks) :dets.close(state.locks) end :ok end # Load/Reloads text files from disk defp load() do triggers = Path.wildcard(directory() <> "/*.txt") |> Enum.reduce(%{}, fn(path, m) -> file = Path.basename(path) key = String.replace(file, ".txt", "") data = directory() <> file |> File.read! |> String.split("\n") |> Enum.reject(fn(line) -> cond do line == "" -> true !line -> true true -> false end end) |> Enum.with_index Map.put(m, key, data) end) |> Enum.sort |> Enum.into(Map.new) send(self(), :reload_markov) triggers end defp dump(trigger, data) do data = data |> Enum.sort_by(fn({_, idx}) -> idx end) |> Enum.map(fn({text, _}) -> text end) |> Enum.join("\n") File.write!(directory() <> "/" <> trigger <> ".txt", data<>"\n", []) end defp get_random(msg, triggers, trigger, []) do if data = Map.get(triggers, trigger) do {data, _idx} = Enum.random(data) data else nil end end defp get_random(msg, triggers, trigger, opt) do arg = case Integer.parse(opt) do {pos, ""} -> {:index, pos} {_pos, _some_string} -> {:grep, opt} _error -> {:grep, opt} end get_with_param(msg, triggers, trigger, arg) end defp get_with_param(msg, triggers, trigger, {:index, pos}) do data = Map.get(triggers, trigger, %{}) case Enum.find(data, fn({_, index}) -> index+1 == pos end) do {text, _} -> text _ -> nil end end defp get_with_param(msg, triggers, trigger, {:grep, query}) do out = with_stateful_results(msg, {:grep, trigger, query}, fn() -> data = Map.get(triggers, trigger, %{}) regex = Regex.compile!("#{query}", "i") Enum.filter(data, fn({txt, _}) -> Regex.match?(regex, txt) end) |> Enum.map(fn({txt, _}) -> txt end) |> Enum.shuffle() end) if out, do: out end defp create_file(name) do File.touch!(directory() <> "/" <> name <> ".txt") :ok end defp add(triggers, trigger_and_content) do case String.split(trigger_and_content, " ", parts: 2) do [trigger, content] -> {trigger, _} = clean_trigger(trigger) if Map.has_key?(triggers, trigger) do jaro = Enum.find(triggers[trigger], fn({string, idx}) -> String.jaro_distance(content, string) > 0.9 end) if jaro do {string, idx} = jaro {:error, {:jaro, string, idx}} else File.write!(directory() <> "/" <> trigger <> ".txt", content<>"\n", [:append]) idx = Enum.count(triggers[trigger])+1 {:ok, idx} end else {:error, :notxt} end _ -> {:error, :badarg} end end # fixme: this is definitely the ugliest thing i've ever done defp clean_trigger(trigger) do [trigger | opts] = trigger |> String.strip |> String.split(" ", parts: 2) trigger = trigger |> String.downcase |> :unicode.characters_to_nfd_binary() |> String.replace(~r/[^a-z0-9._]/, "") |> String.trim(".") |> String.trim("_") {trigger, opts} end def format_line(line, prefix, msg) do prefix = unless(prefix, do: "", else: prefix) prefix <> line |> String.split("\\\\") |> Enum.map(fn(line) -> String.split(line, "\\\\\\\\") end) |> List.flatten() |> Enum.map(fn(line) -> String.trim(line) |> Tmpl.render(msg) end) end def directory() do Application.get_env(:nola, :data_path) <> "/irc.txt/" end defp can_write?(%{rw: rw?, locks: locks}, msg = %{channel: nil, sender: sender}, trigger) do admin? = IRC.admin?(sender) locked? = case :dets.lookup(locks, trigger) do [{trigger}] -> true _ -> false end unlocked? = if rw? == false, do: false, else: !locked? can? = unlocked? || admin? if !can? do reason = if !rw?, do: "lecture seule", else: "fichier vérrouillé" msg.replyfun.("#{sender.nick}: permission refusée (#{reason})") end can? end defp can_write?(state = %__MODULE__{rw: rw?, locks: locks}, msg = %{channel: channel, sender: sender}, trigger) do admin? = IRC.admin?(sender) operator? = IRC.UserTrack.operator?(msg.network, channel, sender.nick) locked? = case :dets.lookup(locks, trigger) do [{trigger}] -> true _ -> false end unlocked? = if rw? == false, do: false, else: !locked? can? = admin? || operator? || unlocked? if !can? do reason = if !rw?, do: "lecture seule", else: "fichier vérrouillé" msg.replyfun.("#{sender.nick}: permission refusée (#{reason})") end can? end end diff --git a/lib/nola_plugins/txt_plugin/markov.ex b/lib/nola_plugins/txt/markov.ex similarity index 87% rename from lib/nola_plugins/txt_plugin/markov.ex rename to lib/nola_plugins/txt/markov.ex index 2e30dfa..b47666c 100644 --- a/lib/nola_plugins/txt_plugin/markov.ex +++ b/lib/nola_plugins/txt/markov.ex @@ -1,9 +1,9 @@ -defmodule Nola.IRC.TxtPlugin.Markov do +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} end diff --git a/lib/nola_plugins/txt_plugin/markov_native.ex b/lib/nola_plugins/txt/markov_native.ex similarity index 88% rename from lib/nola_plugins/txt_plugin/markov_native.ex rename to lib/nola_plugins/txt/markov_native.ex index 4c403c2..aa6b454 100644 --- a/lib/nola_plugins/txt_plugin/markov_native.ex +++ b/lib/nola_plugins/txt/markov_native.ex @@ -1,33 +1,33 @@ -defmodule Nola.IRC.TxtPlugin.MarkovNative do - @behaviour Nola.IRC.TxtPlugin.Markov +defmodule Nola.Plugins.Txt.MarkovNative do + @behaviour Nola.Plugins.Txt.Markov def start_link() do ExChain.MarkovModel.start_link() end def reload(data, markov) do data = data |> Enum.map(fn({_, data}) -> for {line, _idx} <- data, do: line end) |> List.flatten ExChain.MarkovModel.populate_model(markov, data) :ok end def sentence(markov) do case ExChain.SentenceGenerator.create_filtered_sentence(markov) do {:ok, line, _, _} -> {:ok, line} error -> error end end def complete_sentence(sentence, markov) do case ExChain.SentenceGenerator.complete_sentence(markov, sentence) do {line, _} -> {:ok, line} error -> error end end end diff --git a/lib/nola_plugins/txt_plugin/markov_py_markovify.ex b/lib/nola_plugins/txt/markov_py_markovify.ex similarity index 78% rename from lib/nola_plugins/txt_plugin/markov_py_markovify.ex rename to lib/nola_plugins/txt/markov_py_markovify.ex index b610ea8..f79ed47 100644 --- a/lib/nola_plugins/txt_plugin/markov_py_markovify.ex +++ b/lib/nola_plugins/txt/markov_py_markovify.ex @@ -1,39 +1,39 @@ -defmodule Nola.IRC.TxtPlugin.MarkovPyMarkovify do +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.IRC.TxtPlugin.directory()) | args] + args = [script, Path.expand(Nola.Plugins.Txt.directory()) | 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.IRC.TxtPlugin, []) + 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/nola_plugins/untappd_plugin.ex b/lib/nola_plugins/untappd.ex similarity index 98% rename from lib/nola_plugins/untappd_plugin.ex rename to lib/nola_plugins/untappd.ex index 50b0c4d..cf5c694 100644 --- a/lib/nola_plugins/untappd_plugin.ex +++ b/lib/nola_plugins/untappd.ex @@ -1,66 +1,66 @@ -defmodule Nola.IRC.UntappdPlugin do +defmodule Nola.Plugins.Untappd do def irc_doc() do """ # [Untappd](https://untappd.com) * `!beer ` Information about the first beer matching `` * `?beer ` List the 10 firsts beer matching `` _Note_: The best way to search is always "Brewery Name + Beer Name", such as "Dogfish 60 Minute". Link your Untappd account to the bot (for automated checkins on [alcoolog](#alcoolog), ...) with the `enable-untappd` command, in private. """ end def start_link() do GenServer.start_link(__MODULE__, [], name: __MODULE__) end def init(_) do {:ok, _} = Registry.register(IRC.PubSub, "trigger:beer", [plugin: __MODULE__]) {:ok, %{}} end def handle_info({:irc, :trigger, _, m = %IRC.Message{trigger: %{type: :bang, args: args}}}, state) do case Untappd.search_beer(Enum.join(args, " "), limit: 1) do {:ok, %{"response" => %{"beers" => %{"count" => count, "items" => [result | _]}}}} -> %{"beer" => beer, "brewery" => brewery} = result description = Map.get(beer, "beer_description") |> String.replace("\n", " ") |> String.replace("\r", " ") |> String.trim() beer_s = "#{Map.get(brewery, "brewery_name")}: #{Map.get(beer, "beer_name")} - #{Map.get(beer, "beer_abv")}°" city = get_in(brewery, ["location", "brewery_city"]) location = [Map.get(brewery, "brewery_type"), city, Map.get(brewery, "country_name")] |> Enum.filter(fn(x) -> x end) |> Enum.join(", ") extra = "#{Map.get(beer, "beer_style")} - IBU: #{Map.get(beer, "beer_ibu")} - #{location}" m.replyfun.([beer_s, extra, description]) err -> m.replyfun.("Error") end {:noreply, state} end def handle_info({:irc, :trigger, _, m = %IRC.Message{trigger: %{type: :query, args: args}}}, state) do case Untappd.search_beer(Enum.join(args, " ")) do {:ok, %{"response" => %{"beers" => %{"count" => count, "items" => results}}}} -> beers = for %{"beer" => beer, "brewery" => brewery} <- results do "#{Map.get(brewery, "brewery_name")}: #{Map.get(beer, "beer_name")} - #{Map.get(beer, "beer_abv")}°" end |> Enum.intersperse(", ") |> Enum.join("") m.replyfun.("#{count}. #{beers}") err -> m.replyfun.("Error") end {:noreply, state} end def handle_info(info, state) do {:noreply, state} end end diff --git a/lib/nola_plugins/user_mention_plugin.ex b/lib/nola_plugins/user_mention.ex similarity index 90% rename from lib/nola_plugins/user_mention_plugin.ex rename to lib/nola_plugins/user_mention.ex index eb230fd..c4dd719 100644 --- a/lib/nola_plugins/user_mention_plugin.ex +++ b/lib/nola_plugins/user_mention.ex @@ -1,52 +1,52 @@ -defmodule Nola.IRC.UserMentionPlugin do +defmodule Nola.Plugins.UserMention do @moduledoc """ # mention * **@`` ``**: notifie si possible le nick immédiatement via Telegram, SMS, ou équivalent à `!tell`. """ require Logger def short_irc_doc, do: false def irc_doc, do: @moduledoc def start_link() do GenServer.start_link(__MODULE__, [], name: __MODULE__) end def init(_) do {:ok, _} = Registry.register(IRC.PubSub, "triggers", plugin: __MODULE__) {:ok, nil} end def handle_info({:irc, :trigger, nick, message = %IRC.Message{sender: sender, account: account, network: network, channel: channel, trigger: %IRC.Trigger{type: :at, args: content}}}, state) do nick = nick |> String.trim(":") |> String.trim(",") target = IRC.Account.find_always_by_nick(network, channel, nick) if target do telegram = IRC.Account.get_meta(target, "telegram-id") sms = IRC.Account.get_meta(target, "sms-number") text = "#{channel} <#{sender.nick}> #{Enum.join(content, " ")}" cond do telegram -> Nola.Telegram.send_message(telegram, "`#{channel}` <**#{sender.nick}**> #{Enum.join(content, " ")}") sms -> - case Nola.IRC.SmsPlugin.send_sms(sms, text) do + case Nola.Plugins.Sms.send_sms(sms, text) do {:error, code} -> message.replyfun("#{sender.nick}: erreur #{code} (sms)") end true -> - Nola.IRC.TellPlugin.tell(message, nick, content) + Nola.Plugins.Tell.tell(message, nick, content) end else message.replyfun.("#{nick} m'est inconnu") end {:noreply, state} end def handle_info(_, state) do {:noreply, state} end end diff --git a/lib/nola_plugins/wikipedia_plugin.ex b/lib/nola_plugins/wikipedia.ex similarity index 98% rename from lib/nola_plugins/wikipedia_plugin.ex rename to lib/nola_plugins/wikipedia.ex index 3202e13..dcd1874 100644 --- a/lib/nola_plugins/wikipedia_plugin.ex +++ b/lib/nola_plugins/wikipedia.ex @@ -1,90 +1,90 @@ -defmodule Nola.IRC.WikipediaPlugin do +defmodule Nola.Plugins.Wikipedia do require Logger @moduledoc """ # wikipédia * **!wp ``**: retourne le premier résultat de la `` Wikipedia * **!wp**: un article Wikipédia au hasard """ def irc_doc, do: @moduledoc def start_link() do GenServer.start_link(__MODULE__, [], name: __MODULE__) end def init(_) do {:ok, _} = Registry.register(IRC.PubSub, "trigger:wp", [plugin: __MODULE__]) {:ok, nil} end def handle_info({:irc, :trigger, _, message = %IRC.Message{trigger: %IRC.Trigger{type: :bang, args: []}}}, state) do irc_random(message) {:noreply, state} end def handle_info({:irc, :trigger, _, message = %IRC.Message{trigger: %IRC.Trigger{type: :bang, args: args}}}, state) do irc_search(Enum.join(args, " "), message) {:noreply, state} end def handle_info(info, state) do {:noreply, state} end defp irc_search("", message), do: irc_random(message) defp irc_search(query, message) do params = %{ "action" => "query", "list" => "search", "srsearch" => String.strip(query), "srlimit" => 1, } case query_wikipedia(params) do {:ok, %{"query" => %{"search" => [item | _]}}} -> title = item["title"] url = "https://fr.wikipedia.org/wiki/" <> String.replace(title, " ", "_") msg = "Wikipédia: #{title} — #{url}" message.replyfun.(msg) _ -> nil end end defp irc_random(message) do params = %{ "action" => "query", "generator" => "random", "grnnamespace" => 0, "prop" => "info" } case query_wikipedia(params) do {:ok, %{"query" => %{"pages" => map = %{}}}} -> [{_, item}] = Map.to_list(map) title = item["title"] url = "https://fr.wikipedia.org/wiki/" <> String.replace(title, " ", "_") msg = "Wikipédia: #{title} — #{url}" message.replyfun.(msg) _ -> nil end end defp query_wikipedia(params) do url = "https://fr.wikipedia.org/w/api.php" params = params |> Map.put("format", "json") |> Map.put("utf8", "") case HTTPoison.get(url, [], params: params) do {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> Jason.decode(body) {:ok, %HTTPoison.Response{status_code: 400, body: body}} -> Logger.error "Wikipedia HTTP 400: #{inspect body}" {:error, "http 400"} error -> Logger.error "Wikipedia http error: #{inspect error}" {:error, "http client error"} end end end diff --git a/lib/nola_plugins/wolfram_alpha_plugin.ex b/lib/nola_plugins/wolfram_alpha.ex similarity index 97% rename from lib/nola_plugins/wolfram_alpha_plugin.ex rename to lib/nola_plugins/wolfram_alpha.ex index 6ee06f0..c2f106e 100644 --- a/lib/nola_plugins/wolfram_alpha_plugin.ex +++ b/lib/nola_plugins/wolfram_alpha.ex @@ -1,47 +1,47 @@ -defmodule Nola.IRC.WolframAlphaPlugin do +defmodule Nola.Plugins.WolframAlpha do use GenServer require Logger @moduledoc """ # wolfram alpha * **`!wa `** lance `` sur WolframAlpha """ def irc_doc, do: @moduledoc def start_link() do GenServer.start_link(__MODULE__, [], name: __MODULE__) end def init(_) do {:ok, _} = Registry.register(IRC.PubSub, "trigger:wa", [plugin: __MODULE__]) {:ok, nil} end def handle_info({:irc, :trigger, _, m = %IRC.Message{trigger: %IRC.Trigger{type: :bang, args: query}}}, state) do query = Enum.join(query, " ") params = %{ "appid" => Keyword.get(Application.get_env(:nola, :wolframalpha, []), :app_id, "NO_APP_ID"), "units" => "metric", "i" => query } url = "https://www.wolframalpha.com/input/?i=" <> URI.encode(query) case HTTPoison.get("http://api.wolframalpha.com/v1/result", [], [params: params]) do {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> m.replyfun.(["#{query} -> #{body}", url]) {:ok, %HTTPoison.Response{status_code: code, body: body}} -> error = case {code, body} do {501, b} -> "input invalide: #{body}" {code, error} -> "erreur #{code}: #{body || ""}" end m.replyfun.("wa: #{error}") {:error, %HTTPoison.Error{reason: reason}} -> m.replyfun.("wa: erreur http: #{to_string(reason)}") _ -> m.replyfun.("wa: erreur http") end {:noreply, state} end end diff --git a/lib/nola_plugins/youtube_plugin.ex b/lib/nola_plugins/youtube.ex similarity index 98% rename from lib/nola_plugins/youtube_plugin.ex rename to lib/nola_plugins/youtube.ex index fb9bea2..c62f73c 100644 --- a/lib/nola_plugins/youtube_plugin.ex +++ b/lib/nola_plugins/youtube.ex @@ -1,104 +1,104 @@ -defmodule Nola.IRC.YouTubePlugin do +defmodule Nola.Plugins.YouTube do require Logger @moduledoc """ # youtube * **!yt ``**, !youtube ``: retourne le premier résultat de la `` YouTube """ defstruct client: nil def irc_doc, do: @moduledoc def start_link() do GenServer.start_link(__MODULE__, [], name: __MODULE__) end def init([]) do for t <- ["trigger:yt", "trigger:youtube"], do: {:ok, _} = Registry.register(IRC.PubSub, t, [plugin: __MODULE__]) {:ok, %__MODULE__{}} end def handle_info({:irc, :trigger, _, message = %IRC.Message{trigger: %IRC.Trigger{type: :bang, args: args}}}, state) do irc_search(Enum.join(args, " "), message) {:noreply, state} end def handle_info(info, state) do {:noreply, state} end defp irc_search(query, message) do case search(query) do {:ok, %{"items" => [item | _]}} -> url = "https://youtube.com/watch?v=" <> item["id"] snippet = item["snippet"] duration = item["contentDetails"]["duration"] |> String.replace("PT", "") |> String.downcase date = snippet["publishedAt"] |> DateTime.from_iso8601() |> elem(1) |> Timex.format("{relative}", :relative) |> elem(1) info_line = "— #{duration} — uploaded by #{snippet["channelTitle"]} — #{date}" <> " — #{item["statistics"]["viewCount"]} views, #{item["statistics"]["likeCount"]} likes," <> " #{item["statistics"]["dislikeCount"]} dislikes" message.replyfun.("#{snippet["title"]} — #{url}") message.replyfun.(info_line) {:error, error} -> message.replyfun.("Erreur YouTube: "<>error) _ -> nil end end defp search(query) do query = query |> String.strip key = Application.get_env(:nola, :youtube)[:api_key] params = %{ "key" => key, "maxResults" => 1, "part" => "id", "safeSearch" => "none", "type" => "video", "q" => query, } url = "https://www.googleapis.com/youtube/v3/search" case HTTPoison.get(url, [], params: params) do {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> {:ok, json} = Jason.decode(body) item = List.first(json["items"]) if item do video_id = item["id"]["videoId"] params = %{ "part" => "snippet,contentDetails,statistics", "id" => video_id, "key" => key } headers = [] options = [params: params] case HTTPoison.get("https://www.googleapis.com/youtube/v3/videos", [], options) do {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> Jason.decode(body) {:ok, %HTTPoison.Response{status_code: code, body: body}} -> Logger.error "YouTube HTTP #{code}: #{inspect body}" {:error, "http #{code}"} error -> Logger.error "YouTube http error: #{inspect error}" :error end else :error end {:ok, %HTTPoison.Response{status_code: code, body: body}} -> Logger.error "YouTube HTTP #{code}: #{inspect body}" {:error, "http #{code}"} error -> Logger.error "YouTube http error: #{inspect error}" :error end end end diff --git a/lib/nola_web/controllers/alcoolog_controller.ex b/lib/nola_web/controllers/alcoolog_controller.ex index 3081762..3c812c5 100644 --- a/lib/nola_web/controllers/alcoolog_controller.ex +++ b/lib/nola_web/controllers/alcoolog_controller.ex @@ -1,323 +1,323 @@ 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] def token(conn, %{"token" => token}) do case Nola.Token.lookup(token) do {:ok, {:alcoolog, :index, network, channel}} -> index(conn, nil, network, channel) err -> 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 profile_account = IRC.Account.find_always_by_nick(network, nick, nick) days = String.to_integer(Map.get(params, "days", "180")) friend? = Enum.member?(IRC.Membership.friends(account), profile_account.id) if friend? do - stats = Nola.IRC.AlcoologPlugin.get_full_statistics(profile_account.id) - history = for {{nick, ts}, points, active, cl, deg, type, descr, meta} <- Nola.IRC.AlcoologPlugin.nick_history(profile_account) 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() conn |> assign(:title, "alcoolog #{nick}") |> 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 profile_account = IRC.Account.find_always_by_nick(network, nick, nick) friend? = Enum.member?(IRC.Membership.friends(account), profile_account.id) if friend? do - stats = Nola.IRC.AlcoologPlugin.get_full_statistics(profile_account.id) + 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 profile_account = IRC.Account.find_always_by_nick(network, nick, nick) friend? = Enum.member?(IRC.Membership.friends(account), profile_account.id) count = String.to_integer(Map.get(params, "days", "180")) if friend? do - data = Nola.IRC.AlcoologPlugin.user_over_time_gl(profile_account, count) + 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)) 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 profile_account = IRC.Account.find_always_by_nick(network, nick, nick) friend? = Enum.member?(IRC.Membership.friends(account), profile_account.id) count = String.to_integer(Map.get(params, "days", "180")) if friend? do - data = Nola.IRC.AlcoologPlugin.user_over_time(profile_account, count) + 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)) 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 profile_account = IRC.Account.find_always_by_nick(network, nick, nick) friend? = Enum.member?(IRC.Membership.friends(account), profile_account.id) if friend? do - history = for {{nick, ts}, points, active, cl, deg, type, descr, meta} <- Nola.IRC.AlcoologPlugin.nick_history(profile_account) 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 last = List.last(history) - {_, active} = Nola.IRC.AlcoologPlugin.user_stats(profile_account) + {_, 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 profile_account = IRC.Account.find_always_by_nick(network, nick, nick) friend? = Enum.member?(IRC.Membership.friends(account), profile_account.id) if friend? do - history = for {_, date, value} <- Nola.IRC.AlcoologAnnouncerPlugin.log(profile_account) do + history = for {_, date, value} <- Nola.Plugs.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 # network = Map.get(params, "network") # chan = if c = Map.get(params, "chan") do # NolaWeb.reformat_chan(c) # end # irc_conn = if network do # 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 def index(conn, account, network, channel) do 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) match = [ {{{:_, :"$1"}, :_, :_, :_, :_, :_, :_, :_}, [ {:>, :"$1", {:const, before15}}, ], [:"$_"]} ] # tuple ets: {{nick, date}, volumes, current, nom, commentaire} members = IRC.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.IRC.AlcoologPlugin.ETS, match) + 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.IRC.AlcoologPlugin.get_channel_statistics(account, network, channel) + 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) # {date, single_peak} # conn |> assign(:title, "alcoolog") |> 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 count = 30 channel = NolaWeb.reformat_chan(channel) members = IRC.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) 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.IRC.AlcoologPlugin.user_over_time_gl(account, count), gls, fn({date, gl}, 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) 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})) end def minisync(conn, %{"user_id" => user_id, "key" => key, "value" => value}) do account = IRC.Account.get(user_id) if account do - ds = Nola.IRC.AlcoologPlugin.data_state() - meta = Nola.IRC.AlcoologPlugin.get_user_meta(ds, account.id) + 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.IRC.AlcoologPlugin.put_user_meta(ds, account.id, new_meta) + 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/nola_web/controllers/gpt_controller.ex b/lib/nola_web/controllers/gpt_controller.ex index 038b235..810a875 100644 --- a/lib/nola_web/controllers/gpt_controller.ex +++ b/lib/nola_web/controllers/gpt_controller.ex @@ -1,33 +1,33 @@ defmodule NolaWeb.GptController do use NolaWeb, :controller require Logger plug NolaWeb.ContextPlug def result(conn, params = %{"id" => result_id}) do - case Nola.IRC.GptPlugin.get_result(result_id) do + case Nola.Plugins.Gpt.get_result(result_id) do {:ok, result} -> network = Map.get(params, "network") channel = if c = Map.get(params, "chan"), do: NolaWeb.reformat_chan(c) render(conn, "result.html", network: network, channel: channel, result: result) {:error, :not_found} -> conn |> put_status(404) |> text("Page not found") end end def prompt(conn, params = %{"id" => prompt_id}) do - case Nola.IRC.GptPlugin.get_prompt(prompt_id) do + case Nola.Plugins.Gpt.get_prompt(prompt_id) do {:ok, prompt} -> network = Map.get(params, "network") channel = if c = Map.get(params, "chan"), do: NolaWeb.reformat_chan(c) render(conn, "prompt.html", network: network, channel: channel, prompt: prompt) {:error, :not_found} -> conn |> put_status(404) |> text("Page not found") end end end diff --git a/lib/nola_web/controllers/irc_controller.ex b/lib/nola_web/controllers/irc_controller.ex index c617e78..d6114e6 100644 --- a/lib/nola_web/controllers/irc_controller.ex +++ b/lib/nola_web/controllers/irc_controller.ex @@ -1,101 +1,101 @@ defmodule NolaWeb.IrcController do use NolaWeb, :controller 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([IRC.Account.AccountPlugin] ++ IRC.Plugin.enabled()) do + commands = for mod <- Enum.uniq([Nola.Plugins.Account] ++ Nola.Plugins.enabled()) do if is_atom(mod) do - identifier = Module.split(mod) |> List.last |> String.replace("Plugin", "") |> Macro.underscore + identifier = Module.split(mod) |> List.last |> Macro.underscore {identifier, mod.irc_doc()} end end |> Enum.filter(& &1) |> Enum.filter(fn({_, doc}) -> doc end) members = cond do network && channel -> Enum.map(IRC.UserTrack.channel(network, channel), fn(tuple) -> IRC.UserTrack.User.from_tuple(tuple) end) true -> IRC.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) defp do_txt(conn, nil) do - doc = Nola.IRC.TxtPlugin.irc_doc() + doc = Nola.IRC.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) conn |> assign(:title, "txt") |> 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 if lines = Map.get(data, txt) do 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) -> 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) Map.put(m, key, data) end) |> Enum.sort |> Enum.into(Map.new) end end diff --git a/lib/nola_web/controllers/sms_controller.ex b/lib/nola_web/controllers/sms_controller.ex index 575655c..0fffa23 100644 --- a/lib/nola_web/controllers/sms_controller.ex +++ b/lib/nola_web/controllers/sms_controller.ex @@ -1,10 +1,10 @@ defmodule NolaWeb.SmsController do use NolaWeb, :controller require Logger def ovh_callback(conn, %{"senderid" => from, "message" => message}) do - spawn(fn() -> Nola.IRC.SmsPlugin.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/nola_web/live/chat_live.ex b/lib/nola_web/live/chat_live.ex index 276b362..678ea36 100644 --- a/lib/nola_web/live/chat_live.ex +++ b/lib/nola_web/live/chat_live.ex @@ -1,120 +1,120 @@ defmodule NolaWeb.ChatLive do use Phoenix.LiveView use Phoenix.HTML require Logger def mount(%{"network" => network, "chan" => chan}, %{"account" => account_id}, socket) do chan = NolaWeb.reformat_chan(chan) connection = IRC.Connection.get_network(network, chan) account = IRC.Account.get(account_id) membership = IRC.Membership.of_account(IRC.Account.get("DRgpD4fLf8PDJMLp8Dtb")) if account && connection && Enum.member?(membership, {connection.network, chan}) do {:ok, _} = Registry.register(IRC.PubSub, "#{connection.network}:events", plugin: __MODULE__) for t <- ["messages", "triggers", "outputs", "events"] do {:ok, _} = Registry.register(IRC.PubSub, "#{connection.network}/#{chan}:#{t}", plugin: __MODULE__) end IRC.PuppetConnection.start(account, connection) users = IRC.UserTrack.channel(connection.network, chan) |> Enum.map(fn(tuple) -> IRC.UserTrack.User.from_tuple(tuple) end) |> Enum.reduce(Map.new, fn(user = %{id: id}, acc) -> Map.put(acc, id, user) end) - backlog = case Nola.IRC.BufferPlugin.select_buffer(connection.network, chan) do + backlog = case Nola.IRC.Buffer.select_buffer(connection.network, chan) do {backlog, _} -> {backlog, _} = Enum.reduce(backlog, {backlog, nil}, &reduce_contextual_event/2) Enum.reverse(backlog) _ -> [] end socket = socket |> assign(:connection_id, connection.id) |> assign(:network, connection.network) |> assign(:chan, chan) |> assign(:title, "live") |> assign(:channel, chan) |> assign(:account_id, account.id) |> assign(:backlog, backlog) |> assign(:users, users) |> assign(:counter, 0) {:ok, socket} else {:ok, redirect(socket, to: "/")} end end def handle_event("send", %{"message" => %{"text" => text}}, socket) do account = IRC.Account.get(socket.assigns.account_id) IRC.send_message_as(account, socket.assigns.network, socket.assigns.channel, text, true) {:noreply, assign(socket, :counter, socket.assigns.counter + 1)} end def handle_info({:irc, :event, event = %{type: :join, user_id: id}}, socket) do if user = IRC.UserTrack.lookup(id) do socket = socket |> assign(:users, Map.put(socket.assigns.users, id, user)) |> append_to_backlog(event) {:noreply, socket} else {:noreply, socket} end end def handle_info({:irc, :event, event = %{type: :nick, user_id: id, nick: nick}}, socket) do socket = socket |> assign(:users, update_in(socket.assigns.users, [id, :nick], nick)) |> append_to_backlog(event) {:noreply, socket} end def handle_info({:irc, :event, event = %{type: :quit, user_id: id}}, socket) do socket = socket |> assign(:users, Map.delete(socket.assigns.users, id)) |> append_to_backlog(event) {:noreply, socket} end def handle_info({:irc, :event, event = %{type: :part, user_id: id}}, socket) do socket = socket |> assign(:users, Map.delete(socket.assigns.users, id)) |> append_to_backlog(event) {:noreply, socket} end def handle_info({:irc, :trigger, _, message}, socket) do handle_info({:irc, nil, message}, socket) end def handle_info({:irc, :text, message}, socket) do IO.inspect({:live_message, message}) socket = socket |> append_to_backlog(message) {:noreply, socket} end def handle_info(info, socket) do Logger.debug("Unhandled info: #{inspect info}") {:noreply, socket} end defp append_to_backlog(socket, line) do {add, _} = reduce_contextual_event(line, {[], List.last(socket.assigns.backlog)}) assign(socket, :backlog, socket.assigns.backlog ++ add) end defp reduce_contextual_event(line, {acc, nil}) do {[line | acc], line} end defp reduce_contextual_event(line, {acc, last}) do if NaiveDateTime.to_date(last.at) != NaiveDateTime.to_date(line.at) do {[%{type: :day_changed, date: NaiveDateTime.to_date(line.at), at: nil}, line | acc], line} else {[line | acc], line} end end end