diff --git a/lib/irc/account.ex b/lib/irc/account.ex index 46c7f6e..45680f8 100644 --- a/lib/irc/account.ex +++ b/lib/irc/account.ex @@ -1,451 +1,451 @@ 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(LSG.data_path() <> "/account_#{base}.dets") + 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 = LSG.AuthToken.new_url(m.account.id, nil) + 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 = LSG.IRC.SmsPlugin.my_number() + 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 #{LSG.Telegram.my_path()} and send:" + 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 = LSG.AuthToken.new_url(m.account.id, {:external_redirect, 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/irc/connection.ex b/lib/irc/connection.ex index 9382714..86d8279 100644 --- a/lib/irc/connection.ex +++ b/lib/irc/connection.ex @@ -1,521 +1,521 @@ defmodule IRC.Connection do require Logger use Ecto.Schema @moduledoc """ # IRC Connection Provides a nicer abstraction over ExIRC's handlers. ## Start connections ``` IRC.Connection.start_link(host: "irc.random.sh", port: 6697, nick: "pouetbot", channels: ["#dev"]) ## PubSub topics * `account` -- accounts change * {:account_change, old_account_id, new_account_id} # Sent when account merged * {:accounts, [{:account, network, channel, nick, account_id}] # Sent on bot join * {:account, network, nick, account_id} # Sent on user join * `message` -- aill messages (without triggers) * `message:private` -- all messages without a channel * `message:#CHANNEL` -- all messages within `#CHANNEL` * `triggers` -- all triggers * `trigger:TRIGGER` -- any message with a trigger `TRIGGER` ## Replying to %IRC.Message{} Each `IRC.Message` comes with a dedicated `replyfun`, to which you only have to pass either: """ def irc_doc, do: nil @min_backoff :timer.seconds(5) @max_backoff :timer.seconds(2*60) embedded_schema do field :network, :string field :host, :string field :port, :integer field :nick, :string field :user, :string field :name, :string field :pass, :string field :tls, :boolean, default: false field :channels, {:array, :string}, default: [] end defmodule Supervisor do use DynamicSupervisor def start_link() do DynamicSupervisor.start_link(__MODULE__, [], name: __MODULE__) end def start_child(%IRC.Connection{} = conn) do spec = %{id: conn.id, start: {IRC.Connection, :start_link, [conn]}, restart: :transient} DynamicSupervisor.start_child(__MODULE__, spec) end @impl true def init(_init_arg) do DynamicSupervisor.init( strategy: :one_for_one, max_restarts: 10, max_seconds: 1 ) end end def changeset(params) do import Ecto.Changeset %__MODULE__{id: EntropyString.large_id()} |> cast(params, [:network, :host, :port, :nick, :user, :name, :pass, :channels, :tls]) |> validate_required([:host, :port, :nick, :user, :name]) |> apply_action(:insert) end def to_tuple(%__MODULE__{} = conn) do {conn.id, conn.network, conn.host, conn.port, conn.nick, conn.user, conn.name, conn.pass, conn.tls, conn.channels, nil} end def from_tuple({id, network, host, port, nick, user, name, pass, tls, channels, _}) do %__MODULE__{id: id, network: network, host: host, port: port, nick: nick, user: user, name: name, pass: pass, tls: tls, channels: channels} end ## -- MANAGER API def setup() do :dets.open_file(dets(), []) end - def dets(), do: to_charlist(LSG.data_path("/connections.dets")) + def dets(), do: to_charlist(Nola.data_path("/connections.dets")) def lookup(id) do case :dets.lookup(dets(), id) do [object | _] -> from_tuple(object) _ -> nil end end def connections() do :dets.foldl(fn(object, acc) -> [from_tuple(object) | acc] end, [], dets()) end def start_all() do for conn <- connections(), do: {conn, IRC.Connection.Supervisor.start_child(conn)} end def get_network(network, channel \\ nil) do spec = [{{:_, :"$1", :_, :_, :_, :_, :_, :_, :_, :_, :_}, [{:==, :"$1", {:const, network}}], [:"$_"]}] results = Enum.map(:dets.select(dets(), spec), fn(object) -> from_tuple(object) end) if channel do Enum.find(results, fn(conn) -> Enum.member?(conn.channels, channel) end) else List.first(results) end end def get_host_nick(host, port, nick) do spec = [{{:_, :_, :"$1", :"$2", :"$3", :_, :_, :_, :_, :_, :_}, [{:andalso, {:andalso, {:==, :"$1", {:const, host}}, {:==, :"$2", {:const, port}}}, {:==, :"$3", {:const, nick}}}], [:"$_"]} ] case :dets.select(dets(), spec) do [object] -> from_tuple(object) [] -> nil end end def delete_connection(%__MODULE__{id: id} = conn) do :dets.delete(dets(), id) stop_connection(conn) :ok end def start_connection(%__MODULE__{} = conn) do IRC.Connection.Supervisor.start_child(conn) end def stop_connection(%__MODULE__{id: id}) do case :global.whereis_name(id) do pid when is_pid(pid) -> GenServer.stop(pid, :normal) _ -> :error end end def add_connection(opts) do case changeset(opts) do {:ok, conn} -> if existing = get_host_nick(conn.host, conn.port, conn.nick) do {:error, {:existing, conn}} else :dets.insert(dets(), to_tuple(conn)) IRC.Connection.Supervisor.start_child(conn) end error -> error end end def update_connection(connection) do :dets.insert(dets(), to_tuple(connection)) end def start_link(conn) do GenServer.start_link(__MODULE__, [conn], name: {:global, conn.id}) end def broadcast_message(net, chan, message) do dispatch("conn", {:broadcast, net, chan, message}, IRC.ConnectionPubSub) end def broadcast_message(list, message) when is_list(list) do for {net, chan} <- list do broadcast_message(net, chan, message) end end def privmsg(channel, line) do GenServer.cast(__MODULE__, {:privmsg, channel, line}) end def init([conn]) do Logger.metadata(conn: conn.id) backoff = :backoff.init(@min_backoff, @max_backoff) |> :backoff.type(:jitter) {:ok, %{client: nil, backoff: backoff, conn: conn, connected_server: nil, connected_port: nil, network: conn.network}, {:continue, :connect}} end @triggers %{ "!" => :bang, "+" => :plus, "-" => :minus, "?" => :query, "." => :dot, "~" => :tilde, "@" => :at, "++" => :plus_plus, "--" => :minus_minus, "!!" => :bang_bang, "??" => :query_query, ".." => :dot_dot, "~~" => :tilde_tilde, "@@" => :at_at } def handle_continue(:connect, state) do client_opts = [] |> Keyword.put(:network, state.conn.network) {:ok, _} = Registry.register(IRC.ConnectionPubSub, "conn", []) client = if state.client && Process.alive?(state.client) do Logger.info("Reconnecting client") state.client else Logger.info("Connecting") {:ok, client} = ExIRC.Client.start_link(debug: false) ExIRC.Client.add_handler(client, self()) client end opts = [{:nodelay, true}] conn_fun = if state.conn.tls, do: :connect_ssl!, else: :connect! apply(ExIRC.Client, conn_fun, [client, to_charlist(state.conn.host), state.conn.port, opts]) {:noreply, %{state | client: client}} end def handle_info(:disconnected, state) do {delay, backoff} = :backoff.fail(state.backoff) Logger.info("#{inspect(self())} Disconnected -- reconnecting in #{inspect delay}ms") Process.send_after(self(), :connect, delay) {:noreply, %{state | backoff: backoff}} end def handle_info(:connect, state) do {:noreply, state, {:continue, :connect}} end def handle_cast({:privmsg, channel, line}, state) do irc_reply(state, {channel, nil}, line) {:noreply, state} end # Connection successful def handle_info({:connected, server, port}, state) do Logger.info("#{inspect(self())} Connected to #{inspect(server)}:#{port} #{inspect state}") {_, backoff} = :backoff.succeed(state.backoff) ExIRC.Client.logon(state.client, state.conn.pass || "", state.conn.nick, state.conn.user, state.conn.name) {:noreply, %{state | backoff: backoff, connected_server: server, connected_port: port}} end # Logon successful def handle_info(:logged_in, state) do Logger.info("#{inspect(self())} Logged in") {_, backoff} = :backoff.succeed(state.backoff) Enum.map(state.conn.channels, &ExIRC.Client.join(state.client, &1)) {:noreply, %{state | backoff: backoff}} end # ISUP def handle_info({:isup, network}, state) when is_binary(network) do IRC.UserTrack.clear_network(state.network) if network != state.network do Logger.warn("Possibly misconfigured network: #{network} != #{state.network}") end {:noreply, state} end # Been kicked def handle_info({:kicked, _sender, chan, _reason}, state) do ExIRC.Client.join(state.client, chan) {:noreply, state} end # Received something in a channel def handle_info({:received, text, sender, chan}, state) do user = if user = IRC.UserTrack.find_by_nick(state.network, sender.nick) do user else Logger.error("Could not lookup user for message: #{inspect {state.network, chan, sender.nick}}") user = IRC.UserTrack.joined(chan, sender, []) ExIRC.Client.who(state.client, chan) # Rewho everything in case of need ? We shouldn't not know that user.. user end if !user do ExIRC.Client.who(state.client, chan) # Rewho everything in case of need ? We shouldn't not know that user.. Logger.error("Could not lookup user nor create it for message: #{inspect {state.network, chan, sender.nick}}") else if !Map.get(user.options, :puppet) do reply_fun = fn(text) -> irc_reply(state, {chan, sender}, text) end account = IRC.Account.lookup(sender) message = %IRC.Message{id: FlakeId.get(), transport: :irc, at: NaiveDateTime.utc_now(), text: text, network: state.network, account: account, sender: sender, channel: chan, replyfun: reply_fun, trigger: extract_trigger(text)} message = case IRC.UserTrack.messaged(message) do :ok -> message {:ok, message} -> message end publish(message, ["#{message.network}/#{chan}:messages"]) end end {:noreply, state} end # Received a private message def handle_info({:received, text, sender}, state) do reply_fun = fn(text) -> irc_reply(state, {sender.nick, sender}, text) end account = IRC.Account.lookup(sender) message = %IRC.Message{id: FlakeId.get(), transport: :irc, text: text, network: state.network, at: NaiveDateTime.utc_now(), account: account, sender: sender, replyfun: reply_fun, trigger: extract_trigger(text)} message = case IRC.UserTrack.messaged(message) do :ok -> message {:ok, message} -> message end publish(message, ["messages:private", "#{message.network}/#{account.id}:messages"]) {:noreply, state} end ## -- Broadcast def handle_info({:broadcast, net, account = %IRC.Account{}, message}, state) do if net == state.conn.network do user = IRC.UserTrack.find_by_account(net, account) if user do irc_reply(state, {user.nick, nil}, message) end end {:noreply, state} end def handle_info({:broadcast, net, chan, message}, state) do if net == state.conn.network && Enum.member?(state.conn.channels, chan) do irc_reply(state, {chan, nil}, message) end {:noreply, state} end ## -- UserTrack def handle_info({:joined, channel}, state) do ExIRC.Client.who(state.client, channel) {:noreply, state} end def handle_info({:who, channel, whos}, state) do accounts = Enum.map(whos, fn(who = %ExIRC.Who{nick: nick, operator?: operator}) -> priv = if operator, do: [:operator], else: [] # Don't touch -- on WHO the bot joined, not the users. IRC.UserTrack.joined(channel, who, priv, false) account = IRC.Account.lookup(who) if account do {:account, who.network, channel, who.nick, account.id} end end) |> Enum.filter(fn(x) -> x end) dispatch("account", {:accounts, accounts}) {:noreply, state} end def handle_info({:quit, reason, sender}, state) do IRC.UserTrack.quitted(sender, reason) {:noreply, state} end def handle_info({:joined, channel, sender}, state) do IRC.UserTrack.joined(channel, sender, []) account = IRC.Account.lookup(sender) if account do dispatch("account", {:account, sender.network, channel, sender.nick, account.id}) end {:noreply, state} end def handle_info({:kicked, nick, _by, channel, _reason}, state) do IRC.UserTrack.parted(state.network, channel, nick) {:noreply, state} end def handle_info({:parted, channel, %ExIRC.SenderInfo{nick: nick}}, state) do IRC.UserTrack.parted(state.network, channel, nick) {:noreply, state} end def handle_info({:mode, [channel, mode, nick]}, state) do track_mode(state.network, channel, nick, mode) {:noreply, state} end def handle_info({:nick_changed, old_nick, new_nick}, state) do IRC.UserTrack.renamed(state.network, old_nick, new_nick) {:noreply, state} end def handle_info(unhandled, client) do Logger.debug("unhandled: #{inspect unhandled}") {:noreply, client} end def publish(pub), do: publish(pub, []) def publish(m = %IRC.Message{trigger: nil}, keys) do dispatch(["messages"] ++ keys, {:irc, :text, m}) end def publish(m = %IRC.Message{trigger: t = %IRC.Trigger{trigger: trigger}}, keys) do dispatch(["triggers", "#{m.network}/#{m.channel}:triggers", "trigger:"<>trigger], {:irc, :trigger, trigger, m}) end def publish_event(net, event = %{type: _}) when is_binary(net) do event = event |> Map.put(:at, NaiveDateTime.utc_now()) |> Map.put(:network, net) dispatch("#{net}:events", {:irc, :event, event}) end def publish_event({net, chan}, event = %{type: type}) do event = event |> Map.put(:at, NaiveDateTime.utc_now()) |> Map.put(:network, net) |> Map.put(:channel, chan) dispatch("#{net}/#{chan}:events", {:irc, :event, event}) end def dispatch(keys, content, sub \\ IRC.PubSub) def dispatch(key, content, sub) when is_binary(key), do: dispatch([key], content, sub) def dispatch(keys, content, sub) when is_list(keys) do Logger.debug("dispatch #{inspect keys} = #{inspect content}") for key <- keys do spawn(fn() -> Registry.dispatch(sub, key, fn h -> for {pid, _} <- h, do: send(pid, content) end) end) end end # # Triggers # def triggers, do: @triggers for {trigger, name} <- @triggers do def extract_trigger(unquote(trigger)<>text) do text = String.strip(text) [trigger | args] = String.split(text, " ") %IRC.Trigger{type: unquote(name), trigger: String.downcase(trigger), args: args} end end def extract_trigger(_), do: nil # # IRC Replies # # irc_reply(ExIRC.Client pid, {channel or nick, ExIRC.Sender}, binary | replies # replies :: {:kick, reason} | {:kick, nick, reason} | {:mode, mode, nick} defp irc_reply(state = %{client: client, network: network}, {target, _}, text) when is_binary(text) or is_list(text) do lines = IRC.splitlong(text) |> Enum.map(fn(x) -> if(is_list(x), do: x, else: String.split(x, "\n")) end) |> List.flatten() outputs = for line <- lines do ExIRC.Client.msg(client, :privmsg, target, line) {:irc, :out, %IRC.Message{id: FlakeId.get(), transport: :irc, network: network, channel: target, text: line, sender: %ExIRC.SenderInfo{nick: state.conn.nick}, at: NaiveDateTime.utc_now(), meta: %{self: true}}} end for f <- outputs, do: dispatch(["irc:outputs", "#{network}/#{target}:outputs"], f) end defp irc_reply(%{client: client}, {target, %{nick: nick}}, {:kick, reason}) do ExIRC.Client.kick(client, target, nick, reason) end defp irc_reply(%{client: client}, {target, _}, {:kick, nick, reason}) do ExIRC.Client.kick(client, target, nick, reason) end defp irc_reply(%{client: client}, {target, %{nick: nick}}, {:mode, mode}) do ExIRC.Client.mode(%{client: client}, target, mode, nick) end defp irc_reply(%{client: client}, target, {:mode, mode, nick}) do ExIRC.Client.mode(client, target, mode, nick) end defp irc_reply(%{client: client}, target, {:channel_mode, mode}) do ExIRC.Client.mode(client, target, mode) end defp track_mode(network, channel, nick, "+o") do IRC.UserTrack.change_privileges(network, channel, nick, {[:operator], []}) :ok end defp track_mode(network, channel, nick, "-o") do IRC.UserTrack.change_privileges(network, channel, nick, {[], [:operator]}) :ok end defp track_mode(network, channel, nick, "+v") do IRC.UserTrack.change_privileges(network, channel, nick, {[:voice], []}) :ok end defp track_mode(network, channel, nick, "-v") do IRC.UserTrack.change_privileges(network, channel, nick, {[], [:voice]}) :ok end defp track_mode(network, channel, nick, mode) do Logger.warn("Unhandled track_mode: #{inspect {nick, mode}}") :ok end defp server(%{conn: %{host: host, port: port}}) do host <> ":" <> to_string(port) end end diff --git a/lib/irc/irc.ex b/lib/irc/irc.ex index d9fdfb5..71d6d93 100644 --- a/lib/irc/irc.ex +++ b/lib/irc/irc.ex @@ -1,79 +1,79 @@ defmodule IRC do defmodule Message do @derive {Poison.Encoder, except: [:replyfun]} defstruct [:id, :text, {:transport, :irc}, :network, :account, :sender, :channel, :trigger, :replyfun, :at, {:meta, %{}} ] end defmodule Trigger do @derive Poison.Encoder defstruct [:type, :trigger, :args] end def send_message_as(account, network, channel, text, force_puppet \\ false) do connection = IRC.Connection.get_network(network) if connection && (force_puppet || IRC.PuppetConnection.whereis(account, connection)) do IRC.PuppetConnection.start_and_send_message(account, connection, channel, text) else user = IRC.UserTrack.find_by_account(network, account) nick = if(user, do: user.nick, else: account.name) IRC.Connection.broadcast_message(network, channel, "<#{nick}> #{text}") end end def register(key) do case Registry.register(IRC.PubSub, key, []) do {:ok, _} -> :ok error -> error end end def admin?(%Message{sender: sender}), do: admin?(sender) def admin?(%{nick: nick, user: user, host: host}) do - for {n, u, h} <- LSG.IRC.env(:admins, []) do + for {n, u, h} <- Nola.IRC.env(:admins, []) do admin_part_match?(n, nick) && admin_part_match?(u, user) && admin_part_match?(h, host) end |> Enum.any? end defp admin_part_match?(:_, _), do: true defp admin_part_match?(a, a), do: true defp admin_part_match?(_, _), do: false @max_chars 440 def splitlong(string, max_chars \\ 440) def splitlong(string, max_chars) when is_list(string) do Enum.map(string, fn(s) -> splitlong(s, max_chars) end) |> List.flatten() end def splitlong(string, max_chars) do string |> String.codepoints |> Enum.chunk_every(max_chars) |> Enum.map(&Enum.join/1) end def splitlong_with_prefix(string, prefix, max_chars \\ 440) do prefix = "#{prefix} " max_chars = max_chars - (length(String.codepoints(prefix))) string |> String.codepoints |> Enum.chunk_every(max_chars) |> Enum.map(fn(line) -> prefix <> Enum.join(line) end) end end diff --git a/lib/irc/membership.ex b/lib/irc/membership.ex index 74ed1b4..b727dfd 100644 --- a/lib/irc/membership.ex +++ b/lib/irc/membership.ex @@ -1,129 +1,129 @@ defmodule IRC.Membership do @moduledoc """ Memberships (users in channels) """ # Key: {account, net, channel} # Format: {key, last_seen} defp dets() do - to_charlist(LSG.data_path <> "/memberships.dets") + to_charlist(Nola.data_path <> "/memberships.dets") end def start_link() do GenServer.start_link(__MODULE__, [], [name: __MODULE__]) end def init(_) do dets = :dets.open_file(dets(), []) {:ok, dets} end def of_account(%IRC.Account{id: id}) do spec = [{{{:"$1", :"$2", :"$3"}, :_}, [{:==, :"$1", {:const, id}}], [{{:"$2", :"$3"}}]}] :dets.select(dets(), spec) end def merge_account(old_id, new_id) do #iex(37)> :ets.fun2ms(fn({{old_id, _, _}, _}=obj) when old_id == "42" -> obj end) spec = [{{{:"$1", :_, :_}, :_}, [{:==, :"$1", {:const, old_id}}], [:"$_"]}] Util.ets_mutate_select_each(:dets, dets(), spec, fn(table, obj = {{_old, net, chan}, ts}) -> :dets.delete_object(table, obj) :dets.insert(table, {{new_id, net, chan}, ts}) end) end def touch(%IRC.Account{id: id}, network, channel) do :dets.insert(dets(), {{id, network, channel}, NaiveDateTime.utc_now()}) end def touch(account_id, network, channel) do if account = IRC.Account.get(account_id) do touch(account, network, channel) end end def notify_channels(account, minutes \\ 30, last_active \\ true) do not_before = NaiveDateTime.add(NaiveDateTime.utc_now(), (minutes*-60), :second) spec = [{{{:"$1", :_, :_}, :_}, [{:==, :"$1", {:const, account.id}}], [:"$_"]}] memberships = :dets.select(dets(), spec) |> Enum.sort_by(fn({_, ts}) -> ts end, {:desc, NaiveDateTime}) active_memberships = Enum.filter(memberships, fn({_, ts}) -> NaiveDateTime.compare(ts, not_before) == :gt end) cond do active_memberships == [] && last_active -> case memberships do [{{_, net, chan}, _}|_] -> [{net, chan}] _ -> [] end active_memberships == [] -> [] true -> Enum.map(active_memberships, fn({{_, net, chan}, _}) -> {net,chan} end) end end def members_or_friends(account, _network, nil) do friends(account) end def members_or_friends(_, network, channel) do members(network, channel) end def expanded_members_or_friends(account, network, channel) do expand(network, members_or_friends(account, network, channel)) end def expanded_members(network, channel) do expand(network, members(network, channel)) end def members(network, channel) do #iex(19)> :ets.fun2ms(fn({{id, net, chan}, ts}) when net == network and chan == channel and ts > min_seen -> id end) limit = 0 # NaiveDateTime.add(NaiveDateTime.utc_now, 30*((24*-60)*60), :second) spec = [ {{{:"$1", :"$2", :"$3"}, :"$4"}, [ {:andalso, {:andalso, {:==, :"$2", {:const, network}}, {:==, :"$3", {:const, channel}}}, {:>, :"$4", {:const, limit}}} ], [:"$1"]} ] :dets.select(dets(), spec) end def friends(account = %IRC.Account{id: id}) do for({net, chan} <- of_account(account), do: members(net, chan)) |> List.flatten() |> Enum.uniq() end def handle_info(_, dets) do {:noreply, dets} end def handle_cast(_, dets) do {:noreply, dets} end def handle_call(_, _, dets) do {:noreply, dets} end def terminate(_, dets) do :dets.sync(dets) :dets.close(dets) end defp expand(network, list) do for id <- list do if account = IRC.Account.get(id) do user = IRC.UserTrack.find_by_account(network, account) nick = if(user, do: user.nick, else: account.name) {account, user, nick} end end |> Enum.filter(fn(x) -> x end) end end diff --git a/lib/irc/plugin_supervisor.ex b/lib/irc/plugin_supervisor.ex index 24ac683..a65ad09 100644 --- a/lib/irc/plugin_supervisor.ex +++ b/lib/irc/plugin_supervisor.ex @@ -1,99 +1,99 @@ defmodule IRC.Plugin 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} 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(LSG.data_path("/plugins.dets")) + 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)} 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) 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)}") :ignore end end end diff --git a/lib/irc/puppet_connection.ex b/lib/irc/puppet_connection.ex index f12cbf7..91a26b3 100644 --- a/lib/irc/puppet_connection.ex +++ b/lib/irc/puppet_connection.ex @@ -1,238 +1,238 @@ defmodule IRC.PuppetConnection do require Logger @min_backoff :timer.seconds(5) @max_backoff :timer.seconds(2*60) @max_idle :timer.hours(12) @env Mix.env defmodule Supervisor do use DynamicSupervisor def start_link() do DynamicSupervisor.start_link(__MODULE__, [], name: __MODULE__) end def start_child(%IRC.Account{id: account_id}, %IRC.Connection{id: connection_id}) do spec = %{id: {account_id, connection_id}, start: {IRC.PuppetConnection, :start_link, [account_id, connection_id]}, restart: :transient} DynamicSupervisor.start_child(__MODULE__, spec) end @impl true def init(_init_arg) do DynamicSupervisor.init( strategy: :one_for_one, max_restarts: 10, max_seconds: 1 ) end end def whereis(account = %IRC.Account{id: account_id}, connection = %IRC.Connection{id: connection_id}) do {:global, name} = name(account_id, connection_id) case :global.whereis_name(name) do :undefined -> nil pid -> pid end end def send_message(account = %IRC.Account{id: account_id}, connection = %IRC.Connection{id: connection_id}, channel, text) do GenServer.cast(name(account_id, connection_id), {:send_message, self(), channel, text}) end def start_and_send_message(account = %IRC.Account{id: account_id}, connection = %IRC.Connection{id: connection_id}, channel, text) do {:global, name} = name(account_id, connection_id) pid = whereis(account, connection) pid = if !pid do case IRC.PuppetConnection.Supervisor.start_child(account, connection) do {:ok, pid} -> pid {:error, {:already_started, pid}} -> pid end else pid end GenServer.cast(pid, {:send_message, self(), channel, text}) end def start(account = %IRC.Account{}, connection = %IRC.Connection{}) do IRC.PuppetConnection.Supervisor.start_child(account, connection) end def start_link(account_id, connection_id) do GenServer.start_link(__MODULE__, [account_id, connection_id], name: name(account_id, connection_id)) end def name(account_id, connection_id) do {:global, {PuppetConnection, account_id, connection_id}} end def init([account_id, connection_id]) do account = %IRC.Account{} = IRC.Account.get(account_id) connection = %IRC.Connection{} = IRC.Connection.lookup(connection_id) Logger.metadata(puppet_conn: account.id <> "@" <> connection.id) backoff = :backoff.init(@min_backoff, @max_backoff) |> :backoff.type(:jitter) idle = :erlang.send_after(@max_idle, self, :idle) {:ok, %{client: nil, backoff: backoff, idle: idle, connected: false, buffer: [], channels: [], connection_id: connection_id, account_id: account_id, connected_server: nil, connected_port: nil, network: connection.network}, {:continue, :connect}} end def handle_continue(:connect, state) do #ipv6 = if @env == :prod do - # subnet = LSG.Subnet.assign(state.account_id) + # subnet = Nola.Subnet.assign(state.account_id) # IRC.Account.put_meta(IRC.Account.get(state.account_id), "subnet", subnet) # ip = Pfx.host(subnet, 1) # {:ok, ipv6} = :inet_parse.ipv6_address(to_charlist(ip)) # System.cmd("add-ip6", [ip]) # ipv6 #end conn = IRC.Connection.lookup(state.connection_id) client_opts = [] |> Keyword.put(:network, conn.network) client = if state.client && Process.alive?(state.client) do Logger.info("Reconnecting client") state.client else Logger.info("Connecting") {:ok, client} = ExIRC.Client.start_link(debug: false) ExIRC.Client.add_handler(client, self()) client end base_opts = [ {:nodelay, true} ] #{ip, opts} = case {ipv6, :inet_res.resolve(to_charlist(conn.host), :in, :aaaa)} do # {ipv6, {:ok, {:dns_rec, _dns_header, _query, rrs = [{:dns_rr, _, _, _, _, _, _, _, _, _} | _], _, _}}} -> # ip = rrs # |> Enum.map(fn({:dns_rr, _, :aaaa, :in, _, _, ipv6, _, _, _}) -> ipv6 end) # |> Enum.shuffle() # |> List.first() # opts = [ # :inet6, # {:ifaddr, ipv6} # ] # {ip, opts} # _ -> {ip, opts} = {to_charlist(conn.host), []} #end conn_fun = if conn.tls, do: :connect_ssl!, else: :connect! apply(ExIRC.Client, conn_fun, [client, ip, conn.port, base_opts ++ opts]) {:noreply, %{state | client: client}} end def handle_continue(:connected, state) do state = Enum.reduce(Enum.reverse(state.buffer), state, fn(b, state) -> {:noreply, state} = handle_cast(b, state) state end) {:noreply, %{state | buffer: []}} end def handle_cast(cast = {:send_message, _pid, _channel, _text}, state = %{connected: false, buffer: buffer}) do {:noreply, %{state | buffer: [cast | buffer]}} end def handle_cast({:send_message, pid, channel, text}, state = %{connected: true}) do channels = if !Enum.member?(state.channels, channel) do ExIRC.Client.join(state.client, channel) [channel | state.channels] else state.channels end ExIRC.Client.msg(state.client, :privmsg, channel, text) meta = %{puppet: true, from: pid} account = IRC.Account.get(state.account_id) nick = make_nick(state) sender = %ExIRC.SenderInfo{network: state.network, nick: suffix_nick(nick), user: nick, host: "puppet."} reply_fun = fn(text) -> IRC.Connection.broadcast_message(state.network, channel, text) end message = %IRC.Message{id: FlakeId.get(), at: NaiveDateTime.utc_now(), text: text, network: state.network, account: account, sender: sender, channel: channel, replyfun: reply_fun, trigger: IRC.Connection.extract_trigger(text), meta: meta} message = case IRC.UserTrack.messaged(message) do :ok -> message {:ok, message} -> message end IRC.Connection.publish(message, ["#{message.network}/#{channel}:messages"]) idle = if length(state.buffer) == 0 do :erlang.cancel_timer(state.idle) :erlang.send_after(@max_idle, self(), :idle) else state.idle end {:noreply, %{state | idle: idle, channels: channels}} end def handle_info(:idle, state) do ExIRC.Client.quit(state.client, "Puppet was idle for too long") ExIRC.Client.stop!(state.client) {:stop, :normal, state} end def handle_info(:disconnected, state) do {delay, backoff} = :backoff.fail(state.backoff) Logger.info("#{inspect(self())} Disconnected -- reconnecting in #{inspect delay}ms") Process.send_after(self(), :connect, delay) {:noreply, %{state | connected: false, backoff: backoff}} end def handle_info(:connect, state) do {:noreply, state, {:continue, :connect}} end # Connection successful def handle_info({:connected, server, port}, state) do Logger.info("#{inspect(self())} Connected to #{inspect(server)}:#{port} #{inspect state}") {_, backoff} = :backoff.succeed(state.backoff) base_nick = make_nick(state) ExIRC.Client.logon(state.client, "", suffix_nick(base_nick), base_nick, "#{base_nick}'s puppet") {:noreply, %{state | backoff: backoff, connected_server: server, connected_port: port}} end # Logon successful def handle_info(:logged_in, state) do Logger.info("#{inspect(self())} Logged in") {_, backoff} = :backoff.succeed(state.backoff) # Create an UserTrack entry for the client so it's authenticated to the right account_id already. IRC.UserTrack.connected(state.network, suffix_nick(make_nick(state)), make_nick(state), "puppet.", state.account_id, %{puppet: true}) {:noreply, %{state | backoff: backoff}} end # ISUP def handle_info({:isup, network}, state) do {:noreply, %{state | network: network, connected: true}, {:continue, :connected}} end # Been kicked def handle_info({:kicked, _sender, chan, _reason}, state) do {:noreply, %{state | channels: state.channels -- [chan]}} end def handle_info(_info, state) do {:noreply, state} end def make_nick(state) do account = IRC.Account.get(state.account_id) user = IRC.UserTrack.find_by_account(state.network, account) base_nick = if(user, do: user.nick, else: account.name) clean_nick = case String.split(base_nick, ":", parts: 2) do ["@"<>nick, _] -> nick [nick] -> nick end clean_nick end if Mix.env == :dev do def suffix_nick(nick), do: "#{nick}[d]" else def suffix_nick(nick), do: "#{nick}[p]" end end diff --git a/lib/lsg/application.ex b/lib/lsg/application.ex index 0d29668..1782053 100644 --- a/lib/lsg/application.ex +++ b/lib/lsg/application.ex @@ -1,56 +1,56 @@ -defmodule LSG.Application do +defmodule Nola.Application do use Application # See https://hexdocs.pm/elixir/Application.html # for more information on OTP Applications def start(_type, _args) do import Supervisor.Spec Logger.add_backend(Sentry.LoggerBackend) - :ok = LSG.Matrix.setup() - :ok = LSG.TelegramRoom.setup() + :ok = Nola.Matrix.setup() + :ok = Nola.TelegramRoom.setup() # Define workers and child supervisors to be supervised children = [ # Start the endpoint when the application starts - supervisor(LSGWeb.Endpoint, []), - # Start your own worker by calling: LSG.Worker.start_link(arg1, arg2, arg3) - # worker(LSG.Worker, [arg1, arg2, arg3]), - worker(Registry, [[keys: :duplicate, name: LSG.BroadcastRegistry]], id: :registry_broadcast), - worker(LSG.IcecastAgent, []), - worker(LSG.Token, []), - worker(LSG.AuthToken, []), - LSG.Subnet, - {GenMagic.Pool, [name: LSG.GenMagic, pool_size: 2]}, - #worker(LSG.Icecast, []), - ] ++ LSG.IRC.application_childs - ++ LSG.Matrix.application_childs + supervisor(NolaWeb.Endpoint, []), + # Start your own worker by calling: Nola.Worker.start_link(arg1, arg2, arg3) + # worker(Nola.Worker, [arg1, arg2, arg3]), + worker(Registry, [[keys: :duplicate, name: Nola.BroadcastRegistry]], id: :registry_broadcast), + worker(Nola.IcecastAgent, []), + worker(Nola.Token, []), + worker(Nola.AuthToken, []), + Nola.Subnet, + {GenMagic.Pool, [name: Nola.GenMagic, pool_size: 2]}, + #worker(Nola.Icecast, []), + ] ++ Nola.IRC.application_childs + ++ Nola.Matrix.application_childs # See https://hexdocs.pm/elixir/Supervisor.html # for other strategies and supported options - opts = [strategy: :one_for_one, name: LSG.Supervisor] + opts = [strategy: :one_for_one, name: Nola.Supervisor] sup = Supervisor.start_link(children, opts) start_telegram() - spawn_link(fn() -> LSG.IRC.after_start() end) - spawn_link(fn() -> LSG.Matrix.after_start() end) - spawn_link(fn() -> LSG.TelegramRoom.after_start() end) + spawn_link(fn() -> Nola.IRC.after_start() end) + spawn_link(fn() -> Nola.Matrix.after_start() end) + spawn_link(fn() -> Nola.TelegramRoom.after_start() end) sup end # Tell Phoenix to update the endpoint configuration # whenever the application is updated. def config_change(changed, _new, removed) do - LSGWeb.Endpoint.config_change(changed, removed) + NolaWeb.Endpoint.config_change(changed, removed) :ok end defp start_telegram() do token = Keyword.get(Application.get_env(:lsg, :telegram, []), :key) options = [ username: Keyword.get(Application.get_env(:lsg, :telegram, []), :nick, "beauttebot"), purge: false ] - telegram = Telegram.Bot.ChatBot.Supervisor.start_link({LSG.Telegram, token, options}) + telegram = Telegram.Bot.ChatBot.Supervisor.start_link({Nola.Telegram, token, options}) end end diff --git a/lib/lsg/auth_token.ex b/lib/lsg/auth_token.ex index 0c5ba58..d125ea4 100644 --- a/lib/lsg/auth_token.ex +++ b/lib/lsg/auth_token.ex @@ -1,59 +1,59 @@ -defmodule LSG.AuthToken do +defmodule Nola.AuthToken do use GenServer def start_link() do GenServer.start_link(__MODULE__, [], [name: __MODULE__]) end def lookup(id) do GenServer.call(__MODULE__, {:lookup, id}) end def new_path(account, perks \\ nil) do case new(account, perks) do {:ok, id} -> - LSGWeb.Router.Helpers.login_path(LSGWeb.Endpoint, :token, id) + NolaWeb.Router.Helpers.login_path(NolaWeb.Endpoint, :token, id) error -> error end end def new_url(account, perks \\ nil) do case new(account, perks) do {:ok, id} -> - LSGWeb.Router.Helpers.login_url(LSGWeb.Endpoint, :token, id) + NolaWeb.Router.Helpers.login_url(NolaWeb.Endpoint, :token, id) error -> error end end def new(account, perks \\ nil) do GenServer.call(__MODULE__, {:new, account, perks}) end def init(_) do {:ok, Map.new} end def handle_call({:lookup, id}, _, state) do IO.inspect(state) with \ {account, date, perks} <- Map.get(state, id), d when d > 0 <- DateTime.diff(date, DateTime.utc_now()) do {:reply, {:ok, account, perks}, Map.delete(state, id)} else x -> IO.inspect(x) {:reply, {:error, :invalid_token}, state} end end def handle_call({:new, account, perks}, _, state) do id = IRC.UserTrack.Id.token() expire = DateTime.utc_now() |> DateTime.add(15*60, :second) {:reply, {:ok, id}, Map.put(state, id, {account, expire, perks})} end end diff --git a/lib/lsg/icecast.ex b/lib/lsg/icecast.ex index 07dd4fc..60fb45a 100644 --- a/lib/lsg/icecast.ex +++ b/lib/lsg/icecast.ex @@ -1,117 +1,117 @@ -defmodule LSG.Icecast do +defmodule Nola.Icecast do use GenServer require Logger @hackney_pool :default @httpoison_opts [hackney: [pool: @hackney_pool]] @fuse __MODULE__ def start_link, do: GenServer.start_link(__MODULE__, [], []) def init(_) do GenServer.cast(self(), :poll) {:ok, nil} end def handle_cast(:poll, state) do state = poll(state) {:noreply, state} end def handle_info(:poll, state) do state = poll(state) {:noreply, state} end defp poll(state) do state = case request(base_url(), :get) do {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> #update_json_stats(Jason.decode(body)) stats = update_stats(body) if state != stats do Logger.info "Icecast Update: " <> inspect(stats) - LSG.IcecastAgent.update(stats) - Registry.dispatch(LSG.BroadcastRegistry, "icecast", fn ws -> + Nola.IcecastAgent.update(stats) + Registry.dispatch(Nola.BroadcastRegistry, "icecast", fn ws -> for {pid, _} <- ws, do: send(pid, {:icecast, stats}) end) stats else state end error -> Logger.error "Icecast HTTP Error: #{inspect error}" state end interval = Application.get_env(:lsg, :icecast_poll_interval, 60_000) :timer.send_after(interval, :poll) state end defp update_stats(html) do raw = Floki.find(html, "div.roundbox") |> Enum.map(fn(html) -> html = Floki.raw_html(html) [{"h3", _, ["Mount Point /"<>mount]}] = Floki.find(html, "h3.mount") stats = Floki.find(html, "tr") |> Enum.map(fn({"tr", _, tds}) -> [{"td", _, keys}, {"td", _, values}] = tds key = List.first(keys) value = List.first(values) {key, value} end) |> Enum.into(Map.new) {mount, stats} end) |> Enum.into(Map.new) live? = if Map.get(raw["live"], "Content Type:", false), do: true, else: false np = if live? do raw["live"]["Currently playing:"] else raw["autodj"]["Currently playing:"] end genre = raw["live"]["Genre:"] || nil %{np: np || "", live: live? || false, genre: genre} end defp update_json_stats({:ok, body}) do Logger.debug "JSON STATS: #{inspect body}" end defp update_json_stats(error) do Logger.error "Failed to decode JSON Stats: #{inspect error}" end defp request(uri, method, body \\ [], headers \\ []) do - headers = [{"user-agent", "LSG-API[115ans.net, sys.115ans.net] href@random.sh"}] ++ headers + headers = [{"user-agent", "Nola-API[115ans.net, sys.115ans.net] href@random.sh"}] ++ headers options = @httpoison_opts case :ok do #:fuse.ask(@fuse, :sync) do :ok -> run_request(method, uri, body, headers, options) :blown -> :blown end end # This is to work around hackney's behaviour of returning `{:error, :closed}` when a pool connection has been closed # (keep-alive expired). We just retry the request immediatly up to five times. defp run_request(method, uri, body, headers, options), do: run_request(method, uri, body, headers, options, 0) defp run_request(method, uri, body, headers, options, retries) when retries < 4 do case HTTPoison.request(method, uri, body, headers, options) do {:error, :closed} -> run_request(method, uri, body, headers, options, retries + 1) other -> other end end defp run_request(method, uri, body, headers, options, _exceeded_retries), do: {:error, :unavailable} # # -- URIs # defp stats_json_url do base_url() <> "/status-json.xsl" end defp base_url do "http://91.121.59.45:8089" end end diff --git a/lib/lsg/icecast_agent.ex b/lib/lsg/icecast_agent.ex index 8f8a86a..8a3a72b 100644 --- a/lib/lsg/icecast_agent.ex +++ b/lib/lsg/icecast_agent.ex @@ -1,17 +1,17 @@ -defmodule LSG.IcecastAgent do +defmodule Nola.IcecastAgent do use Agent def start_link() do Agent.start_link(fn -> nil end, name: __MODULE__) end def update(stats) do Agent.update(__MODULE__, fn(_old) -> stats end) end def get do Agent.get(__MODULE__, fn(stats) -> stats end) end end diff --git a/lib/lsg/lsg.ex b/lib/lsg/lsg.ex index b5da5e0..11d0e24 100644 --- a/lib/lsg/lsg.ex +++ b/lib/lsg/lsg.ex @@ -1,30 +1,30 @@ -defmodule LSG do +defmodule Nola do @default_brand [ name: "Nola, source_url: "https://phab.random.sh/source/Bot/", owner: "Ashamed owner", owner_email: "contact@my.nola.bot" ] def env(), do: Application.get_env(:lsg) def env(key, default \\ nil), do: Application.get_env(:lsg, key, default) def brand(), do: env(:brand, @default_brand) def brand(key), do: Keyword.get(brand(), key) def name(), do: brand(:name) def source_url(), do: brand(:source_url) def data_path(suffix) do Path.join(data_path(), suffix) end def data_path do Application.get_env(:lsg, :data_path) end def version do Application.spec(:lsg)[:vsn] end end diff --git a/lib/lsg/subnet.ex b/lib/lsg/subnet.ex index 81bd862..ac9d8e6 100644 --- a/lib/lsg/subnet.ex +++ b/lib/lsg/subnet.ex @@ -1,84 +1,84 @@ -defmodule LSG.Subnet do +defmodule Nola.Subnet do use Agent def start_link(_) do Agent.start_link(&setup/0, name: __MODULE__) end def assignations() do :dets.select(dets(), [{{:"$1", :"$2"}, [is_binary: :"$2"], [{{:"$1", :"$2"}}]}]) end def find_subnet_for(binary) when is_binary(binary) do case :dets.select(dets(), [{{:"$1", :"$2"}, [{:==, :"$2", binary}], [{{:"$1", :"$2"}}]}]) do [{subnet, _}] -> subnet _ -> nil end end def assign(binary) when is_binary(binary) do result = if subnet = find_subnet_for(binary) do {:ok, subnet} else Agent.get_and_update(__MODULE__, fn(dets) -> {subnet, _} = available_select(dets) :dets.insert(dets, {subnet, binary}) :dets.sync(dets) {{:new, subnet}, dets} end) end case result do {:new, subnet} -> ip = Pfx.host(subnet, 1) set_reverse(binary, ip) subnet {:ok, subnet} -> subnet end end def set_reverse(name, ip, value \\ nil) def set_reverse(name, ip, nil) do set_reverse(name, ip, "#{name}.users.goulag.org") end def set_reverse(_, ip, value) do ptr_zone = "3.0.0.2.d.f.0.a.2.ip6.arpa" ip_fqdn = Pfx.dns_ptr(ip) ip_local = String.replace(ip_fqdn, ".#{ptr_zone}", "") rev? = String.ends_with?(value, ".users.goulag.org") if rev? do {:ok, rev_zone} = PowerDNSex.show_zone("users.goulag.org") rev_update? = Enum.any?(rev_zone.rrsets, fn(rr) -> rr.name == "#{ip_fqdn}." end) record = %{name: "#{value}.", type: "AAAA", ttl: 8600, records: [%{content: ip, disabled: false}]} if(rev_update?, do: PowerDNSex.update_record(rev_zone, record), else: PowerDNSex.create_record(rev_zone, record)) end {:ok, zone} = PowerDNSex.show_zone(ptr_zone) update? = Enum.any?(zone.rrsets, fn(rr) -> rr.name == "#{ip_fqdn}." end) record = %{name: "#{ip_fqdn}.", type: "PTR", ttl: 3600, records: [%{content: "#{value}.", disabled: false}]} pdns = if(update?, do: PowerDNSex.update_record(zone, record), else: PowerDNSex.create_record(zone, record)) :ok end @doc false def dets() do - (LSG.data_path() <> "/subnets.dets") |> String.to_charlist() + (Nola.data_path() <> "/subnets.dets") |> String.to_charlist() end @doc false def setup() do {:ok, dets} = :dets.open_file(dets(), []) dets end defp available_select(dets) do spec = [{{:"$1", :"$2"}, [is_integer: :"$2"], [{{:"$1", :"$2"}}]}] {subnets, _} = :dets.select(dets, spec, 20) subnet = subnets |> Enum.sort_by(fn({_, last}) -> last end) |> List.first() end end diff --git a/lib/lsg/token.ex b/lib/lsg/token.ex index 33946d4..563ac72 100644 --- a/lib/lsg/token.ex +++ b/lib/lsg/token.ex @@ -1,38 +1,38 @@ -defmodule LSG.Token do +defmodule Nola.Token do use GenServer def start_link() do GenServer.start_link(__MODULE__, [], [name: __MODULE__]) end def lookup(id) do with \ [{_, cred, date}] <- :ets.lookup(__MODULE__.ETS, id), IO.inspect("cred: #{inspect cred} valid for #{inspect date} now #{inspect DateTime.utc_now()}"), d when d > 0 <- DateTime.diff(date, DateTime.utc_now()) do {:ok, cred} else err -> {:error, err} end end def new(cred) do GenServer.call(__MODULE__, {:new, cred}) end def init(_) do ets = :ets.new(__MODULE__.ETS, [:ordered_set, :named_table, :protected, {:read_concurrency, true}]) {:ok, ets} end def handle_call({:new, cred}, _, ets) do id = IRC.UserTrack.Id.large_id() expire = DateTime.utc_now() |> DateTime.add(15*60, :second) obj = {id, cred, expire} :ets.insert(ets, obj) {:reply, {:ok, id}, ets} end end diff --git a/lib/lsg_irc/admin_handler.ex b/lib/lsg_irc/admin_handler.ex index fab4dbc..9a5d557 100644 --- a/lib/lsg_irc/admin_handler.ex +++ b/lib/lsg_irc/admin_handler.ex @@ -1,41 +1,41 @@ -defmodule LSG.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/lsg_irc/alcolog_plugin.ex b/lib/lsg_irc/alcolog_plugin.ex index f61b237..145e4fc 100644 --- a/lib/lsg_irc/alcolog_plugin.ex +++ b/lib/lsg_irc/alcolog_plugin.ex @@ -1,1229 +1,1229 @@ -defmodule LSG.IRC.AlcoologPlugin do +defmodule Nola.IRC.AlcoologPlugin 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 = (LSG.data_path() <> "/" <> "alcoolisme.dets") |> String.to_charlist - dets_meta_filename = (LSG.data_path() <> "/" <> "alcoolisme_meta.dets") |> String.to_charlist + dets_filename = (Nola.data_path() <> "/" <> "alcoolisme.dets") |> String.to_charlist + dets_meta_filename = (Nola.data_path() <> "/" <> "alcoolisme_meta.dets") |> String.to_charlist %{dets: dets_filename, meta: dets_meta_filename, ets: __MODULE__.ETS} end def init(_) do triggers = for(t <- @pubsub_triggers, do: "trigger:"<>t) for sub <- @pubsub ++ triggers do {:ok, _} = Registry.register(IRC.PubSub, sub, plugin: __MODULE__) end - dets_filename = (LSG.data_path() <> "/" <> "alcoolisme.dets") |> String.to_charlist + 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 = (LSG.data_path() <> "/" <> "alcoolisme_meta.dets") |> String.to_charlist + 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 - LSG.IRC.TxtPlugin.reply_random(m, "alcoolog.santo") + Nola.IRC.TxtPlugin.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} = LSG.Token.new({:alcoolog, :index, m.sender.network, m.channel}) - url = LSGWeb.Router.Helpers.alcoolog_url(LSGWeb.Endpoint, :index, m.network, LSGWeb.format_chan(m.channel), token) + {: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 = LSGWeb.Router.Helpers.alcoolog_url(LSGWeb.Endpoint, :index, m.network, LSGWeb.format_chan(m.channel)) + 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 -> LSG.IRC.TxtPlugin.reply_random(m, "alcoolog.drink_toohuge") - cl == 0 || deg == 0 -> LSG.IRC.TxtPlugin.reply_random(m, "alcoolog.drink_zero") - cl < 0 || deg < 0 -> LSG.IRC.TxtPlugin.reply_random(m, "alcoolog.drink_negative") + 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") 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 = LSG.IRC.TxtPlugin.random("alcoolog.santai") + sante = Nola.IRC.TxtPlugin.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 = LSG.IRC.TxtPlugin.random("alcoolog.#{to_string(miss)}") + miss = Nola.IRC.TxtPlugin.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 = LSG.IRC.TxtPlugin.random(txt_file) + user_status = Nola.IRC.TxtPlugin.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 -> - LSG.IRC.TxtPlugin.reply_random(m, "alcoolog.fatter") + Nola.IRC.TxtPlugin.reply_random(m, "alcoolog.fatter") old_meta.weight == meta.weight -> m.replyfun.("aucun changement!") true -> - LSG.IRC.TxtPlugin.reply_random(m, "alcoolog.thinner") + Nola.IRC.TxtPlugin.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}") - LSG.IRC.TxtPlugin.reply_random(m, "alcoolog.delete") + Nola.IRC.TxtPlugin.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}") {: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/lsg_irc/alcoolog_announcer_plugin.ex b/lib/lsg_irc/alcoolog_announcer_plugin.ex index 3902d5f..f90dc42 100644 --- a/lib/lsg_irc/alcoolog_announcer_plugin.ex +++ b/lib/lsg_irc/alcoolog_announcer_plugin.ex @@ -1,272 +1,272 @@ -defmodule LSG.IRC.AlcoologAnnouncerPlugin do +defmodule Nola.IRC.AlcoologAnnouncerPlugin 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 = (LSG.data_path() <> "/" <> "alcoologlog.dets") |> String.to_charlist + 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 = (LSG.data_path() <> "/" <> "alcoologlog.dets") |> String.to_charlist + 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 = LSG.IRC.SettingPlugin.declare("alcoolog.alerts", __MODULE__, true, :boolean) - #:ok = LSG.IRC.SettingPlugin.declare("alcoolog.aperoalert", __MODULE__, true, :boolean) + #: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(LSG.IRC.AlcoologPlugin.get_all_stats(), %{}) + Enum.into(Nola.IRC.AlcoologPlugin.get_all_stats(), %{}) end defp timer() do Process.send_after(self(), :stats, :timer.seconds(@seconds)) end end diff --git a/lib/lsg_irc/base_plugin.ex b/lib/lsg_irc/base_plugin.ex index 7153c70..ee0352e 100644 --- a/lib/lsg_irc/base_plugin.ex +++ b/lib/lsg_irc/base_plugin.ex @@ -1,131 +1,131 @@ -defmodule LSG.IRC.BasePlugin do +defmodule Nola.IRC.BasePlugin 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() |> 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([LSG.IRC, Macro.camelize(plugin<>"_plugin")]) + module = Module.concat([Nola.IRC, Macro.camelize(plugin<>"_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 :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([LSG.IRC, Macro.camelize(plugin<>"_plugin")]) + module = Module.concat([Nola.IRC, Macro.camelize(plugin<>"_plugin")]) with true <- Code.ensure_loaded?(module), IRC.Plugin.switch(module, true), {:ok, pid} <- IRC.Plugin.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([LSG.IRC, Macro.camelize(plugin<>"_plugin")]) + module = Module.concat([Nola.IRC, Macro.camelize(plugin<>"_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) 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([LSG.IRC, Macro.camelize(plugin<>"_plugin")]) + module = Module.concat([Nola.IRC, Macro.camelize(plugin<>"_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 = LSGWeb.Router.Helpers.irc_url(LSGWeb.Endpoint, :index, m.network, LSGWeb.format_chan(m.channel)) + url = NolaWeb.Router.Helpers.irc_url(NolaWeb.Endpoint, :index, m.network, NolaWeb.format_chan(m.channel)) m.replyfun.("-> #{url}") {:noreply, nil} end def handle_info({:irc, :trigger, "version", message = %{trigger: %{type: :bang}}}, _) do {:ok, vsn} = :application.get_key(:lsg, :vsn) ver = List.to_string(vsn) - url = LSGWeb.Router.Helpers.irc_url(LSGWeb.Endpoint, :index) + 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() message.replyfun.([ - <<"🤖 I am a robot running", 2, "beautte, version #{ver}", 2, " — source: #{LSG.source_url()}">>, + <<"🤖 I am a robot running", 2, "beautte, version #{ver}", 2, " — source: #{Nola.source_url()}">>, "🦾 Elixir #{elixir_ver} #{otp_ver} on #{system}", "👷‍♀️ Owner: href ", "🌍 Web interface: #{url}" ]) {:noreply, nil} end def handle_info(msg, _) do {:noreply, nil} end end diff --git a/lib/lsg_irc/bourosama_plugin.ex b/lib/lsg_irc/bourosama_plugin.ex index ba63d81..dd05144 100644 --- a/lib/lsg_irc/bourosama_plugin.ex +++ b/lib/lsg_irc/bourosama_plugin.ex @@ -1,58 +1,58 @@ -defmodule LSG.IRC.BoursoramaPlugin do +defmodule Nola.IRC.BoursoramaPlugin 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/lsg_irc/buffer_plugin.ex b/lib/lsg_irc/buffer_plugin.ex index d278151..eece34e 100644 --- a/lib/lsg_irc/buffer_plugin.ex +++ b/lib/lsg_irc/buffer_plugin.ex @@ -1,44 +1,44 @@ -defmodule LSG.IRC.BufferPlugin do +defmodule Nola.IRC.BufferPlugin 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/lsg_irc/calc_plugin.ex b/lib/lsg_irc/calc_plugin.ex index ca65675..264370c 100644 --- a/lib/lsg_irc/calc_plugin.ex +++ b/lib/lsg_irc/calc_plugin.ex @@ -1,37 +1,37 @@ -defmodule LSG.IRC.CalcPlugin do +defmodule Nola.IRC.CalcPlugin 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/lsg_irc/coronavirus_plugin.ex b/lib/lsg_irc/coronavirus_plugin.ex index b9a9e40..d04d8f9 100644 --- a/lib/lsg_irc/coronavirus_plugin.ex +++ b/lib/lsg_irc/coronavirus_plugin.ex @@ -1,172 +1,172 @@ -defmodule LSG.IRC.CoronavirusPlugin do +defmodule Nola.IRC.CoronavirusPlugin 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/lsg_irc/correction_plugin.ex b/lib/lsg_irc/correction_plugin.ex index a77c4a2..5f9b278 100644 --- a/lib/lsg_irc/correction_plugin.ex +++ b/lib/lsg_irc/correction_plugin.ex @@ -1,59 +1,59 @@ -defmodule LSG.IRC.CorrectionPlugin do +defmodule Nola.IRC.CorrectionPlugin 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/lsg_irc/dice_plugin.ex b/lib/lsg_irc/dice_plugin.ex index eafd88a..b5e7649 100644 --- a/lib/lsg_irc/dice_plugin.ex +++ b/lib/lsg_irc/dice_plugin.ex @@ -1,66 +1,66 @@ -defmodule LSG.IRC.DicePlugin do +defmodule Nola.IRC.DicePlugin 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/lsg_irc/finance_plugin.ex b/lib/lsg_irc/finance_plugin.ex index 51ec3f6..c1a1771 100644 --- a/lib/lsg_irc/finance_plugin.ex +++ b/lib/lsg_irc/finance_plugin.ex @@ -1,190 +1,190 @@ -defmodule LSG.IRC.FinancePlugin do +defmodule Nola.IRC.FinancePlugin 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(:lsg, :alphavantage, []) |> Keyword.get(:api_key, "demo") end end diff --git a/lib/lsg_irc/gpt_plugin.ex b/lib/lsg_irc/gpt_plugin.ex index e3cefa7..2c8f182 100644 --- a/lib/lsg_irc/gpt_plugin.ex +++ b/lib/lsg_irc/gpt_plugin.ex @@ -1,259 +1,259 @@ -defmodule LSG.IRC.GptPlugin do +defmodule Nola.IRC.GptPlugin do require Logger import Irc.Plugin.TempRef 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 - LSGWeb.Router.Helpers.gpt_url(LSGWeb.Endpoint, :result, m.network, LSGWeb.format_chan(m.channel), id) + NolaWeb.Router.Helpers.gpt_url(NolaWeb.Endpoint, :result, m.network, NolaWeb.format_chan(m.channel), id) else - LSGWeb.Router.Helpers.gpt_url(LSGWeb.Endpoint, :result, id) + 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 - LSGWeb.Router.Helpers.gpt_url(LSGWeb.Endpoint, :prompt, m.network, LSGWeb.format_chan(m.channel), prompt) + NolaWeb.Router.Helpers.gpt_url(NolaWeb.Endpoint, :prompt, m.network, NolaWeb.format_chan(m.channel), prompt) else - LSGWeb.Router.Helpers.gpt_url(LSGWeb.Endpoint, :prompt, prompt) + 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}") 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/lsg_irc/kick_roulette_plugin.ex b/lib/lsg_irc/kick_roulette_plugin.ex index f810a74..55b7da4 100644 --- a/lib/lsg_irc/kick_roulette_plugin.ex +++ b/lib/lsg_irc/kick_roulette_plugin.ex @@ -1,32 +1,32 @@ -defmodule LSG.IRC.KickRoulettePlugin do +defmodule Nola.IRC.KickRoulettePlugin 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/lsg_irc/last_fm_plugin.ex b/lib/lsg_irc/last_fm_plugin.ex index 0c6b8a6..f29982c 100644 --- a/lib/lsg_irc/last_fm_plugin.ex +++ b/lib/lsg_irc/last_fm_plugin.ex @@ -1,187 +1,187 @@ -defmodule LSG.IRC.LastFmPlugin do +defmodule Nola.IRC.LastFmPlugin 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 = (LSG.data_path() <> "/" <> "lastfm.dets") |> String.to_charlist + dets_filename = (Nola.data_path() <> "/" <> "lastfm.dets") |> String.to_charlist {:ok, dets} = :dets.open_file(dets_filename, []) {:ok, %__MODULE__{dets: dets}} end def handle_info({:irc, :trigger, "lastfm", message = %{trigger: %{type: :plus, args: [username]}}}, state) do 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(:lsg, :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(:lsg, :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/lsg_irc/link_plugin.ex b/lib/lsg_irc/link_plugin.ex index aaf6c6f..28e537a 100644 --- a/lib/lsg_irc/link_plugin.ex +++ b/lib/lsg_irc/link_plugin.ex @@ -1,271 +1,271 @@ -defmodule LSG.IRC.LinkPlugin do +defmodule Nola.IRC.LinkPlugin 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, 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 :lsg, LSG.IRC.LinkPlugin, + config :lsg, Nola.IRC.LinkPlugin, handlers: [ - LSG.IRC.LinkPlugin.Youtube: [ + Nola.IRC.LinkPlugin.Youtube: [ invidious: true ], - LSG.IRC.LinkPlugin.Twitter: [], - LSG.IRC.LinkPlugin.Imgur: [], + Nola.IRC.LinkPlugin.Twitter: [], + Nola.IRC.LinkPlugin.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(:lsg, __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(:lsg, __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/lsg_irc/link_plugin/github.ex b/lib/lsg_irc/link_plugin/github.ex index 19be89b..93e0892 100644 --- a/lib/lsg_irc/link_plugin/github.ex +++ b/lib/lsg_irc/link_plugin/github.ex @@ -1,49 +1,49 @@ -defmodule LSG.IRC.LinkPlugin.Github do - @behaviour LSG.IRC.LinkPlugin +defmodule Nola.IRC.LinkPlugin.Github do + @behaviour Nola.IRC.LinkPlugin @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/lsg_irc/link_plugin/html.ex b/lib/lsg_irc/link_plugin/html.ex index e0e4229..56a8ceb 100644 --- a/lib/lsg_irc/link_plugin/html.ex +++ b/lib/lsg_irc/link_plugin/html.ex @@ -1,106 +1,106 @@ -defmodule LSG.IRC.LinkPlugin.HTML do - @behaviour LSG.IRC.LinkPlugin +defmodule Nola.IRC.LinkPlugin.HTML do + @behaviour Nola.IRC.LinkPlugin @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/lsg_irc/link_plugin/imgur.ex b/lib/lsg_irc/link_plugin/imgur.ex index 443afdb..af0b36a 100644 --- a/lib/lsg_irc/link_plugin/imgur.ex +++ b/lib/lsg_irc/link_plugin/imgur.ex @@ -1,96 +1,96 @@ -defmodule LSG.IRC.LinkPlugin.Imgur do - @behaviour LSG.IRC.LinkPlugin +defmodule Nola.IRC.LinkPlugin.Imgur do + @behaviour Nola.IRC.LinkPlugin @moduledoc """ # Imgur link preview No options. Needs to have a Imgur API key configured: ``` config :lsg, :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(:lsg, :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(:lsg, :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/lsg_irc/link_plugin/pdf.ex b/lib/lsg_irc/link_plugin/pdf.ex index 8c4869c..5f72ef5 100644 --- a/lib/lsg_irc/link_plugin/pdf.ex +++ b/lib/lsg_irc/link_plugin/pdf.ex @@ -1,39 +1,39 @@ -defmodule LSG.IRC.LinkPlugin.PDF do +defmodule Nola.IRC.LinkPlugin.PDF do require Logger - @behaviour LSG.IRC.LinkPlugin + @behaviour Nola.IRC.LinkPlugin @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/lsg_irc/link_plugin/redacted.ex b/lib/lsg_irc/link_plugin/redacted.ex index 2e92b4a..7a6229d 100644 --- a/lib/lsg_irc/link_plugin/redacted.ex +++ b/lib/lsg_irc/link_plugin/redacted.ex @@ -1,18 +1,18 @@ -defmodule LSG.IRC.LinkPlugin.Redacted do - @behaviour LSG.IRC.LinkPlugin +defmodule Nola.IRC.LinkPlugin.Redacted do + @behaviour Nola.IRC.LinkPlugin @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/lsg_irc/link_plugin/reddit.ex b/lib/lsg_irc/link_plugin/reddit.ex index 6fc1723..79102e0 100644 --- a/lib/lsg_irc/link_plugin/reddit.ex +++ b/lib/lsg_irc/link_plugin/reddit.ex @@ -1,119 +1,119 @@ -defmodule LSG.IRC.LinkPlugin.Reddit do - @behaviour LSG.IRC.LinkPlugin +defmodule Nola.IRC.LinkPlugin.Reddit do + @behaviour Nola.IRC.LinkPlugin @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/lsg_irc/link_plugin/twitter.ex b/lib/lsg_irc/link_plugin/twitter.ex index 41e10ab..640b193 100644 --- a/lib/lsg_irc/link_plugin/twitter.ex +++ b/lib/lsg_irc/link_plugin/twitter.ex @@ -1,158 +1,158 @@ -defmodule LSG.IRC.LinkPlugin.Twitter do - @behaviour LSG.IRC.LinkPlugin +defmodule Nola.IRC.LinkPlugin.Twitter do + @behaviour Nola.IRC.LinkPlugin @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/lsg_irc/link_plugin/youtube.ex b/lib/lsg_irc/link_plugin/youtube.ex index f38eca3..1e3a0de 100644 --- a/lib/lsg_irc/link_plugin/youtube.ex +++ b/lib/lsg_irc/link_plugin/youtube.ex @@ -1,72 +1,72 @@ -defmodule LSG.IRC.LinkPlugin.YouTube do - @behaviour LSG.IRC.LinkPlugin +defmodule Nola.IRC.LinkPlugin.YouTube do + @behaviour Nola.IRC.LinkPlugin @moduledoc """ # YouTube link preview needs an API key: ``` config :lsg, :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(:lsg, :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/lsg_irc/logger_plugin.ex b/lib/lsg_irc/logger_plugin.ex index de601a6..b13f33a 100644 --- a/lib/lsg_irc/logger_plugin.ex +++ b/lib/lsg_irc/logger_plugin.ex @@ -1,70 +1,70 @@ -defmodule LSG.IRC.LoggerPlugin do +defmodule Nola.IRC.LoggerPlugin 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, "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/lsg_irc/lsg_irc.ex b/lib/lsg_irc/lsg_irc.ex index a50abed..f64978a 100644 --- a/lib/lsg_irc/lsg_irc.ex +++ b/lib/lsg_irc/lsg_irc.ex @@ -1,34 +1,34 @@ -defmodule LSG.IRC do +defmodule Nola.IRC do require Logger - def env(), do: LSG.env(:irc) + 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() [ 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]), 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() Logger.info("Starting connections") IRC.Connection.start_all() end end diff --git a/lib/lsg_irc/outline_plugin.ex b/lib/lsg_irc/outline_plugin.ex index 47fa6fa..820500e 100644 --- a/lib/lsg_irc/outline_plugin.ex +++ b/lib/lsg_irc/outline_plugin.ex @@ -1,108 +1,108 @@ -defmodule LSG.IRC.OutlinePlugin do +defmodule Nola.IRC.OutlinePlugin 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(LSG.data_path, "/outline.txt") + 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/lsg_irc/preums_plugin.ex b/lib/lsg_irc/preums_plugin.ex index 68257f0..9344ce9 100644 --- a/lib/lsg_irc/preums_plugin.ex +++ b/lib/lsg_irc/preums_plugin.ex @@ -1,276 +1,276 @@ -defmodule LSG.IRC.PreumsPlugin do +defmodule Nola.IRC.PreumsPlugin 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 - (LSG.data_path() <> "/preums.dets") |> String.to_charlist() + (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(:lsg, LSG.IRC.PreumsPlugin, []) + env = Application.get_env(:lsg, Nola.IRC.PreumsPlugin, []) 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/lsg_irc/quatre_cent_vingt_plugin.ex b/lib/lsg_irc/quatre_cent_vingt_plugin.ex index fff7e4f..8953ea3 100644 --- a/lib/lsg_irc/quatre_cent_vingt_plugin.ex +++ b/lib/lsg_irc/quatre_cent_vingt_plugin.ex @@ -1,149 +1,149 @@ -defmodule LSG.IRC.QuatreCentVingtPlugin do +defmodule Nola.IRC.QuatreCentVingtPlugin 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 = (LSG.data_path() <> "/420.dets") |> String.to_charlist + 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/lsg_irc/radio_france_plugin.ex b/lib/lsg_irc/radio_france_plugin.ex index 34935ca..c2e966f 100644 --- a/lib/lsg_irc/radio_france_plugin.ex +++ b/lib/lsg_irc/radio_france_plugin.ex @@ -1,133 +1,133 @@ -defmodule LSG.IRC.RadioFrancePlugin do +defmodule Nola.IRC.RadioFrancePlugin 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/lsg_irc/say_plugin.ex b/lib/lsg_irc/say_plugin.ex index 8e93ec2..915b0f6 100644 --- a/lib/lsg_irc/say_plugin.ex +++ b/lib/lsg_irc/say_plugin.ex @@ -1,73 +1,73 @@ -defmodule LSG.IRC.SayPlugin do +defmodule Nola.IRC.SayPlugin 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/lsg_irc/script_plugin.ex b/lib/lsg_irc/script_plugin.ex index bae6f3f..94d4edf 100644 --- a/lib/lsg_irc/script_plugin.ex +++ b/lib/lsg_irc/script_plugin.ex @@ -1,42 +1,42 @@ -defmodule LSG.IRC.ScriptPlugin do +defmodule Nola.IRC.ScriptPlugin 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 = (LSG.data_path() <> "/" <> "scripts.dets") |> String.to_charlist + 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/lsg_irc/seen_plugin.ex b/lib/lsg_irc/seen_plugin.ex index 405c372..2a4d0dd 100644 --- a/lib/lsg_irc/seen_plugin.ex +++ b/lib/lsg_irc/seen_plugin.ex @@ -1,59 +1,59 @@ -defmodule LSG.IRC.SeenPlugin do +defmodule Nola.IRC.SeenPlugin 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 = (LSG.data_path() <> "/seen.dets") |> String.to_charlist() + 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/lsg_irc/sms_plugin.ex b/lib/lsg_irc/sms_plugin.ex index be1611f..2bbf13e 100644 --- a/lib/lsg_irc/sms_plugin.ex +++ b/lib/lsg_irc/sms_plugin.ex @@ -1,165 +1,165 @@ -defmodule LSG.IRC.SmsPlugin do +defmodule Nola.IRC.SmsPlugin 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(:lsg, :sms, []), :number, "+33000000000") end def start_link() do GenServer.start_link(__MODULE__, [], name: __MODULE__) end def path() do account = Keyword.get(Application.get_env(:lsg, :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" =>LSGWeb.Router.Helpers.sms_url(LSGWeb.Endpoint, :ovh_callback), + "callBack" =>NolaWeb.Router.Helpers.sms_url(NolaWeb.Endpoint, :ovh_callback), "smsResponse" => %{ - "cgiUrl" => LSGWeb.Router.Helpers.sms_url(LSGWeb.Endpoint, :ovh_callback), + "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(:lsg, :sms) end defp env(key) do Keyword.get(env(), key) end end diff --git a/lib/lsg_irc/tell_plugin.ex b/lib/lsg_irc/tell_plugin.ex index baaa0c5..ecc98df 100644 --- a/lib/lsg_irc/tell_plugin.ex +++ b/lib/lsg_irc/tell_plugin.ex @@ -1,106 +1,106 @@ -defmodule LSG.IRC.TellPlugin do +defmodule Nola.IRC.TellPlugin 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 - (LSG.data_path() <> "/tell.dets") |> String.to_charlist() + (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/lsg_irc/txt_plugin.ex b/lib/lsg_irc/txt_plugin.ex index 2c9dfca..d2bb627 100644 --- a/lib/lsg_irc/txt_plugin.ex +++ b/lib/lsg_irc/txt_plugin.ex @@ -1,556 +1,556 @@ -defmodule LSG.IRC.TxtPlugin do +defmodule Nola.IRC.TxtPlugin 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 = (LSG.data_path() <> "/" <> "txtlocks.dets") |> String.to_charlist + dets_locks_filename = (Nola.data_path() <> "/" <> "txtlocks.dets") |> String.to_charlist {:ok, locks} = :dets.open_file(dets_locks_filename, []) - markov_handler = Keyword.get(Application.get_env(:lsg, __MODULE__, []), :markov_handler, LSG.IRC.TxtPlugin.Markov.Native) + markov_handler = Keyword.get(Application.get_env(:lsg, __MODULE__, []), :markov_handler, Nola.IRC.TxtPlugin.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 - LSGWeb.Router.Helpers.irc_url(LSGWeb.Endpoint, :txt, m.network, LSGWeb.format_chan(m.channel), trigger) + NolaWeb.Router.Helpers.irc_url(NolaWeb.Endpoint, :txt, m.network, NolaWeb.format_chan(m.channel), trigger) else - LSGWeb.Router.Helpers.irc_url(LSGWeb.Endpoint, :txt, trigger) + 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(:lsg, :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/lsg_irc/txt_plugin/markov.ex b/lib/lsg_irc/txt_plugin/markov.ex index 311138c..2e30dfa 100644 --- a/lib/lsg_irc/txt_plugin/markov.ex +++ b/lib/lsg_irc/txt_plugin/markov.ex @@ -1,9 +1,9 @@ -defmodule LSG.IRC.TxtPlugin.Markov do +defmodule Nola.IRC.TxtPlugin.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/lsg_irc/txt_plugin/markov_native.ex b/lib/lsg_irc/txt_plugin/markov_native.ex index 524e860..4c403c2 100644 --- a/lib/lsg_irc/txt_plugin/markov_native.ex +++ b/lib/lsg_irc/txt_plugin/markov_native.ex @@ -1,33 +1,33 @@ -defmodule LSG.IRC.TxtPlugin.MarkovNative do - @behaviour LSG.IRC.TxtPlugin.Markov +defmodule Nola.IRC.TxtPlugin.MarkovNative do + @behaviour Nola.IRC.TxtPlugin.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/lsg_irc/txt_plugin/markov_py_markovify.ex b/lib/lsg_irc/txt_plugin/markov_py_markovify.ex index a3838cd..cda7853 100644 --- a/lib/lsg_irc/txt_plugin/markov_py_markovify.ex +++ b/lib/lsg_irc/txt_plugin/markov_py_markovify.ex @@ -1,39 +1,39 @@ -defmodule LSG.IRC.TxtPlugin.MarkovPyMarkovify do +defmodule Nola.IRC.TxtPlugin.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(LSG.IRC.TxtPlugin.directory()) | args] + args = [script, Path.expand(Nola.IRC.TxtPlugin.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(:lsg)) <> "/irc/txt/markovify.py" - env = Application.get_env(:lsg, LSG.IRC.TxtPlugin, []) + env = Application.get_env(:lsg, Nola.IRC.TxtPlugin, []) |> Keyword.get(:py_markovify, []) {Keyword.get(env, :python, "python3"), Keyword.get(env, :script, default_script)} end end diff --git a/lib/lsg_irc/untappd_plugin.ex b/lib/lsg_irc/untappd_plugin.ex index 69e4be6..50b0c4d 100644 --- a/lib/lsg_irc/untappd_plugin.ex +++ b/lib/lsg_irc/untappd_plugin.ex @@ -1,66 +1,66 @@ -defmodule LSG.IRC.UntappdPlugin do +defmodule Nola.IRC.UntappdPlugin 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/lsg_irc/user_mention_plugin.ex b/lib/lsg_irc/user_mention_plugin.ex index ca743c4..eb230fd 100644 --- a/lib/lsg_irc/user_mention_plugin.ex +++ b/lib/lsg_irc/user_mention_plugin.ex @@ -1,52 +1,52 @@ -defmodule LSG.IRC.UserMentionPlugin do +defmodule Nola.IRC.UserMentionPlugin 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 -> - LSG.Telegram.send_message(telegram, "`#{channel}` <**#{sender.nick}**> #{Enum.join(content, " ")}") + Nola.Telegram.send_message(telegram, "`#{channel}` <**#{sender.nick}**> #{Enum.join(content, " ")}") sms -> - case LSG.IRC.SmsPlugin.send_sms(sms, text) do + case Nola.IRC.SmsPlugin.send_sms(sms, text) do {:error, code} -> message.replyfun("#{sender.nick}: erreur #{code} (sms)") end true -> - LSG.IRC.TellPlugin.tell(message, nick, content) + Nola.IRC.TellPlugin.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/lsg_irc/wikipedia_plugin.ex b/lib/lsg_irc/wikipedia_plugin.ex index 618eb66..3202e13 100644 --- a/lib/lsg_irc/wikipedia_plugin.ex +++ b/lib/lsg_irc/wikipedia_plugin.ex @@ -1,90 +1,90 @@ -defmodule LSG.IRC.WikipediaPlugin do +defmodule Nola.IRC.WikipediaPlugin 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/lsg_irc/wolfram_alpha_plugin.ex b/lib/lsg_irc/wolfram_alpha_plugin.ex index c07f659..b553e63 100644 --- a/lib/lsg_irc/wolfram_alpha_plugin.ex +++ b/lib/lsg_irc/wolfram_alpha_plugin.ex @@ -1,47 +1,47 @@ -defmodule LSG.IRC.WolframAlphaPlugin do +defmodule Nola.IRC.WolframAlphaPlugin 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(:lsg, :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/lsg_irc/youtube_plugin.ex b/lib/lsg_irc/youtube_plugin.ex index 49fc31c..3d2acfb 100644 --- a/lib/lsg_irc/youtube_plugin.ex +++ b/lib/lsg_irc/youtube_plugin.ex @@ -1,104 +1,104 @@ -defmodule LSG.IRC.YouTubePlugin do +defmodule Nola.IRC.YouTubePlugin 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(:lsg, :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/lsg_matrix/matrix.ex b/lib/lsg_matrix/matrix.ex index 49da6b2..9334816 100644 --- a/lib/lsg_matrix/matrix.ex +++ b/lib/lsg_matrix/matrix.ex @@ -1,169 +1,169 @@ -defmodule LSG.Matrix do +defmodule Nola.Matrix do require Logger alias Polyjuice.Client @behaviour MatrixAppService.Adapter.Room @behaviour MatrixAppService.Adapter.Transaction @behaviour MatrixAppService.Adapter.User @env Mix.env def dets(part) do - (LSG.data_path() <> "/matrix-#{to_string(part)}.dets") |> String.to_charlist() + (Nola.data_path() <> "/matrix-#{to_string(part)}.dets") |> String.to_charlist() end def setup() do {:ok, _} = :dets.open_file(dets(:rooms), []) {:ok, _} = :dets.open_file(dets(:room_aliases), []) {:ok, _} = :dets.open_file(dets(:users), []) :ok end def myself?("@_dev:random.sh"), do: true def myself?("@_bot:random.sh"), do: true def myself?("@_dev."<>_), do: true def myself?("@_bot."<>_), do: true def myself?(_), do: false def mxc_to_http(mxc = "mxc://"<>_) do uri = URI.parse(mxc) %URI{uri | scheme: "https", path: "/_matrix/media/r0/download/#{uri.authority}#{uri.path}"} |> URI.to_string() end def get_or_create_matrix_user(id) do if mxid = lookup_user(id) do mxid else opts = [ type: "m.login.application_service", inhibit_login: true, device_id: "APP_SERVICE", initial_device_display_name: "Application Service", username: if(@env == :dev, do: "_dev.#{id}", else: "_bot.#{id}") ] Logger.debug("Registering user for #{id}") {:ok, %{"user_id" => mxid}} = Polyjuice.Client.LowLevel.register(client(), opts) :dets.insert(dets(:users), {id, mxid}) end end def lookup_user(id) do case :dets.lookup(dets(:users), id) do [{_, matrix_id}] -> matrix_id _ -> nil end end def user_name("@"<>name) do [username, _] = String.split(name, ":", parts: 2) username end def application_childs() do import Supervisor.Spec [ - supervisor(LSG.Matrix.Room.Supervisor, [], [name: IRC.PuppetConnection.Supervisor]), + supervisor(Nola.Matrix.Room.Supervisor, [], [name: IRC.PuppetConnection.Supervisor]), ] end def after_start() do rooms = :dets.foldl(fn({id, _, _, _}, acc) -> [id | acc] end, [], dets(:rooms)) - for room <- rooms, do: LSG.Matrix.Room.start(room) + for room <- rooms, do: Nola.Matrix.Room.start(room) end def lookup_room(room) do case :dets.lookup(dets(:rooms), room) do [{_, network, channel, opts}] -> {:ok, Map.merge(opts, %{network: network, channel: channel})} _ -> {:error, :no_such_room} end end def lookup_room_alias(room_alias) do case :dets.lookup(dets(:room_aliases), room_alias) do [{_, room_id}] -> {:ok, room_id} _ -> {:error, :no_such_room_alias} end end def lookup_or_create_room(room_alias) do case lookup_room_alias(room_alias) do {:ok, room_id} -> {:ok, room_id} {:error, :no_such_room_alias} -> create_room(room_alias) end end def create_room(room_alias) do Logger.debug("Matrix: creating room #{inspect room_alias}") localpart = localpart(room_alias) with {:ok, network, channel} <- extract_network_channel_from_localpart(localpart), %IRC.Connection{} <- IRC.Connection.get_network(network, channel), room = [visibility: :public, room_alias_name: localpart, name: if(network == "random", do: channel, else: "#{network}/#{channel}")], {:ok, %{"room_id" => room_id}} <- Client.Room.create_room(client(), room) do Logger.info("Matrix: created room #{room_alias} #{room_id}") :dets.insert(dets(:rooms), {room_id, network, channel, %{}}) :dets.insert(dets(:room_aliases), {room_alias, room_id}) {:ok, room_id} else nil -> {:error, :no_such_network_channel} error -> error end end def localpart(room_alias) do [<<"#", localpart :: binary>>, _] = String.split(room_alias, ":", parts: 2) localpart end def extract_network_channel_from_localpart(localpart) do s = localpart |> String.replace("dev.", "") |> String.split("/", parts: 2) case s do [network, channel] -> {:ok, network, channel} [channel] -> {:ok, "random", channel} _ -> {:error, :invalid_localpart} end end @impl MatrixAppService.Adapter.Room def query_alias(room_alias) do case lookup_or_create_room(room_alias) do {:ok, room_id} -> - LSG.Matrix.Room.start(room_id) + Nola.Matrix.Room.start(room_id) :ok error -> error end end @impl MatrixAppService.Adapter.Transaction def new_event(event = %MatrixAppService.Event{}) do Logger.debug("New matrix event: #{inspect event}") if event.room_id do - LSG.Matrix.Room.start_and_send_matrix_event(event.room_id, event) + Nola.Matrix.Room.start_and_send_matrix_event(event.room_id, event) end :noop end @impl MatrixAppService.Adapter.User def query_user(user_id) do Logger.warn("Matrix lookup user: #{inspect user_id}") :error end def client(opts \\ []) do base_url = Application.get_env(:matrix_app_service, :base_url) access_token = Application.get_env(:matrix_app_service, :access_token) default_opts = [ access_token: access_token, device_id: "APP_SERVICE", application_service: true, user_id: nil ] opts = Keyword.merge(default_opts, opts) Polyjuice.Client.LowLevel.create(base_url, opts) end end diff --git a/lib/lsg_matrix/plug.ex b/lib/lsg_matrix/plug.ex index c0c027f..c64ed11 100644 --- a/lib/lsg_matrix/plug.ex +++ b/lib/lsg_matrix/plug.ex @@ -1,25 +1,25 @@ -defmodule LSG.Matrix.Plug do +defmodule Nola.Matrix.Plug do defmodule Auth do def init(state) do state end def call(conn, _) do hs = Application.get_env(:matrix_app_service, :homeserver_token) MatrixAppServiceWeb.AuthPlug.call(conn, hs) end end defmodule SetConfig do def init(state) do state end def call(conn, _) do config = Application.get_all_env(:matrix_app_service) MatrixAppServiceWeb.SetConfigPlug.call(conn, config) end end end diff --git a/lib/lsg_matrix/room.ex b/lib/lsg_matrix/room.ex index 72b02c4..c790760 100644 --- a/lib/lsg_matrix/room.ex +++ b/lib/lsg_matrix/room.ex @@ -1,196 +1,196 @@ -defmodule LSG.Matrix.Room do +defmodule Nola.Matrix.Room do require Logger - alias LSG.Matrix + alias Nola.Matrix alias Polyjuice.Client import Matrix, only: [client: 0, client: 1, user_name: 1, myself?: 1] defmodule Supervisor do use DynamicSupervisor def start_link() do DynamicSupervisor.start_link(__MODULE__, [], name: __MODULE__) end def start_child(room_id) do - spec = %{id: room_id, start: {LSG.Matrix.Room, :start_link, [room_id]}, restart: :transient} + spec = %{id: room_id, start: {Nola.Matrix.Room, :start_link, [room_id]}, restart: :transient} DynamicSupervisor.start_child(__MODULE__, spec) end @impl true def init(_init_arg) do DynamicSupervisor.init( strategy: :one_for_one, max_restarts: 10, max_seconds: 1 ) end end def start(room_id) do __MODULE__.Supervisor.start_child(room_id) end def start_link(room_id) do GenServer.start_link(__MODULE__, [room_id], name: name(room_id)) end def start_and_send_matrix_event(room_id, event) do pid = if pid = whereis(room_id) do pid else case __MODULE__.start(room_id) do {:ok, pid} -> pid {:error, {:already_started, pid}} -> pid :ignore -> nil end end if(pid, do: send(pid, {:matrix_event, event})) end def whereis(room_id) do {:global, name} = name(room_id) case :global.whereis_name(name) do :undefined -> nil pid -> pid end end def name(room_id) do {:global, {__MODULE__, room_id}} end def init([room_id]) do case Matrix.lookup_room(room_id) do {:ok, state} -> Logger.metadata(matrix_room: room_id) {:ok, _} = Registry.register(IRC.PubSub, "#{state.network}:events", plugin: __MODULE__) for t <- ["messages", "triggers", "outputs", "events"] do {:ok, _} = Registry.register(IRC.PubSub, "#{state.network}/#{state.channel}:#{t}", plugin: __MODULE__) end state = state |> Map.put(:id, room_id) Logger.info("Started Matrix room #{room_id}") {:ok, state, {:continue, :update_state}} error -> Logger.info("Received event for nonexistent room #{inspect room_id}: #{inspect error}") :ignore end end def handle_continue(:update_state, state) do {:ok, s} = Client.Room.get_state(client(), state.id) members = Enum.reduce(s, [], fn(s, acc) -> if s["type"] == "m.room.member" do if s["content"]["membership"] == "join" do [s["user_id"] | acc] else # XXX: The user left, remove from IRC.Memberships ? acc end else acc end end) |> Enum.filter(& &1) for m <- members, do: IRC.UserTrack.joined(state.id, %{network: "matrix", nick: m, user: m, host: "matrix."}, [], true) accounts = IRC.UserTrack.channel(state.network, state.channel) |> Enum.filter(& &1) |> Enum.map(fn(tuple) -> IRC.UserTrack.User.from_tuple(tuple).account end) |> Enum.uniq() |> Enum.each(fn(account_id) -> introduce_irc_account(account_id, state) end) {:noreply, state} end def handle_info({:irc, :text, message}, state), do: handle_irc(message, state) def handle_info({:irc, :out, message}, state), do: handle_irc(message, state) def handle_info({:irc, :trigger, _, message}, state), do: handle_irc(message, state) def handle_info({:irc, :event, event}, state), do: handle_irc(event, state) def handle_info({:matrix_event, event}, state) do if myself?(event.user_id) do {:noreply, state} else handle_matrix(event, state) end end def handle_irc(message = %IRC.Message{account: account}, state) do unless Map.get(message.meta, :puppet) && Map.get(message.meta, :from) == self() do opts = if Map.get(message.meta, :self) || is_nil(account) do [] else mxid = Matrix.get_or_create_matrix_user(account.id) [user_id: mxid] end Client.Room.send_message(client(opts),state.id, message.text) end {:noreply, state} end def handle_irc(%{type: :join, account_id: account_id}, state) do introduce_irc_account(account_id, state) {:noreply, state} end def handle_irc(%{type: quit_or_part, account_id: account_id}, state) when quit_or_part in [:quit, :part] do mxid = Matrix.get_or_create_matrix_user(account_id) Client.Room.leave(client(user_id: mxid), state.id) {:noreply, state} end def handle_irc(event, state) do Logger.warn("Skipped irc event #{inspect event}") {:noreply, state} end def handle_matrix(event = %{type: "m.room.member", user_id: user_id, content: %{"membership" => "join"}}, state) do _account = get_account(event, state) IRC.UserTrack.joined(state.id, %{network: "matrix", nick: user_id, user: user_id, host: "matrix."}, [], true) {:noreply, state} end def handle_matrix(event = %{type: "m.room.member", user_id: user_id, content: %{"membership" => "leave"}}, state) do IRC.UserTrack.parted(state.id, %{network: "matrix", nick: user_id}) {:noreply, state} end def handle_matrix(event = %{type: "m.room.message", user_id: user_id, content: %{"msgtype" => "m.text", "body" => text}}, state) do IRC.send_message_as(get_account(event, state), state.network, state.channel, text, true) {:noreply, state} end def handle_matrix(event, state) do Logger.warn("Skipped matrix event #{inspect event}") {:noreply, state} end def get_account(%{user_id: user_id}, %{id: id}) do IRC.Account.find_by_nick("matrix", user_id) end defp introduce_irc_account(account_id, state) do mxid = Matrix.get_or_create_matrix_user(account_id) account = IRC.Account.get(account_id) user = IRC.UserTrack.find_by_account(state.network, account) base_nick = if(user, do: user.nick, else: account.name) case Client.Profile.put_displayname(client(user_id: mxid), base_nick) do :ok -> :ok error -> Logger.warn("Failed to update profile for #{mxid}: #{inspect error}") end case Client.Room.join(client(user_id: mxid), state.id) do {:ok, _} -> :ok error -> Logger.warn("Failed to join room for #{mxid}: #{inspect error}") end :ok end end diff --git a/lib/lsg_telegram/room.ex b/lib/lsg_telegram/room.ex index 4e86382..794cca3 100644 --- a/lib/lsg_telegram/room.ex +++ b/lib/lsg_telegram/room.ex @@ -1,188 +1,188 @@ -defmodule LSG.TelegramRoom do +defmodule Nola.TelegramRoom do require Logger @behaviour Telegram.ChatBot alias Telegram.Api @couch "bot-telegram-rooms" def rooms(), do: rooms(:with_docs) @spec rooms(:with_docs | :ids) :: [Map.t | integer( )] def rooms(:with_docs) do case Couch.get(@couch, :all_docs, include_docs: true) do {:ok, %{"rows" => rows}} -> {:ok, for(%{"doc" => doc} <- rows, do: doc)} error = {:error, _} -> error end end def rooms(:ids) do case Couch.get(@couch, :all_docs) do {:ok, %{"rows" => rows}} -> {:ok, for(%{"id" => id} <- rows, do: id)} error = {:error, _} -> error end end def room(id, opts \\ []) do Couch.get(@couch, id, opts) end # TODO: Create couch def setup() do :ok end def after_start() do - for id <- room(:ids), do: Telegram.Bot.ChatBot.Chat.Session.Supervisor.start_child(LSG.Telegram, Integer.parse(id) |> elem(0)) + for id <- room(:ids), do: Telegram.Bot.ChatBot.Chat.Session.Supervisor.start_child(Nola.Telegram, Integer.parse(id) |> elem(0)) end @impl Telegram.ChatBot def init(id) when is_integer(id) and id < 0 do token = Keyword.get(Application.get_env(:lsg, :telegram, []), :key) {:ok, chat} = Api.request(token, "getChat", chat_id: id) Logger.metadata(transport: :telegram, id: id, telegram_room_id: id) tg_room = case room(id) do {:ok, tg_room = %{"network" => _net, "channel" => _chan}} -> tg_room {:error, :not_found} -> [net, chan] = String.split(chat["title"], "/", parts: 2) {net, chan} = case IRC.Connection.get_network(net, chan) do %IRC.Connection{} -> {net, chan} _ -> {nil, nil} end {:ok, _id, _rev} = Couch.post(@couch, %{"_id" => id, "network" => net, "channel" => nil}) {:ok, tg_room} = room(id) tg_room end %{"network" => net, "channel" => chan} = tg_room Logger.info("Starting ChatBot for room #{id} \"#{chat["title"]}\" #{inspect tg_room}") irc_plumbed = if net && chan do {:ok, _} = Registry.register(IRC.PubSub, "#{net}/#{chan}:messages", plugin: __MODULE__) {:ok, _} = Registry.register(IRC.PubSub, "#{net}/#{chan}:triggers", plugin: __MODULE__) {:ok, _} = Registry.register(IRC.PubSub, "#{net}/#{chan}:outputs", plugin: __MODULE__) true else Logger.warn("Did not found telegram match for #{id} \"#{chat["title"]}\"") false end {:ok, %{id: id, net: net, chan: chan, irc: irc_plumbed}} end def init(id) do Logger.error("telegram_room: bad id (not room id)", transport: :telegram, id: id, telegram_room_id: id) :ignoree end defp find_or_create_meta_account(from = %{"id" => user_id}, state) do if account = IRC.Account.find_meta_account("telegram-id", user_id) do account else first_name = Map.get(from, "first_name") last_name = Map.get(from, "last_name") name = [first_name, last_name] |> Enum.filter(& &1) |> Enum.join(" ") username = Map.get(from, "username", first_name) account = username |> IRC.Account.new_account() |> IRC.Account.update_account_name(name) |> IRC.Account.put_meta("telegram-id", user_id) Logger.info("telegram_room: created account #{account.id} for telegram user #{user_id}") account end end def handle_update(%{"message" => %{"from" => from = %{"id" => user_id}, "text" => text}}, _token, state) do account = find_or_create_meta_account(from, state) connection = IRC.Connection.get_network(state.net) IRC.send_message_as(account, state.net, state.chan, text, true) {:ok, state} end def handle_update(data = %{"message" => %{"from" => from = %{"id" => user_id}, "location" => %{"latitude" => lat, "longitude" => lon}}}, _token, state) do account = find_or_create_meta_account(from, state) connection = IRC.Connection.get_network(state.net) IRC.send_message_as(account, state.net, state.chan, "@ #{lat}, #{lon}", true) {:ok, state} end for type <- ~w(photo voice video document animation) do def handle_update(data = %{"message" => %{unquote(type) => _}}, token, state) do upload(unquote(type), data, token, state) end end def handle_update(update, token, state) do {:ok, state} end def handle_info({:irc, _, _, message}, state) do handle_info({:irc, nil, message}, state) end def handle_info({:irc, _, message = %IRC.Message{sender: %{nick: nick}, text: text}}, state) do if Map.get(message.meta, :from) == self() do else body = if Map.get(message.meta, :self), do: text, else: "<#{nick}> #{text}" - LSG.Telegram.send_message(state.id, body) + Nola.Telegram.send_message(state.id, body) end {:ok, state} end def handle_info(info, state) do Logger.info("UNhandled #{inspect info}") {:ok, state} end defp upload(_type, %{"message" => m = %{"chat" => %{"id" => chat_id}, "from" => from = %{"id" => user_id}}}, token, state) do account = find_or_create_meta_account(from, state) if account do {content, type} = cond do m["photo"] -> {m["photo"], "photo"} m["voice"] -> {m["voice"], "voice message"} m["video"] -> {m["video"], "video"} m["document"] -> {m["document"], "file"} m["animation"] -> {m["animation"], "gif"} end file = if is_list(content) && Enum.count(content) > 1 do Enum.sort_by(content, fn(p) -> p["file_size"] end, &>=/2) |> List.first() else content end file_id = file["file_id"] file_unique_id = file["file_unique_id"] text = if(m["caption"], do: m["caption"] <> " ", else: "") spawn(fn() -> with \ {:ok, file} <- Telegram.Api.request(token, "getFile", file_id: file_id), path = "https://api.telegram.org/file/bot#{token}/#{file["file_path"]}", {:ok, %HTTPoison.Response{status_code: 200, body: body}} <- HTTPoison.get(path), <> = body, - {:ok, magic} <- GenMagic.Pool.perform(LSG.GenMagic, {:bytes, smol_body}), + {:ok, magic} <- GenMagic.Pool.perform(Nola.GenMagic, {:bytes, smol_body}), bucket = Application.get_env(:lsg, :s3, []) |> Keyword.get(:bucket), ext = Path.extname(file["file_path"]), s3path = "#{account.id}/#{file_unique_id}#{ext}", s3req = ExAws.S3.put_object(bucket, s3path, body, acl: :public_read, content_type: magic.mime_type), {:ok, _} <- ExAws.request(s3req) do - path = LSGWeb.Router.Helpers.url(LSGWeb.Endpoint) <> "/files/#{s3path}" + path = NolaWeb.Router.Helpers.url(NolaWeb.Endpoint) <> "/files/#{s3path}" txt = "#{type}: #{text}#{path}" connection = IRC.Connection.get_network(state.net) IRC.send_message_as(account, state.net, state.chan, txt, true) else error -> Telegram.Api.request(token, "sendMessage", chat_id: chat_id, text: "File upload failed, sorry.") Logger.error("Failed upload from Telegram: #{inspect error}") end end) {:ok, state} end end end diff --git a/lib/lsg_telegram/telegram.ex b/lib/lsg_telegram/telegram.ex index 748a456..ef5c3b8 100644 --- a/lib/lsg_telegram/telegram.ex +++ b/lib/lsg_telegram/telegram.ex @@ -1,233 +1,233 @@ -defmodule LSG.Telegram do +defmodule Nola.Telegram do require Logger @behaviour Telegram.ChatBot def my_path() do "https://t.me/beauttebot" end def send_message(id, text, md2 \\ false) do md = if md2, do: "MarkdownV2", else: "Markdown" token = Keyword.get(Application.get_env(:lsg, :telegram, []), :key) - Telegram.Bot.ChatBot.Chat.Session.Supervisor.start_child(LSG.Telegram, id) + Telegram.Bot.ChatBot.Chat.Session.Supervisor.start_child(Nola.Telegram, id) Telegram.Api.request(token, "sendMessage", chat_id: id, text: text, parse_mode: "Markdown") end @impl Telegram.ChatBot def init(chat_id) when chat_id < 0 do - {:ok, state} = LSG.TelegramRoom.init(chat_id) + {:ok, state} = Nola.TelegramRoom.init(chat_id) {:ok, %{room_state: state}} end def init(chat_id) do Logger.info("Telegram session starting: #{chat_id}") account = IRC.Account.find_meta_account("telegram-id", chat_id) account_id = if account, do: account.id {:ok, %{account: account_id}} end @impl Telegram.ChatBot def handle_update(update, token, %{room_state: room_state}) do - {:ok, room_state} = LSG.TelegramRoom.handle_update(update, token, room_state) + {:ok, room_state} = Nola.TelegramRoom.handle_update(update, token, room_state) {:ok, %{room_state: room_state}} end def handle_update(%{"message" => m = %{"chat" => %{"type" => "private"}, "text" => text = "/start"<>_}}, _token, state) do text = "*Welcome to beautte!*\n\nQuery the bot on IRC and say \"enable-telegram\" to continue." send_message(m["chat"]["id"], text) {:ok, %{account: nil}} end def handle_update(%{"message" => m = %{"chat" => %{"type" => "private"}, "text" => text = "/enable"<>_}}, _token, state) do key = case String.split(text, " ") do ["/enable", key | _] -> key _ -> "nil" end #Handled message "1247435154:AAGnSSCnySn0RuVxy_SUcDEoOX_rbF6vdq0" %{"message" => # %{"chat" => %{"first_name" => "J", "id" => 2075406, "type" => "private", "username" => "ahref"}, # "date" => 1591027272, "entities" => # [%{"length" => 7, "offset" => 0, "type" => "bot_command"}], # "from" => %{"first_name" => "J", "id" => 2075406, "is_bot" => false, "language_code" => "en", "username" => "ahref"}, # "message_id" => 11, "text" => "/enable salope"}, "update_id" => 764148578} account = IRC.Account.find_meta_account("telegram-validation-code", String.downcase(key)) text = if account do net = IRC.Account.get_meta(account, "telegram-validation-target") IRC.Account.put_meta(account, "telegram-id", m["chat"]["id"]) IRC.Account.put_meta(account, "telegram-username", m["chat"]["username"]) IRC.Account.put_meta(account, "telegram-username", m["chat"]["username"]) IRC.Account.delete_meta(account, "telegram-validation-code") IRC.Account.delete_meta(account, "telegram-validation-target") IRC.Connection.broadcast_message(net, account, "Telegram #{m["chat"]["username"]} account added!") "Yay! Linked to account **#{account.name}**." else "Token invalid" end send_message(m["chat"]["id"], text) {:ok, %{account: account.id}} end #[debug] Unhandled update: %{"message" => # %{"chat" => %{"first_name" => "J", "id" => 2075406, "type" => "private", "username" => "ahref"}, # "date" => 1591096015, # "from" => %{"first_name" => "J", "id" => 2075406, "is_bot" => false, "language_code" => "en", "username" => "ahref"}, # "message_id" => 29, # "photo" => [ # %{"file_id" => "AgACAgQAAxkBAAMdXtYyz4RQqLcpOlR6xKK3w3NayHAAAnCzMRuL4bFSgl_cTXMl4m5G_T0kXQADAQADAgADbQADZVMBAAEaBA", # "file_size" => 9544, "file_unique_id" => "AQADRv09JF0AA2VTAQAB", "height" => 95, "width" => 320}, # %{"file_id" => "AgACAgQAAxkBAAMdXtYyz4RQqLcpOlR6xKK3w3NayHAAAnCzMRuL4bFSgl_cTXMl4m5G_T0kXQADAQADAgADeAADZFMBAAEaBA", # "file_size" => 21420, "file_unique_id" => "AQADRv09JF0AA2RTAQAB", "height" => 148, "width" => 501}]}, # "update_id" => 218161546} for type <- ~w(photo voice video document animation) do def handle_update(data = %{"message" => %{unquote(type) => _}}, token, state) do start_upload(unquote(type), data, token, state) end end #[debug] Unhandled update: %{"callback_query" => # %{ # "chat_instance" => "-7948978714441865930", "data" => "evolu.net/#dmz", # "from" => %{"first_name" => "J", "id" => 2075406, "is_bot" => false, "language_code" => "en", "username" => "ahref"}, # "id" => "8913804780149600", # "message" => %{"chat" => %{"first_name" => "J", "id" => 2075406, "type" => "private", "username" => "ahref"}, # "date" => 1591098553, "from" => %{"first_name" => "devbeautte", "id" => 1293058838, "is_bot" => true, "username" => "devbeauttebot"}, # "message_id" => 62, # "reply_markup" => %{"inline_keyboard" => [[%{"callback_data" => "random/#", "text" => "random/#"}, # %{"callback_data" => "evolu.net/#dmz", "text" => "evolu.net/#dmz"}]]}, # "text" => "Where should I send the file?"} # } # , "update_id" => 218161568} #def handle_update(t, %{"callback_query" => cb = %{"data" => "resend", "id" => id, "message" => m = %{"message_id" => m_id, "chat" => %{"id" => chat_id}, "reply_to_message" => op}}}) do #end def handle_update(%{"callback_query" => cb = %{"data" => "start-upload:"<>target, "id" => id, "message" => m = %{"message_id" => m_id, "chat" => %{"id" => chat_id}, "reply_to_message" => op}}}, t, state) do account = IRC.Account.find_meta_account("telegram-id", chat_id) if account do target = case String.split(target, "/") do ["everywhere"] -> IRC.Membership.of_account(account) [net, chan] -> [{net, chan}] end Telegram.Api.request(t, "editMessageText", chat_id: chat_id, message_id: m_id, text: "Processing...", reply_markup: %{}) {content, type} = cond do op["photo"] -> {op["photo"], ""} op["voice"] -> {op["voice"], " a voice message"} op["video"] -> {op["video"], ""} op["document"] -> {op["document"], ""} op["animation"] -> {op["animation"], ""} end file = if is_list(content) && Enum.count(content) > 1 do Enum.sort_by(content, fn(p) -> p["file_size"] end, &>=/2) |> List.first() else content end file_id = file["file_id"] file_unique_id = file["file_unique_id"] text = if(op["caption"], do: ": "<> op["caption"] <> "", else: "") resend = %{"inline_keyboard" => [ [%{"text" => "re-share", "callback_data" => "resend"}] ]} spawn(fn() -> with \ {:ok, file} <- Telegram.Api.request(t, "getFile", file_id: file_id), path = "https://api.telegram.org/file/bot#{t}/#{file["file_path"]}", {:ok, %HTTPoison.Response{status_code: 200, body: body}} <- HTTPoison.get(path), <> = body, - {:ok, magic} <- GenMagic.Pool.perform(LSG.GenMagic, {:bytes, smol_body}), + {:ok, magic} <- GenMagic.Pool.perform(Nola.GenMagic, {:bytes, smol_body}), bucket = Application.get_env(:lsg, :s3, []) |> Keyword.get(:bucket), ext = Path.extname(file["file_path"]), s3path = "#{account.id}/#{file_unique_id}#{ext}", Telegram.Api.request(t, "editMessageText", chat_id: chat_id, message_id: m_id, text: "*Uploading...*", reply_markup: %{}, parse_mode: "MarkdownV2"), s3req = ExAws.S3.put_object(bucket, s3path, body, acl: :public_read, content_type: magic.mime_type), {:ok, _} <- ExAws.request(s3req) do - path = LSGWeb.Router.Helpers.url(LSGWeb.Endpoint) <> "/files/#{s3path}" + path = NolaWeb.Router.Helpers.url(NolaWeb.Endpoint) <> "/files/#{s3path}" sent = for {net, chan} <- target do txt = "sent#{type}#{text} #{path}" IRC.send_message_as(account, net, chan, txt) "#{net}/#{chan}" end if caption = op["caption"], do: as_irc_message(chat_id, caption, account) text = "Sent on " <> Enum.join(sent, ", ") <> " !" Telegram.Api.request(t, "editMessageText", chat_id: chat_id, message_id: m_id, text: "_Sent!_", reply_markup: %{}, parse_mode: "MarkdownV2") else error -> Telegram.Api.request(t, "editMessageText", chat_id: chat_id, message_id: m_id, text: "Something failed.", reply_markup: %{}, parse_mode: "MarkdownV2") Logger.error("Failed upload from Telegram: #{inspect error}") end end) end {:ok, state} end def handle_update(%{"message" => m = %{"chat" => %{"id" => id, "type" => "private"}, "text" => text}}, _, state) do account = IRC.Account.find_meta_account("telegram-id", id) if account do as_irc_message(id, text, account) end {:ok, state} end def handle_update(m, _, state) do Logger.debug("Unhandled update: #{inspect m}") {:ok, state} end @impl Telegram.ChatBot def handle_info(info, %{room_state: room_state}) do - {:ok, room_state} = LSG.TelegramRoom.handle_info(info, room_state) + {:ok, room_state} = Nola.TelegramRoom.handle_info(info, room_state) {:ok, %{room_state: room_state}} end def handle_info(_info, state) do {:ok, state} end defp as_irc_message(id, text, account) do reply_fun = fn(text) -> send_message(id, text) end trigger_text = cond do String.starts_with?(text, "/") -> "/"<>text = text "!"<>text Enum.any?(IRC.Connection.triggers(), fn({trigger, _}) -> String.starts_with?(text, trigger) end) -> text true -> "!"<>text end message = %IRC.Message{ id: FlakeId.get(), transport: :telegram, network: "telegram", channel: nil, text: text, account: account, sender: %ExIRC.SenderInfo{nick: account.name}, replyfun: reply_fun, trigger: IRC.Connection.extract_trigger(trigger_text), at: nil } IRC.Connection.publish(message, ["messages:private", "messages:telegram", "telegram/#{account.id}:messages"]) message end defp start_upload(_type, %{"message" => m = %{"chat" => %{"id" => id, "type" => "private"}}}, token, state) do account = IRC.Account.find_meta_account("telegram-id", id) if account do text = if(m["text"], do: m["text"], else: nil) targets = IRC.Membership.of_account(account) |> Enum.map(fn({net, chan}) -> "#{net}/#{chan}" end) |> Enum.map(fn(i) -> %{"text" => i, "callback_data" => "start-upload:#{i}"} end) kb = if Enum.count(targets) > 1 do [%{"text" => "everywhere", "callback_data" => "start-upload:everywhere"}] ++ targets else targets end |> Enum.chunk_every(2) keyboard = %{"inline_keyboard" => kb} Telegram.Api.request(token, "sendMessage", chat_id: id, text: "Where should I send this file?", reply_markup: keyboard, reply_to_message_id: m["message_id"], parse_mode: "MarkdownV2") end {:ok, state} end end diff --git a/lib/lsg_web/channels/user_socket.ex b/lib/lsg_web/channels/user_socket.ex index 691a26c..eadd4e0 100644 --- a/lib/lsg_web/channels/user_socket.ex +++ b/lib/lsg_web/channels/user_socket.ex @@ -1,37 +1,37 @@ -defmodule LSGWeb.UserSocket do +defmodule NolaWeb.UserSocket do use Phoenix.Socket ## Channels - # channel "room:*", LSGWeb.RoomChannel + # channel "room:*", NolaWeb.RoomChannel ## Transports #transport :websocket, Phoenix.Transports.WebSocket # transport :longpoll, Phoenix.Transports.LongPoll # Socket params are passed from the client and can # be used to verify and authenticate a user. After # verification, you can put default assigns into # the socket that will be set for all channels, ie # # {:ok, assign(socket, :user_id, verified_user_id)} # # To deny connection, return `:error`. # # See `Phoenix.Token` documentation for examples in # performing token verification on connect. def connect(_params, socket) do {:ok, socket} end # Socket id's are topics that allow you to identify all sockets for a given user: # # def id(socket), do: "user_socket:#{socket.assigns.user_id}" # # Would allow you to broadcast a "disconnect" event and terminate # all active sockets and channels for a given user: # - # LSGWeb.Endpoint.broadcast("user_socket:#{user.id}", "disconnect", %{}) + # NolaWeb.Endpoint.broadcast("user_socket:#{user.id}", "disconnect", %{}) # # Returning `nil` makes this socket anonymous. def id(_socket), do: nil end diff --git a/lib/lsg_web/components/component.ex b/lib/lsg_web/components/component.ex index d504129..fff8263 100644 --- a/lib/lsg_web/components/component.ex +++ b/lib/lsg_web/components/component.ex @@ -1,44 +1,44 @@ -defmodule LSGWeb.Component do +defmodule NolaWeb.Component do use Phoenix.Component @date_time_default_format "%F %H:%M" @date_time_formats %{"time-24-with-seconds" => "%H:%M:%S"} def naive_date_time_utc(assigns = %{at: nil}) do "" end def naive_date_time_utc(assigns = %{format: format}) do assigns = assign(assigns, :format, Map.get(@date_time_formats, format, format)) ~H""" """ end def naive_date_time_utc(assigns) do naive_date_time_utc(assign(assigns, :format, "%F %H:%M")) end def get_luxon_format("%H:%M:%S"), do: "TIME_24_WITH_SECONDS" def nick(assigns = %{self: false}) do ~H""" <%= @nick %> """ end def nick(assigns = %{self: true}) do ~H""" You """ end end diff --git a/lib/lsg_web/components/event_component.ex b/lib/lsg_web/components/event_component.ex index fa81d19..8af3c67 100644 --- a/lib/lsg_web/components/event_component.ex +++ b/lib/lsg_web/components/event_component.ex @@ -1,43 +1,43 @@ -defmodule LSGWeb.EventComponent do +defmodule NolaWeb.EventComponent do use Phoenix.Component def content(assigns = %{event: %{type: :day_changed}}) do ~H""" Day changed: <%= Date.to_string(@date) %> """ end def content(assigns = %{event: %{type: :quit}}) do ~H""" - + has quit: <%= @reason %> """ end def content(assigns = %{event: %{type: :part}}) do ~H""" - + has left: <%= @reason %> """ end def content(assigns = %{event: %{type: :nick}}) do ~H""" <%= @old_nick %> is now known as - + """ end def content(assigns = %{event: %{type: :join}}) do ~H""" - + joined """ end end diff --git a/lib/lsg_web/components/message_component.ex b/lib/lsg_web/components/message_component.ex index 5997754..5d0386b 100644 --- a/lib/lsg_web/components/message_component.ex +++ b/lib/lsg_web/components/message_component.ex @@ -1,12 +1,12 @@ -defmodule LSGWeb.MessageComponent do +defmodule NolaWeb.MessageComponent do use Phoenix.Component def content(assigns) do ~H""" - +
<%= @message.sender.nick %>
<%= @text %>
""" end end diff --git a/lib/lsg_web/context_plug.ex b/lib/lsg_web/context_plug.ex index aaf851e..ebededa 100644 --- a/lib/lsg_web/context_plug.ex +++ b/lib/lsg_web/context_plug.ex @@ -1,92 +1,92 @@ -defmodule LSGWeb.ContextPlug do +defmodule NolaWeb.ContextPlug do import Plug.Conn import Phoenix.Controller def init(opts \\ []) do opts || [] end def get_account(conn) do cond do get_session(conn, :account) -> get_session(conn, :account) get_session(conn, :oidc_id) -> if account = IRC.Account.find_meta_account("identity-id", get_session(conn, :oidc_id)), do: account.id true -> nil end end def call(conn, opts) do account = with \ {:account, account_id} when is_binary(account_id) <- {:account, get_account(conn)}, {:account, account} when not is_nil(account) <- {:account, IRC.Account.get(account_id)} do account else _ -> nil end network = Map.get(conn.params, "network") network = if network == "-", do: nil, else: network oidc_account = IRC.Account.find_meta_account("identity-id", get_session(conn, :oidc_id)) conns = IRC.Connection.get_network(network) chan = if c = Map.get(conn.params, "chan") do - LSGWeb.reformat_chan(c) + NolaWeb.reformat_chan(c) end chan_conn = IRC.Connection.get_network(network, chan) memberships = if account do IRC.Membership.of_account(account) end auth_required = cond do Keyword.get(opts, :restrict) == :public -> false account == nil -> true network == nil -> false Keyword.get(opts, :restrict) == :logged_in -> false network && chan -> !Enum.member?(memberships, {network, chan}) network -> !Enum.any?(memberships, fn({n, _}) -> n == network end) end bot = cond do network && chan && chan_conn -> chan_conn.nick network && conns -> conns.nick true -> nil end cond do account && auth_required -> conn |> put_status(404) |> text("Page not found") |> halt() auth_required -> conn |> put_status(403) - |> render(LSGWeb.AlcoologView, "auth.html", bot: bot, no_header: true, network: network) + |> render(NolaWeb.AlcoologView, "auth.html", bot: bot, no_header: true, network: network) |> halt() (network && !conns) -> conn |> put_status(404) |> text("Page not found") |> halt() (chan && !chan_conn) -> conn |> put_status(404) |> text("Page not found") |> halt() true -> conn = conn |> assign(:network, network) |> assign(:chan, chan) |> assign(:bot, bot) |> assign(:account, account) |> assign(:oidc_account, oidc_account) |> assign(:memberships, memberships) end end end diff --git a/lib/lsg_web/controllers/alcoolog_controller.ex b/lib/lsg_web/controllers/alcoolog_controller.ex index 6542f15..3081762 100644 --- a/lib/lsg_web/controllers/alcoolog_controller.ex +++ b/lib/lsg_web/controllers/alcoolog_controller.ex @@ -1,323 +1,323 @@ -defmodule LSGWeb.AlcoologController do - use LSGWeb, :controller +defmodule NolaWeb.AlcoologController do + use NolaWeb, :controller require Logger - plug LSGWeb.ContextPlug when action not in [:token] - plug LSGWeb.ContextPlug, [restrict: :public] when action in [:token] + plug NolaWeb.ContextPlug when action not in [:token] + plug NolaWeb.ContextPlug, [restrict: :public] when action in [:token] def token(conn, %{"token" => token}) do - case LSG.Token.lookup(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 = LSG.IRC.AlcoologPlugin.get_full_statistics(profile_account.id) - history = for {{nick, ts}, points, active, cl, deg, type, descr, meta} <- LSG.IRC.AlcoologPlugin.nick_history(profile_account) 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 %{ 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 = LSG.IRC.AlcoologPlugin.get_full_statistics(profile_account.id) + stats = Nola.IRC.AlcoologPlugin.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 = LSG.IRC.AlcoologPlugin.user_over_time_gl(profile_account, count) + data = Nola.IRC.AlcoologPlugin.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 = LSG.IRC.AlcoologPlugin.user_over_time(profile_account, count) + data = Nola.IRC.AlcoologPlugin.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} <- LSG.IRC.AlcoologPlugin.nick_history(profile_account) do + history = for {{nick, ts}, points, active, cl, deg, type, descr, meta} <- Nola.IRC.AlcoologPlugin.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} = LSG.IRC.AlcoologPlugin.user_stats(profile_account) + {_, active} = Nola.IRC.AlcoologPlugin.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} <- LSG.IRC.AlcoologAnnouncerPlugin.log(profile_account) do + history = for {_, date, value} <- Nola.IRC.AlcoologAnnouncerPlugin.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, LSGWeb.reformat_chan(channel)) + 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 - # LSGWeb.reformat_chan(c) + # 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(LSG.IRC.AlcoologPlugin.ETS, match) + drinks = :ets.select(Nola.IRC.AlcoologPlugin.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 = LSG.IRC.AlcoologPlugin.get_channel_statistics(account, network, channel) + stats = Nola.IRC.AlcoologPlugin.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 = LSGWeb.reformat_chan(channel) + 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(LSG.IRC.AlcoologPlugin.user_over_time_gl(account, count), gls, fn({date, gl}, gls) -> + Enum.reduce(Nola.IRC.AlcoologPlugin.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 = LSG.IRC.AlcoologPlugin.data_state() - meta = LSG.IRC.AlcoologPlugin.get_user_meta(ds, account.id) + ds = Nola.IRC.AlcoologPlugin.data_state() + meta = Nola.IRC.AlcoologPlugin.get_user_meta(ds, account.id) case Float.parse(value) do {val, _} -> new_meta = Map.put(meta, String.to_existing_atom(key), val) - LSG.IRC.AlcoologPlugin.put_user_meta(ds, account.id, new_meta) + Nola.IRC.AlcoologPlugin.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/lsg_web/controllers/gpt_controller.ex b/lib/lsg_web/controllers/gpt_controller.ex index acf9b27..038b235 100644 --- a/lib/lsg_web/controllers/gpt_controller.ex +++ b/lib/lsg_web/controllers/gpt_controller.ex @@ -1,33 +1,33 @@ -defmodule LSGWeb.GptController do - use LSGWeb, :controller +defmodule NolaWeb.GptController do + use NolaWeb, :controller require Logger - plug LSGWeb.ContextPlug + plug NolaWeb.ContextPlug def result(conn, params = %{"id" => result_id}) do - case LSG.IRC.GptPlugin.get_result(result_id) do + case Nola.IRC.GptPlugin.get_result(result_id) do {:ok, result} -> network = Map.get(params, "network") - channel = if c = Map.get(params, "chan"), do: LSGWeb.reformat_chan(c) + 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 LSG.IRC.GptPlugin.get_prompt(prompt_id) do + case Nola.IRC.GptPlugin.get_prompt(prompt_id) do {:ok, prompt} -> network = Map.get(params, "network") - channel = if c = Map.get(params, "chan"), do: LSGWeb.reformat_chan(c) + 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/lsg_web/controllers/icecast_see_controller.ex b/lib/lsg_web/controllers/icecast_see_controller.ex index 1eecca1..877ad4e 100644 --- a/lib/lsg_web/controllers/icecast_see_controller.ex +++ b/lib/lsg_web/controllers/icecast_see_controller.ex @@ -1,41 +1,41 @@ -defmodule LSGWeb.IcecastSseController do - use LSGWeb, :controller +defmodule NolaWeb.IcecastSseController do + use NolaWeb, :controller require Logger @ping_interval 20_000 def sse(conn, _params) do conn |> put_resp_header("X-Accel-Buffering", "no") |> put_resp_header("content-type", "text/event-stream") |> send_chunked(200) |> subscribe |> send_sse_message("ping", "ping") - |> send_sse_message("icecast", LSG.IcecastAgent.get) + |> send_sse_message("icecast", Nola.IcecastAgent.get) |> sse_loop end def subscribe(conn) do :timer.send_interval(@ping_interval, {:event, :ping}) - {:ok, _} = Registry.register(LSG.BroadcastRegistry, "icecast", []) + {:ok, _} = Registry.register(Nola.BroadcastRegistry, "icecast", []) conn end def sse_loop(conn) do {type, event} = receive do {:event, :ping} -> {"ping", "ping"} {:icecast, stats} -> {"icecast", stats} end conn |> send_sse_message(type, event) |> sse_loop() end defp send_sse_message(conn, type, data) do json = Jason.encode!(%{type => data}) {:ok, conn} = chunk(conn, "event: #{type}\ndata: #{json}\n\n") conn end end diff --git a/lib/lsg_web/controllers/irc_auth_sse_controller.ex b/lib/lsg_web/controllers/irc_auth_sse_controller.ex index f370d97..62ee2b5 100644 --- a/lib/lsg_web/controllers/irc_auth_sse_controller.ex +++ b/lib/lsg_web/controllers/irc_auth_sse_controller.ex @@ -1,66 +1,66 @@ -defmodule LSGWeb.IrcAuthSseController do - use LSGWeb, :controller +defmodule NolaWeb.IrcAuthSseController do + use NolaWeb, :controller require Logger @ping_interval 20_000 @expire_delay :timer.minutes(3) def sse(conn, params) do perks = if uri = Map.get(params, "redirect_to") do {:redirect, uri} else nil end token = String.downcase(EntropyString.random_string(65)) conn |> assign(:token, token) |> assign(:perks, perks) |> put_resp_header("X-Accel-Buffering", "no") |> put_resp_header("content-type", "text/event-stream") |> send_chunked(200) |> subscribe() |> send_sse_message("token", token) |> sse_loop end def subscribe(conn) do :timer.send_interval(@ping_interval, {:event, :ping}) :timer.send_after(@expire_delay, {:event, :expire}) {:ok, _} = Registry.register(IRC.PubSub, "messages:private", []) conn end def sse_loop(conn) do {type, event, exit} = receive do {:event, :ping} -> {"ping", "ping", false} {:event, :expire} -> {"expire", "expire", true} {:irc, :text, %{account: account, text: token} = m} -> if String.downcase(String.trim(token)) == conn.assigns.token do - path = LSG.AuthToken.new_path(account.id, conn.assigns.perks) + path = Nola.AuthToken.new_path(account.id, conn.assigns.perks) m.replyfun.("ok!") {"authenticated", path, true} else {nil, nil, false} end _ -> {nil, nil, false} end conn = if type do send_sse_message(conn, type, event) else conn end if exit do conn else sse_loop(conn) end end defp send_sse_message(conn, type, data) do {:ok, conn} = chunk(conn, "event: #{type}\ndata: #{data}\n\n") conn end end diff --git a/lib/lsg_web/controllers/irc_controller.ex b/lib/lsg_web/controllers/irc_controller.ex index d518481..90d9853 100644 --- a/lib/lsg_web/controllers/irc_controller.ex +++ b/lib/lsg_web/controllers/irc_controller.ex @@ -1,101 +1,101 @@ -defmodule LSGWeb.IrcController do - use LSGWeb, :controller +defmodule NolaWeb.IrcController do + use NolaWeb, :controller - plug LSGWeb.ContextPlug + plug NolaWeb.ContextPlug def index(conn, params) do network = Map.get(params, "network") - channel = if c = Map.get(params, "chan"), do: LSGWeb.reformat_chan(c) + channel = if c = Map.get(params, "chan"), do: NolaWeb.reformat_chan(c) commands = for mod <- Enum.uniq([IRC.Account.AccountPlugin] ++ IRC.Plugin.enabled()) do if is_atom(mod) do identifier = Module.split(mod) |> List.last |> String.replace("Plugin", "") |> 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 = LSG.IRC.TxtPlugin.irc_doc() + doc = Nola.IRC.TxtPlugin.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}/#{LSGWeb.format_chan(conn.assigns.chan)}" + 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(:lsg, :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/lsg_web/controllers/network_controller.ex b/lib/lsg_web/controllers/network_controller.ex index 537c2f6..800294f 100644 --- a/lib/lsg_web/controllers/network_controller.ex +++ b/lib/lsg_web/controllers/network_controller.ex @@ -1,11 +1,11 @@ -defmodule LSGWeb.NetworkController do - use LSGWeb, :controller - plug LSGWeb.ContextPlug +defmodule NolaWeb.NetworkController do + use NolaWeb, :controller + plug NolaWeb.ContextPlug def index(conn, %{"network" => network}) do conn |> assign(:title, network) |> render("index.html") end end diff --git a/lib/lsg_web/controllers/open_id_controller.ex b/lib/lsg_web/controllers/open_id_controller.ex index d5af318..94166eb 100644 --- a/lib/lsg_web/controllers/open_id_controller.ex +++ b/lib/lsg_web/controllers/open_id_controller.ex @@ -1,64 +1,64 @@ -defmodule LSGWeb.OpenIdController do - use LSGWeb, :controller - plug LSGWeb.ContextPlug, restrict: :public +defmodule NolaWeb.OpenIdController do + use NolaWeb, :controller + plug NolaWeb.ContextPlug, restrict: :public require Logger def login(conn, _) do url = OAuth2.Client.authorize_url!(new_client(), scope: "openid", state: Base.url_encode64(:crypto.strong_rand_bytes(32), padding: false)) redirect(conn, external: url) end def callback(conn, %{"error" => error_code, "error_description" => error}) do Logger.warn("OpenId error: #{error_code} #{error}") render(conn, "error.html", error: error) end def callback(conn, %{"code" => code, "state" => state}) do with \ client = %{token: %OAuth2.AccessToken{access_token: json}} = OAuth2.Client.get_token!(new_client(), state: state, code: code), {:ok, %{"access_token" => token}} <- Jason.decode(json), client = %OAuth2.Client{client | token: %OAuth2.AccessToken{access_token: token}}, {:ok, %OAuth2.Response{body: body}} <- OAuth2.Client.get(client, "/userinfo"), {:ok, %{"sub" => id, "preferred_username" => username}} <- Jason.decode(body) do if account = conn.assigns.account do if !IRC.Account.get_meta(account, "identity-id") do # XXX: And oidc id not linked yet IRC.Account.put_meta(account, "identity-id", id) end IRC.Account.put_meta(account, "identity-username", username) conn else conn end conn |> put_session(:oidc_id, id) |> put_flash(:info, "Logged in!") |> redirect(to: Routes.path(conn, "/")) else {:error, %OAuth2.Response{status_code: 401}} -> Logger.error("OpenID: Unauthorized token") render(conn, "error.html", error: "The token is invalid.") {:error, %OAuth2.Error{reason: reason}} -> Logger.error("Error: #{inspect reason}") render(conn, "error.html", error: reason) end end def callback(conn, _params) do render(conn, "error.html", error: "Unspecified error.") end defp new_client() do config = Application.get_env(:lsg, :oidc) OAuth2.Client.new([ strategy: OAuth2.Strategy.AuthCode, client_id: config[:client_id], client_secret: config[:client_secret], site: config[:base_url], authorize_url: config[:authorize_url], token_url: config[:token_url], - redirect_uri: Routes.open_id_url(LSGWeb.Endpoint, :callback) + redirect_uri: Routes.open_id_url(NolaWeb.Endpoint, :callback) ]) end end diff --git a/lib/lsg_web/controllers/page_controller.ex b/lib/lsg_web/controllers/page_controller.ex index 94c9c70..2ac4d0a 100644 --- a/lib/lsg_web/controllers/page_controller.ex +++ b/lib/lsg_web/controllers/page_controller.ex @@ -1,53 +1,53 @@ -defmodule LSGWeb.PageController do - use LSGWeb, :controller +defmodule NolaWeb.PageController do + use NolaWeb, :controller - plug LSGWeb.ContextPlug when action not in [:token] - plug LSGWeb.ContextPlug, [restrict: :public] when action in [:token] + plug NolaWeb.ContextPlug when action not in [:token] + plug NolaWeb.ContextPlug, [restrict: :public] when action in [:token] def token(conn, %{"token" => token}) do with \ - {:ok, account, perks} <- LSG.AuthToken.lookup(token) + {:ok, account, perks} <- Nola.AuthToken.lookup(token) do IO.puts("Authenticated account #{inspect account}") conn = put_session(conn, :account, account) case perks do nil -> redirect(conn, to: "/") {:redirect, path} -> redirect(conn, to: path) {:external_redirect, url} -> redirect(conn, external: url) end else z -> IO.inspect(z) text(conn, "Error: invalid or expired token") end end def index(conn = %{assigns: %{account: account}}, _) do memberships = IRC.Membership.of_account(account) users = IRC.UserTrack.find_by_account(account) metas = IRC.Account.get_all_meta(account) predicates = IRC.Account.get_predicates(account) conn |> assign(:title, account.name) |> render("user.html", users: users, memberships: memberships, metas: metas, predicates: predicates) end def irc(conn, _) do - bot_helps = for mod <- LSG.IRC.env(:handlers) do + bot_helps = for mod <- Nola.IRC.env(:handlers) do mod.irc_doc() end render conn, "irc.html", bot_helps: bot_helps end def authenticate(conn, _) do with \ {:account, account_id} when is_binary(account_id) <- {:account, get_session(conn, :account)}, {:account, account} when not is_nil(account) <- {:account, IRC.Account.get(account_id)} do assign(conn, :account, account) else _ -> conn end end end diff --git a/lib/lsg_web/controllers/sms_controller.ex b/lib/lsg_web/controllers/sms_controller.ex index 00c6352..575655c 100644 --- a/lib/lsg_web/controllers/sms_controller.ex +++ b/lib/lsg_web/controllers/sms_controller.ex @@ -1,10 +1,10 @@ -defmodule LSGWeb.SmsController do - use LSGWeb, :controller +defmodule NolaWeb.SmsController do + use NolaWeb, :controller require Logger def ovh_callback(conn, %{"senderid" => from, "message" => message}) do - spawn(fn() -> LSG.IRC.SmsPlugin.incoming(from, String.trim(message)) end) + spawn(fn() -> Nola.IRC.SmsPlugin.incoming(from, String.trim(message)) end) text(conn, "") end end diff --git a/lib/lsg_web/controllers/untappd_controller.ex b/lib/lsg_web/controllers/untappd_controller.ex index 1c3ceb1..d3a540d 100644 --- a/lib/lsg_web/controllers/untappd_controller.ex +++ b/lib/lsg_web/controllers/untappd_controller.ex @@ -1,18 +1,18 @@ -defmodule LSGWeb.UntappdController do - use LSGWeb, :controller +defmodule NolaWeb.UntappdController do + use NolaWeb, :controller def callback(conn, %{"code" => code}) do with \ {:account, account_id} when is_binary(account_id) <- {:account, get_session(conn, :account)}, {:account, account} when not is_nil(account) <- {:account, IRC.Account.get(account_id)}, {:ok, auth_token} <- Untappd.auth_callback(code) do IRC.Account.put_meta(account, "untappd-token", auth_token) text(conn, "OK!") else {:account, _} -> text(conn, "Error: account not found") :error -> text(conn, "Error: untappd authentication failed") end end end diff --git a/lib/lsg_web/endpoint.ex b/lib/lsg_web/endpoint.ex index bfd53c8..d8bf962 100644 --- a/lib/lsg_web/endpoint.ex +++ b/lib/lsg_web/endpoint.ex @@ -1,62 +1,62 @@ -defmodule LSGWeb.Endpoint do +defmodule NolaWeb.Endpoint do use Sentry.PlugCapture use Phoenix.Endpoint, otp_app: :lsg # Serve at "/" the static files from "priv/static" directory. # # You should set gzip to true if you are running phoenix.digest # when deploying your static files in production. plug Plug.Static, at: "/", from: :lsg, gzip: false, only: ~w(assets css js fonts images favicon.ico robots.txt) # Code reloading can be explicitly enabled under the # :code_reloader configuration of your endpoint. if 42==43 && code_reloading? do socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket plug Phoenix.LiveReloader plug Phoenix.CodeReloader end plug Plug.RequestId plug Plug.Logger plug Plug.Parsers, parsers: [:urlencoded, :multipart, :json], pass: ["*/*"], json_decoder: Jason plug Sentry.PlugContext plug Plug.MethodOverride plug Plug.Head @session_options [store: :cookie, key: "_lsg_key", signing_salt: "+p7K3wrj"] socket "/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]] # The session will be stored in the cookie and signed, # this means its contents can be read but not tampered with. # Set :encryption_salt if you would also like to encrypt it. plug Plug.Session, @session_options - plug LSGWeb.Router + plug NolaWeb.Router @doc """ Callback invoked for dynamically configuring the endpoint. It receives the endpoint configuration and checks if configuration should be loaded from the system environment. """ def init(_key, config) do if config[:load_from_system_env] do port = System.get_env("PORT") || raise "expected the PORT environment variable to be set" {:ok, Keyword.put(config, :http, [:inet6, port: port])} else {:ok, config} end end end diff --git a/lib/lsg_web/gettext.ex b/lib/lsg_web/gettext.ex index f38a57d..e9a46e9 100644 --- a/lib/lsg_web/gettext.ex +++ b/lib/lsg_web/gettext.ex @@ -1,24 +1,24 @@ -defmodule LSGWeb.Gettext do +defmodule NolaWeb.Gettext do @moduledoc """ A module providing Internationalization with a gettext-based API. By using [Gettext](https://hexdocs.pm/gettext), your module gains a set of macros for translations, for example: - import LSGWeb.Gettext + import NolaWeb.Gettext # Simple translation gettext "Here is the string to translate" # Plural translation ngettext "Here is the string to translate", "Here are the strings to translate", 3 # Domain-based translation dgettext "errors", "Here is the error message to translate" See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage. """ use Gettext, otp_app: :lsg end diff --git a/lib/lsg_web/live/chat_live.ex b/lib/lsg_web/live/chat_live.ex index e84d880..276b362 100644 --- a/lib/lsg_web/live/chat_live.ex +++ b/lib/lsg_web/live/chat_live.ex @@ -1,120 +1,120 @@ -defmodule LSGWeb.ChatLive do +defmodule NolaWeb.ChatLive do use Phoenix.LiveView use Phoenix.HTML require Logger def mount(%{"network" => network, "chan" => chan}, %{"account" => account_id}, socket) do - chan = LSGWeb.reformat_chan(chan) + 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 LSG.IRC.BufferPlugin.select_buffer(connection.network, chan) do + backlog = case Nola.IRC.BufferPlugin.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 diff --git a/lib/lsg_web/lsg_web.ex b/lib/lsg_web/lsg_web.ex index 3d9ab9a..da622c7 100644 --- a/lib/lsg_web/lsg_web.ex +++ b/lib/lsg_web/lsg_web.ex @@ -1,99 +1,99 @@ -defmodule LSGWeb do +defmodule NolaWeb do @moduledoc """ The entrypoint for defining your web interface, such as controllers, views, channels and so on. This can be used in your application as: - use LSGWeb, :controller - use LSGWeb, :view + use NolaWeb, :controller + use NolaWeb, :view The definitions below will be executed for every view, controller, etc, so keep them short and clean, focused on imports, uses and aliases. Do NOT define functions inside the quoted expressions below. Instead, define any helper function in modules and import those modules here. """ def format_chan("##") do "♯♯" end def format_chan("#") do "♯" end def format_chan("#"<>chan) do chan end def format_chan(chan = "!"<>_), do: chan def reformat_chan("♯") do "#" end def reformat_chan("♯♯") do "##" end def reformat_chan(chan = "!"<>_), do: chan def reformat_chan(chan) do "#"<>chan end def controller do quote do - use Phoenix.Controller, namespace: LSGWeb + use Phoenix.Controller, namespace: NolaWeb import Plug.Conn - import LSGWeb.Router.Helpers - import LSGWeb.Gettext - alias LSGWeb.Router.Helpers, as: Routes + import NolaWeb.Router.Helpers + import NolaWeb.Gettext + alias NolaWeb.Router.Helpers, as: Routes end end def view do quote do use Phoenix.View, root: "lib/lsg_web/templates", - namespace: LSGWeb + namespace: NolaWeb # Import convenience functions from controllers import Phoenix.Controller, only: [get_flash: 2, view_module: 1] # Use all HTML functionality (forms, tags, etc) use Phoenix.HTML - import LSGWeb.Router.Helpers - import LSGWeb.ErrorHelpers - import LSGWeb.Gettext + import NolaWeb.Router.Helpers + import NolaWeb.ErrorHelpers + import NolaWeb.Gettext import Phoenix.LiveView.Helpers - alias LSGWeb.Router.Helpers, as: Routes + alias NolaWeb.Router.Helpers, as: Routes end end def router do quote do use Phoenix.Router import Plug.Conn import Phoenix.Controller import Phoenix.LiveView.Router end end def channel do quote do use Phoenix.Channel - import LSGWeb.Gettext + import NolaWeb.Gettext end end @doc """ When used, dispatch to the appropriate controller/view/etc. """ defmacro __using__(which) when is_atom(which) do apply(__MODULE__, which, []) end end diff --git a/lib/lsg_web/router.ex b/lib/lsg_web/router.ex index 5cc0d4a..5658fda 100644 --- a/lib/lsg_web/router.ex +++ b/lib/lsg_web/router.ex @@ -1,85 +1,85 @@ -defmodule LSGWeb.Router do - use LSGWeb, :router +defmodule NolaWeb.Router do + use NolaWeb, :router pipeline :browser do plug :accepts, ["html", "txt"] plug :fetch_session plug :fetch_flash plug :fetch_live_flash plug :protect_from_forgery plug :put_secure_browser_headers - plug :put_root_layout, {LSGWeb.LayoutView, :root} + plug :put_root_layout, {NolaWeb.LayoutView, :root} end pipeline :api do plug :accepts, ["json", "sse"] end pipeline :matrix_app_service do plug :accepts, ["json"] - plug LSG.Matrix.Plug.Auth - plug LSG.Matrix.Plug.SetConfig + plug Nola.Matrix.Plug.Auth + plug Nola.Matrix.Plug.SetConfig end - scope "/api", LSGWeb do + scope "/api", NolaWeb do pipe_through :api get "/irc-auth.sse", IrcAuthSseController, :sse post "/sms/callback/Ovh", SmsController, :ovh_callback, as: :sms end - scope "/", LSGWeb do + scope "/", NolaWeb do pipe_through :browser get "/", PageController, :index get "/login/irc/:token", PageController, :token, as: :login get "/login/oidc", OpenIdController, :login get "/login/oidc/callback", OpenIdController, :callback get "/api/untappd/callback", UntappdController, :callback, as: :untappd_callback get "/-", IrcController, :index get "/-/txt", IrcController, :txt get "/-/txt/:name", IrcController, :txt get "/-/gpt/prompt/:id", GptController, :task get "/-/gpt/result/:id", GptController, :result get "/-/alcoolog", AlcoologController, :index get "/-/alcoolog/~/:account_name", AlcoologController, :index get "/:network", NetworkController, :index get "/:network/~:nick/alcoolog", AlcoologController, :nick get "/:network/~:nick/alcoolog/log.json", AlcoologController, :nick_log_json get "/:network/~:nick/alcoolog/gls.json", AlcoologController, :nick_gls_json get "/:network/~:nick/alcoolog/volumes.json", AlcoologController, :nick_volumes_json get "/:network/~:nick/alcoolog/history.json", AlcoologController, :nick_history_json get "/:network/~:nick/alcoolog/stats.json", AlcoologController, :nick_stats_json get "/:network/:chan/alcoolog", AlcoologController, :index get "/:network/:chan/alcoolog/gls.json", AlcoologController, :index_gls_json get "/:network/:chan/gpt/prompt/:id", GptController, :task get "/:network/:chan/gpt/result/:id", GptController, :result put "/api/alcoolog/minisync/:user_id/meta/:key", AlcoologController, :minisync_put_meta get "/:network/:chan", IrcController, :index live "/:network/:chan/live", ChatLive get "/:network/:chan/txt", IrcController, :txt get "/:network/:chan/txt/:name", IrcController, :txt get "/:network/:channel/preums", IrcController, :preums get "/:network/:chan/alcoolog/t/:token", AlcoologController, :token end scope "/_matrix/:appservice", MatrixAppServiceWeb.V1, as: :matrix do pipe_through :matrix_app_service put "/transactions/:txn_id", TransactionController, :push get "/users/:user_id", UserController, :query get "/rooms/*room_alias", RoomController, :query get "/thirdparty/protocol/:protocol", ThirdPartyController, :query_protocol get "/thirdparty/user/:protocol", ThirdPartyController, :query_users get "/thirdparty/location/:protocol", ThirdPartyController, :query_locations get "/thirdparty/location", ThirdPartyController, :query_location_by_alias get "/thirdparty/user", ThirdPartyController, :query_user_by_id end end diff --git a/lib/lsg_web/views/alcoolog_view.ex b/lib/lsg_web/views/alcoolog_view.ex index ed3c9b4..ad52472 100644 --- a/lib/lsg_web/views/alcoolog_view.ex +++ b/lib/lsg_web/views/alcoolog_view.ex @@ -1,6 +1,6 @@ -defmodule LSGWeb.AlcoologView do - use LSGWeb, :view +defmodule NolaWeb.AlcoologView do + use NolaWeb, :view require Integer end diff --git a/lib/lsg_web/views/error_helpers.ex b/lib/lsg_web/views/error_helpers.ex index 47906f2..25214bd 100644 --- a/lib/lsg_web/views/error_helpers.ex +++ b/lib/lsg_web/views/error_helpers.ex @@ -1,40 +1,40 @@ -defmodule LSGWeb.ErrorHelpers do +defmodule NolaWeb.ErrorHelpers do @moduledoc """ Conveniences for translating and building error messages. """ use Phoenix.HTML @doc """ Generates tag for inlined form input errors. """ def error_tag(form, field) do Enum.map(Keyword.get_values(form.errors, field), fn (error) -> content_tag :span, translate_error(error), class: "help-block" end) end @doc """ Translates an error message using gettext. """ def translate_error({msg, opts}) do # Because error messages were defined within Ecto, we must # call the Gettext module passing our Gettext backend. We # also use the "errors" domain as translations are placed # in the errors.po file. # Ecto will pass the :count keyword if the error message is # meant to be pluralized. # On your own code and templates, depending on whether you # need the message to be pluralized or not, this could be # written simply as: # # dngettext "errors", "1 file", "%{count} files", count # dgettext "errors", "is invalid" # if count = opts[:count] do - Gettext.dngettext(LSGWeb.Gettext, "errors", msg, msg, count, opts) + Gettext.dngettext(NolaWeb.Gettext, "errors", msg, msg, count, opts) else - Gettext.dgettext(LSGWeb.Gettext, "errors", msg, opts) + Gettext.dgettext(NolaWeb.Gettext, "errors", msg, opts) end end end diff --git a/lib/lsg_web/views/error_view.ex b/lib/lsg_web/views/error_view.ex index 1a7a92d..5cad939 100644 --- a/lib/lsg_web/views/error_view.ex +++ b/lib/lsg_web/views/error_view.ex @@ -1,17 +1,17 @@ -defmodule LSGWeb.ErrorView do - use LSGWeb, :view +defmodule NolaWeb.ErrorView do + use NolaWeb, :view def render("404.html", _assigns) do "Page not found" end def render("500.html", _assigns) do "Internal server error" end # In case no render clause matches or no # template is found, let's render it as 500 def template_not_found(_template, assigns) do render "500.html", assigns end end diff --git a/lib/lsg_web/views/irc_view.ex b/lib/lsg_web/views/irc_view.ex index 36a9bc4..331d91f 100644 --- a/lib/lsg_web/views/irc_view.ex +++ b/lib/lsg_web/views/irc_view.ex @@ -1,3 +1,3 @@ -defmodule LSGWeb.IrcView do - use LSGWeb, :view +defmodule NolaWeb.IrcView do + use NolaWeb, :view end diff --git a/lib/lsg_web/views/layout_view.ex b/lib/lsg_web/views/layout_view.ex index 720281d..2bffc6f 100644 --- a/lib/lsg_web/views/layout_view.ex +++ b/lib/lsg_web/views/layout_view.ex @@ -1,81 +1,81 @@ -defmodule LSGWeb.LayoutView do - use LSGWeb, :view +defmodule NolaWeb.LayoutView do + use NolaWeb, :view def liquid_markdown(conn, text) do context_path = cond do - conn.assigns[:chan] -> "/#{conn.assigns[:network]}/#{LSGWeb.format_chan(conn.assigns[:chan])}" + conn.assigns[:chan] -> "/#{conn.assigns[:network]}/#{NolaWeb.format_chan(conn.assigns[:chan])}" conn.assigns[:network] -> "/#{conn.assigns[:network]}/-" true -> "/-" end {:ok, ast} = Liquex.parse(text) context = Liquex.Context.new(%{ "context_path" => context_path }) {content, _} = Liquex.render(ast, context) content |> to_string() |> Earmark.as_html!() |> raw() end def page_title(conn) do target = cond do conn.assigns[:chan] -> "#{conn.assigns.chan} @ #{conn.assigns.network}" conn.assigns[:network] -> conn.assigns.network - true -> Keyword.get(LSG.name()) + true -> Keyword.get(Nola.name()) end breadcrumb_title = Enum.map(Map.get(conn.assigns, :breadcrumbs)||[], fn({title, _href}) -> title end) title = [conn.assigns[:title], breadcrumb_title, target] |> List.flatten() |> Enum.uniq() |> Enum.filter(fn(x) -> x end) |> Enum.intersperse(" / ") |> Enum.join() content_tag(:title, title) end def format_time(date, with_relative \\ true) do alias Timex.Format.DateTime.Formatters alias Timex.Timezone date = if is_integer(date) do date |> DateTime.from_unix!(:millisecond) |> DateTime.shift_zone!("Europe/Paris", Tzdata.TimeZoneDatabase) else date |> DateTime.shift_zone!("Europe/Paris", Tzdata.TimeZoneDatabase) end now = DateTime.now!("Europe/Paris", Tzdata.TimeZoneDatabase) now_week = Timex.iso_week(now) date_week = Timex.iso_week(date) {y, w} = now_week now_last_week = {y, w-1} now_last_roll = 7-Timex.days_to_beginning_of_week(now) date_date = DateTime.to_date(date) now_date = DateTime.to_date(date) format = cond do date.year != now.year -> "{D}/{M}/{YYYY} {h24}:{m}" date_date == now_date -> "{h24}:{m}" (now_week == date_week) || (date_week == now_last_week && (Date.day_of_week(date) >= now_last_roll)) -> "{WDfull} {h24}:{m}" (now.year == date.year && now.month == date.month) -> "{WDfull} {D} {h24}:{m}" true -> "{WDfull} {D} {M} {h24}:{m}" end {:ok, relative} = Formatters.Relative.relative_to(date, Timex.now("Europe/Paris"), "{relative}", "fr") {:ok, full} = Formatters.Default.lformat(date, "{WDfull} {D} {YYYY} {h24}:{m}", "fr") #"{h24}:{m} {WDfull} {D}", "fr") {:ok, detail} = Formatters.Default.lformat(date, format, "fr") #"{h24}:{m} {WDfull} {D}", "fr") content_tag(:time, if(with_relative, do: relative, else: detail), [title: full]) end end diff --git a/lib/lsg_web/views/network_view.ex b/lib/lsg_web/views/network_view.ex index c369ce6..7a24db1 100644 --- a/lib/lsg_web/views/network_view.ex +++ b/lib/lsg_web/views/network_view.ex @@ -1,4 +1,4 @@ -defmodule LSGWeb.NetworkView do - use LSGWeb, :view +defmodule NolaWeb.NetworkView do + use NolaWeb, :view end diff --git a/lib/lsg_web/views/open_id_view.ex b/lib/lsg_web/views/open_id_view.ex index 64d4430..bd8089b 100644 --- a/lib/lsg_web/views/open_id_view.ex +++ b/lib/lsg_web/views/open_id_view.ex @@ -1,4 +1,4 @@ -defmodule LSGWeb.OpenIdView do - use LSGWeb, :view +defmodule NolaWeb.OpenIdView do + use NolaWeb, :view end diff --git a/lib/lsg_web/views/page_view.ex b/lib/lsg_web/views/page_view.ex index 90c384c..1bfaadd 100644 --- a/lib/lsg_web/views/page_view.ex +++ b/lib/lsg_web/views/page_view.ex @@ -1,3 +1,3 @@ -defmodule LSGWeb.PageView do - use LSGWeb, :view +defmodule NolaWeb.PageView do + use NolaWeb, :view end diff --git a/lib/untappd.ex b/lib/untappd.ex index 1f78376..d5ac904 100644 --- a/lib/untappd.ex +++ b/lib/untappd.ex @@ -1,94 +1,94 @@ defmodule Untappd do @env Mix.env @version Mix.Project.config[:version] require Logger def auth_url() do client_id = Keyword.get(env(), :client_id) - url = LSGWeb.Router.Helpers.untappd_callback_url(LSGWeb.Endpoint, :callback) + url = NolaWeb.Router.Helpers.untappd_callback_url(NolaWeb.Endpoint, :callback) "https://untappd.com/oauth/authenticate/?client_id=#{client_id}&response_type=code&redirect_url=#{URI.encode(url)}" end def auth_callback(code) do client_id = Keyword.get(env(), :client_id) client_secret = Keyword.get(env(), :client_secret) - url = LSGWeb.Router.Helpers.untappd_callback_url(LSGWeb.Endpoint, :callback) + url = NolaWeb.Router.Helpers.untappd_callback_url(NolaWeb.Endpoint, :callback) params = %{ "client_id" => client_id, "client_secret" => client_secret, "response_type" => code, "redirect_url" => url, "code" => code } case HTTPoison.get("https://untappd.com/oauth/authorize", headers(), params: params) do {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> json = Poison.decode!(body) {:ok, get_in(json, ["response", "access_token"])} error -> Logger.error("Untappd auth callback failed: #{inspect error}") :error end end def maybe_checkin(account, beer_id) do if token = IRC.Account.get_meta(account, "untappd-token") do checkin(token, beer_id) else {:error, :no_token} end end def checkin(token, beer_id) do params = get_params(token: token) |> Map.put("timezone", "CEST") |> Map.put("bid", beer_id) form_params = params |> Enum.into([]) case HTTPoison.post("https://api.untappd.com/v4/checkin/add", {:form, form_params}, headers(), params: params) do {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> body = Jason.decode!(body) |> Map.get("response") {:ok, body} {:ok, resp = %HTTPoison.Response{status_code: code, body: body}} -> Logger.warn "Untappd checkin error: #{inspect resp}" {:error, {:http_error, code}} {:error, error} -> {:error, {:http_error, error}} end end def search_beer(query, params \\ []) do params = get_params(params) |> Map.put("q", query) |> Map.put("limit", 10) #|> Map.put("sort", "name") case HTTPoison.get("https://api.untappd.com/v4/search/beer", headers(), params: params) do {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> {:ok, Jason.decode!(body)} error -> Logger.error("Untappd search error: #{inspect error}") end end def get_params(params) do auth = %{"client_id" => Keyword.get(env(), :client_id), "client_secret" => Keyword.get(env(), :client_secret)} if token = Keyword.get(params, :token) do Map.put(auth, "access_token", token) else auth end end def headers(extra \\ []) do client_id = Keyword.get(env(), :client_id) extra ++ [ {"user-agent", "dmzbot (#{client_id}; #{@version}-#{@env})"} ] end def env() do Application.get_env(:lsg, :untappd) end end