diff options
Diffstat (limited to 'lib')
60 files changed, 4063 insertions, 1052 deletions
@@ -2,6 +2,9 @@ defmodule IRC do defmodule Message do defstruct [:text, + {:transport, :irc}, + :network, + :account, :sender, :channel, :trigger, diff --git a/lib/irc/account.ex b/lib/irc/account.ex new file mode 100644 index 0000000..6f9eb05 --- /dev/null +++ b/lib/irc/account.ex @@ -0,0 +1,439 @@ +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? + + defstruct [:id, :name, :token] + @type t :: %__MODULE__{id: String.t(), name: 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") + 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 + + defp 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 `<account-id>` `<token>`** Authenticate and link the current nickname to an account + * **auth** list authentications methods + * **whoami** list currently authenticated users + * **enable-sms** Link a SMS number + * **enable-telegram** Link a Telegram account + """ + + def irc_doc, do: @moduledoc + def start_link(), do: GenServer.start_link(__MODULE__, [], name: __MODULE__) + def init(_) do + {:ok, _} = Registry.register(IRC.PubSub, "message: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 <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) + 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() + 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:" + <> " \"/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}) + 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 <key> <value>" + 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 new file mode 100644 index 0000000..d115d88 --- /dev/null +++ b/lib/irc/connection.ex @@ -0,0 +1,462 @@ +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 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) + |> IO.inspect() + 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 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 + backoff = :backoff.init(@min_backoff, @max_backoff) + |> :backoff.type(:jitter) + {:ok, %{client: nil, backoff: backoff, conn: conn}, {:continue, :connect}} + end + + @triggers %{ + "!" => :bang, + "+" => :plus, + "-" => :minus, + "?" => :query, + "." => :dot, + "~" => :tilde, + "++" => :plus_plus, + "--" => :minus_minus, + "!!" => :bang_bang, + "??" => :query_query, + } + + 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) + client + end + ExIRC.Client.add_handler(client, self()) + if state.conn.tls do + ExIRC.Client.connect_ssl!(client, state.conn.host, state.conn.port) + else + ExIRC.Client.connect!(client, state.conn.host, state.conn.port) + end + {: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.client, {channel, nil}, line) + {:noreply, state} + end + + # Connection successful + def handle_info({:connected, server, port}, state) do + Logger.info("#{inspect(self())} Connected to #{server}:#{port} #{inspect state}") + ExIRC.Client.logon(state.client, state.conn.pass || "", state.conn.nick, state.conn.user, state.conn.name) + {:noreply, state} + 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 + + # 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 + reply_fun = fn(text) -> irc_reply(state.client, {chan, sender}, text) end + account = IRC.Account.lookup(sender) + message = %IRC.Message{text: text, network: network(state), 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:#{chan}"]) + {:noreply, state} + end + + # Received a private message + def handle_info({:received, text, sender}, state) do + reply_fun = fn(text) -> irc_reply(state.client, {sender.nick, sender}, text) end + account = IRC.Account.lookup(sender) + message = %IRC.Message{text: text, network: network(state), 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, ["message:private"]) + {: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.client, {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.client, {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) + {: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(network(state), channel, nick) + {:noreply, state} + end + + def handle_info({:parted, channel, %ExIRC.SenderInfo{nick: nick}}, state) do + IRC.UserTrack.parted(channel, nick) + {:noreply, state} + end + + def handle_info({:mode, [channel, mode, nick]}, state) do + track_mode(network(state), channel, nick, mode) + {:noreply, state} + end + + def handle_info({:nick_changed, old_nick, new_nick}, state) do + IRC.UserTrack.renamed(network(state), old_nick, new_nick) + {:noreply, state} + end + + def handle_info(unhandled, client) do + IO.puts inspect(unhandled) + {:noreply, client} + end + + def publish(pub), do: publish(pub, []) + + def publish(m = %IRC.Message{trigger: nil}, keys) do + dispatch(["message"] ++ keys, {:irc, :text, m}) + end + + def publish(m = %IRC.Message{trigger: t = %IRC.Trigger{trigger: trigger}}, keys) do + dispatch(["triggers", "trigger:"<>trigger], {:irc, :trigger, trigger, m}) + 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 + IO.puts "dispatching to #{inspect({sub,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(client, {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() + for line <- lines do + ExIRC.Client.msg(client, :privmsg, target, line) + end + end + + defp irc_reply(client, {target, %{nick: nick}}, {:kick, reason}) do + ExIRC.Client.kick(client, target, nick, reason) + end + + defp irc_reply(client, {target, _}, {:kick, nick, reason}) do + ExIRC.Client.kick(client, target, nick, reason) + end + + defp irc_reply(client, {target, %{nick: nick}}, {:mode, mode}) do + ExIRC.Client.mode(client, target, mode, nick) + end + + defp irc_reply(client, target, {:mode, mode, nick}) do + ExIRC.Client.mode(client, target, mode, nick) + end + + defp irc_reply(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 server(%{conn: %{host: host, port: port}}) do + host <> ":" <> to_string(port) + end + + defp network(state = %{conn: %{network: network}}) do + if network do + network + else + # FIXME performance meheeeee + ExIRC.Client.state(state.client)[:network] + end + end + +end diff --git a/lib/irc/connection_handler.ex b/lib/irc/connection_handler.ex deleted file mode 100644 index 1c335f2..0000000 --- a/lib/irc/connection_handler.ex +++ /dev/null @@ -1,36 +0,0 @@ -defmodule IRC.ConnectionHandler do - defmodule State do - defstruct [:host, :port, :pass, :nick, :name, :user, :client] - end - - def start_link(client) do - irc = Application.get_env(:lsg, :irc)[:irc] - host = irc[:host] - port = irc[:port] - nick = irc[:nick] - user = irc[:user] - name = irc[:name] - GenServer.start_link(__MODULE__, [%State{client: client, host: host, port: port, nick: nick, user: user, name: name}]) - end - - def init([state]) do - ExIRC.Client.add_handler state.client, self - ExIRC.Client.connect! state.client, state.host, state.port - {:ok, state} - end - - def handle_info({:connected, server, port}, state) do - debug "Connected to #{server}:#{port}" - ExIRC.Client.logon state.client, state.pass, state.nick, state.user, state.name - {:noreply, state} - end - - # Catch-all for messages you don't care about - def handle_info(msg, state) do - {:noreply, state} - end - - defp debug(msg) do - IO.puts IO.ANSI.yellow() <> msg <> IO.ANSI.reset() - end -end diff --git a/lib/irc/conns.ex b/lib/irc/conns.ex new file mode 100644 index 0000000..b0e5d3c --- /dev/null +++ b/lib/irc/conns.ex @@ -0,0 +1,3 @@ +defmodule IRC.Conns do + +end diff --git a/lib/irc/login_handler.ex b/lib/irc/login_handler.ex deleted file mode 100644 index 4b02dc2..0000000 --- a/lib/irc/login_handler.ex +++ /dev/null @@ -1,27 +0,0 @@ -defmodule IRC.LoginHandler do - def start_link(client) do - GenServer.start_link(__MODULE__, [client]) - end - - def init([client]) do - ExIRC.Client.add_handler client, self - conf = Application.get_env(:lsg, :irc, []) |> Keyword.get(:irc) - channels = Keyword.get(conf, :channels, []) - {:ok, {client, channels}} - end - - def handle_info(:logged_in, state = {client, channels}) do - debug "Logged in to server" - channels |> Enum.map(&ExIRC.Client.join client, &1) - {:noreply, state} - end - - # Catch-all for messages you don't care about - def handle_info(_msg, state) do - {:noreply, state} - end - - defp debug(msg) do - IO.puts IO.ANSI.yellow() <> msg <> IO.ANSI.reset() - end -end diff --git a/lib/irc/membership.ex b/lib/irc/membership.ex new file mode 100644 index 0000000..74ed1b4 --- /dev/null +++ b/lib/irc/membership.ex @@ -0,0 +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") + 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 new file mode 100644 index 0000000..ca092dc --- /dev/null +++ b/lib/irc/plugin_supervisor.ex @@ -0,0 +1,91 @@ +defmodule IRC.Plugin do + require Logger + + defmodule Supervisor do + use DynamicSupervisor + + def start_link() do + DynamicSupervisor.start_link(__MODULE__, [], name: __MODULE__) + end + + def start_child(module, opts \\ []) do + IO.inspect(module) + spec = %{id: {IRC.Plugin,module}, start: {IRC.Plugin, :start_link, [module, opts]}, name: module, 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 dets(), do: to_charlist(LSG.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/pubsub_handler.ex b/lib/irc/pubsub_handler.ex deleted file mode 100644 index 61ae55c..0000000 --- a/lib/irc/pubsub_handler.ex +++ /dev/null @@ -1,135 +0,0 @@ -defmodule IRC.PubSubHandler do - @moduledoc """ - # IRC PubSub - - Provides a nicer abstraction over ExIRC's handlers. - - ## PubSub topics - - * `message` -- all 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 - - def start_link(client) do - GenServer.start_link(__MODULE__, [client], [name: __MODULE__]) - end - - def privmsg(channel, line) do - GenServer.cast(__MODULE__, {:privmsg, channel, line}) - end - - def init([client]) do - ExIRC.Client.add_handler(client, self()) - {:ok, client} - end - - @triggers %{ - "!" => :bang, - "+" => :plus, - "-" => :minus, - "?" => :query, - "." => :dot, - "~" => :tilde - } - - def handle_cast({:privmsg, channel, line}, client) do - irc_reply(client, {channel, nil}, line) - {:noreply, client} - end - - def handle_info({:received, text, sender, chan}, client) do - reply_fun = fn(text) -> irc_reply(client, {chan, sender}, text) end - message = %IRC.Message{text: text, sender: sender, channel: chan, replyfun: reply_fun, trigger: extract_trigger(text)} - publish(message, ["message:#{chan}"]) - {:noreply, client} - end - - def handle_info({:received, text, sender}, client) do - reply_fun = fn(text) -> irc_reply(client, {sender.nick, sender}, text) end - message = %IRC.Message{text: text, sender: sender, replyfun: reply_fun, trigger: extract_trigger(text)} - publish(message, ["message:private"]) - {:noreply, client} - end - - def handle_info(unhandled, client) do - IO.puts inspect(unhandled) - {:noreply, client} - end - - defp publish(pub), do: publish(pub, []) - - defp publish(m = %IRC.Message{trigger: nil}, keys) do - dispatch(["message"] ++ keys, {:irc, :text, m}) - end - - defp publish(m = %IRC.Message{trigger: t = %IRC.Trigger{trigger: trigger}}, keys) do - dispatch(["triggers", "trigger:"<>trigger], {:irc, :trigger, trigger, m}) - end - - defp dispatch(key, content) when is_binary(key), do: dispatch([key], content) - defp dispatch(keys, content) when is_list(keys) do - IO.puts "dispatching to #{inspect(keys)} --> #{inspect content}" - for key <- keys do - spawn(fn() -> Registry.dispatch(IRC.PubSub, key, fn h -> - for {pid, _} <- h, do: send(pid, content) - end) end) - end - end - - # - # Triggers - # - - - for {trigger, name} <- @triggers do - defp extract_trigger(unquote(trigger)<>text) do - text = String.strip(text) - [trigger | args] = String.split(text, " ") - %IRC.Trigger{type: unquote(name), trigger: trigger, args: args} - end - end - - defp 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(client, {target, _}, text) when is_binary(text) or is_list(text) do - for line <- IRC.splitlong(text) do - ExIRC.Client.msg(client, :privmsg, target, line) - end - end - - defp irc_reply(client, {target, %{nick: nick}}, {:kick, reason}) do - ExIRC.Client.kick(client, target, nick, reason) - end - - defp irc_reply(client, {target, _}, {:kick, nick, reason}) do - ExIRC.Client.kick(client, target, nick, reason) - end - - defp irc_reply(client, {target, %{nick: nick}}, {:mode, mode}) do - ExIRC.Client.mode(client, target, mode, nick) - end - - defp irc_reply(client, target, {:mode, mode, nick}) do - ExIRC.Client.mode(client, target, mode, nick) - end - - defp irc_reply(client, target, {:channel_mode, mode}) do - ExIRC.Client.mode(client, target, mode) - end - -end diff --git a/lib/irc/user_track.ex b/lib/irc/user_track.ex index 02d5752..08b5160 100644 --- a/lib/irc/user_track.ex +++ b/lib/irc/user_track.ex @@ -4,7 +4,7 @@ defmodule IRC.UserTrack do """ @ets IRC.UserTrack.Storage - # {uuid, nick, nicks, privilege_map} + # {uuid, network, nick, nicks, privilege_map} # Privilege map: # %{"#channel" => [:operator, :voice] defmodule Storage do @@ -49,24 +49,87 @@ defmodule IRC.UserTrack do defmodule Id, do: use EntropyString defmodule User do - defstruct [:id, :nick, :nicks, :username, :host, :realname, :privileges] + defstruct [:id, :account, :network, :nick, {:nicks, []}, :username, :host, :realname, {:privileges, %{}}, {:last_active, %{}}] def to_tuple(u = %__MODULE__{}) do - {u.id || IRC.UserTrack.Id.large_id, u.nick, u.nicks || [], u.username, u.host, u.realname, u.privileges} + {u.id || IRC.UserTrack.Id.large_id, u.network, u.account, String.downcase(u.nick), u.nick, u.nicks || [], u.username, u.host, u.realname, u.privileges, u.last_active} end - def from_tuple({id, nick, nicks, username, host, realname, privs}) do - %__MODULE__{id: id, nick: nick, nicks: nicks, username: username, realname: realname, privileges: privs} + #tuple size: 11 + def from_tuple({id, network, account, _downcased_nick, nick, nicks, username, host, realname, privs, last_active}) do + struct = %__MODULE__{id: id, account: account, network: network, nick: nick, nicks: nicks, username: username, host: host, realname: realname, privileges: privs, last_active: last_active} end end - def find_by_nick(nick) do - case :ets.match(@ets, {:'$1', nick, :_, :_, :_, :_, :_}) do - [[id]] -> lookup(id) + def find_by_account(%IRC.Account{id: id}) do + #iex(15)> :ets.fun2ms(fn(obj = {_, net, acct, _, _, _, _, _, _}) when net == network and acct == account -> obj end) + spec = [ + {{:_, :_, :"$2", :_, :_, :_, :_, :_, :_, :_, :_}, + [ + {:==, :"$2", {:const, id}} + ], [:"$_"]} + ] + for obj <- :ets.select(@ets, spec), do: User.from_tuple(obj) + end + + def find_by_account(network, nil) do + nil + end + + def find_by_account(network, %IRC.Account{id: id}) do + #iex(15)> :ets.fun2ms(fn(obj = {_, net, acct, _, _, _, _, _, _}) when net == network and acct == account -> obj end) + spec = [ + {{:_, :"$1", :"$2", :_, :_, :_, :_, :_, :_, :_, :_}, + [ + {:andalso, {:==, :"$1", {:const, network}}, + {:==, :"$2", {:const, id}}} + ], [:"$_"]} + ] + case :ets.select(@ets, spec) do + results = [_r | _] -> + results + |> Enum.sort_by(fn({_, _, _, _, _, _, _, _, _, _, actives}) -> + Map.get(actives, nil) + end, {:desc, NaiveDateTime}) + |> List.first + |> User.from_tuple() _ -> nil end end + + def merge_account(old_id, new_id) do + #iex(15)> :ets.fun2ms(fn(obj = {_, net, acct, _, _, _, _, _, _}) when net == network and acct == account -> obj end) + spec = [ + {{:_, :_, :"$1", :_, :_, :_, :_, :_, :_, :_, :_}, + [ + {:==, :"$1", {:const, old_id}} + ], [:"$_"]} + ] + Enum.each(:ets.select(@ets, spec), fn({id, net, _, downcased_nick, nick, nicks, username, host, realname, privs, active}) -> + Storage.op(fn(ets) -> + :ets.insert(@ets, {id, net, new_id, downcased_nick, nick, nicks, username, host, realname, privs, active}) + end) + end) + end + + def find_by_nick(%ExIRC.Who{network: network, nick: nick}) do + find_by_nick(network, nick) + end + + + def find_by_nick(%ExIRC.SenderInfo{network: network, nick: nick}) do + find_by_nick(network, nick) + end + + def find_by_nick(network, nick) do + case :ets.match(@ets, {:"$1", network, :_, String.downcase(nick), :_, :_, :_, :_, :_, :_, :_}) do + [[id]] -> lookup(id) + _ -> + nil + end + end + def to_list, do: :ets.tab2list(@ets) def lookup(id) do @@ -76,8 +139,8 @@ defmodule IRC.UserTrack do end end - def operator?(channel, nick) do - if user = find_by_nick(nick) do + def operator?(network, channel, nick) do + if user = find_by_nick(network, nick) do privs = Map.get(user.privileges, channel, []) Enum.member?(privs, :admin) || Enum.member?(privs, :operator) else @@ -85,22 +148,34 @@ defmodule IRC.UserTrack do end end - def channel(channel) do - Enum.filter(to_list(), fn({_, nick, _, _, _, _, channels}) -> + def channel(network, channel) do + Enum.filter(to_list(), fn({_, network, _, _, _, _, _, _, _, channels, _}) -> Map.get(channels, channel) end) end + # TODO + def connected(sender = %{nick: nick}) do + end + def joined(c, s), do: joined(c,s,[]) - def joined(channel, sender=%{nick: nick, user: uname, host: host}, privileges) do + def joined(channel, sender=%{nick: nick, user: uname, host: host}, privileges, touch \\ true) do privileges = if IRC.admin?(sender) do privileges ++ [:admin] else privileges end - user = if user = find_by_nick(nick) do + user = if user = find_by_nick(sender.network, nick) do %User{user | username: uname, host: host, privileges: Map.put(user.privileges || %{}, channel, privileges)} else - %User{nick: nick, username: uname, host: host, privileges: %{channel => privileges}} + user = %User{network: sender.network, nick: nick, username: uname, host: host, privileges: %{channel => privileges}} + + account = IRC.Account.lookup(user).id + user = %User{user | account: account} + end + user = touch_struct(user, channel) + + if touch && user.account do + IRC.Membership.touch(user.account, sender.network, channel) end Storage.op(fn(ets) -> @@ -108,27 +183,47 @@ defmodule IRC.UserTrack do end) end - def joined(channel, nick, privileges) do - user = if user = find_by_nick(nick) do - %User{user | privileges: Map.put(user.privileges, channel, privileges)} + #def joined(network, channel, nick, privileges) do + # user = if user = find_by_nick(network, nick) do + # %User{user | privileges: Map.put(user.privileges, channel, privileges)} + # else + # %User{nick: nick, privileges: %{channel => privileges}} + # end + # + # Storage.op(fn(ets) -> + # :ets.insert(ets, User.to_tuple(user)) + # end) + #end + + def messaged(%IRC.Message{network: network, account: account, channel: chan, sender: %{nick: nick}} = m) do + {user, account} = if user = find_by_nick(network, nick) do + {touch_struct(user, chan), account || IRC.Account.lookup(user)} else - %User{nick: nick, privileges: %{channel => privileges}} + user = %User{network: network, nick: nick, privileges: %{}} + account = IRC.Account.lookup(user) + {%User{user | account: account.id}, account} + end + Storage.insert(User.to_tuple(user)) + if chan, do: IRC.Membership.touch(account, network, chan) + if !m.account do + {:ok, %IRC.Message{m | account: account}} + else + :ok end - - Storage.op(fn(ets) -> - :ets.insert(ets, User.to_tuple(user)) - end) end - def renamed(old_nick, new_nick) do - if user = find_by_nick(old_nick) do + def renamed(network, old_nick, new_nick) do + if user = find_by_nick(network, old_nick) do + old_account = IRC.Account.lookup(user) user = %User{user | nick: new_nick, nicks: [old_nick|user.nicks]} + account = IRC.Account.lookup(user, false) || old_account + user = %User{user | nick: new_nick, account: account.id, nicks: [old_nick|user.nicks]} Storage.insert(User.to_tuple(user)) end end - def change_privileges(channel, nick, {add, remove}) do - if user = find_by_nick(nick) do + def change_privileges(network, channel, nick, {add, remove}) do + if user = find_by_nick(network, nick) do privs = Map.get(user.privileges, channel) privs = Enum.reduce(add, privs, fn(priv, acc) -> [priv|acc] end) @@ -139,9 +234,18 @@ defmodule IRC.UserTrack do end end - def parted(channel, nick) do - if user = find_by_nick(nick) do + def parted(channel, %{network: network, nick: nick}) do + parted(network, channel, nick) + end + + def parted(network, channel, nick) do + if user = find_by_nick(network, nick) do + if user.account do + IRC.Membership.touch(user.account, network, channel) + end + privs = Map.delete(user.privileges, channel) + lasts = Map.delete(user.last_active, channel) if Enum.count(privs) > 0 do user = %User{user | privileges: privs} Storage.insert(User.to_tuple(user)) @@ -152,9 +256,20 @@ defmodule IRC.UserTrack do end def quitted(sender) do - if user = find_by_nick(sender.nick) do - Storage.delete(user.id) + if user = find_by_nick(sender.network, sender.nick) do + if user.account do + for({channel, _} <- user.privileges, do: IRC.Membership.touch(user.account, sender.network, channel)) + end + Storage.delete(user.id) end end + defp touch_struct(user = %User{last_active: last_active}, channel) do + now = NaiveDateTime.utc_now() + last_active = last_active + |> Map.put(channel, now) + |> Map.put(nil, now) + %User{user | last_active: last_active} + end + end diff --git a/lib/irc/user_track_handler.ex b/lib/irc/user_track_handler.ex deleted file mode 100644 index 0ae802a..0000000 --- a/lib/irc/user_track_handler.ex +++ /dev/null @@ -1,93 +0,0 @@ -defmodule IRC.UserTrackHandler do - @moduledoc """ - # User Track Handler - - This handlers keeps track of users presence and privileges. - - Planned API: - - UserTrackHandler.operator?(%ExIRC.Sender{nick: "href", …}, "#channel") :: boolean - - """ - - def irc_doc, do: nil - - def start_link(client) do - GenServer.start_link(__MODULE__, [client]) - end - - defstruct client: nil, ets: nil - - def init([client]) do - ExIRC.Client.add_handler client, self - {:ok, %__MODULE__{client: client}} - end - - def handle_info({:joined, channel}, state) do - ExIRC.Client.who(state.client, channel) - {:noreply, state} - end - - def handle_info({:who, channel, whos}, state) do - Enum.map(whos, fn(who = %ExIRC.Who{nick: nick, operator?: operator}) -> - priv = if operator, do: [:operator], else: [] - IRC.UserTrack.joined(channel, who, priv) - end) - {:noreply, state} - end - - def handle_info({:quit, _reason, sender}, state) do - IRC.UserTrack.quitted(sender) - {:noreply, state} - end - - def handle_info({:joined, channel, sender}, state) do - IRC.UserTrack.joined(channel, sender, []) - {:noreply, state} - end - - def handle_info({:kicked, nick, _by, channel, _reason}, state) do - parted(channel, nick) - {:noreply, state} - end - - def handle_info({:parted, channel, %ExIRC.SenderInfo{nick: nick}}, state) do - parted(channel, nick) - {:noreply, state} - end - - def handle_info({:mode, [channel, mode, nick]}, state) do - mode(channel, nick, mode) - {:noreply, state} - end - - def handle_info({:nick_changed, old_nick, new_nick}, state) do - rename(old_nick, new_nick) - {:noreply, state} - end - - def handle_info(msg, state) do - {:noreply, state} - end - - defp parted(channel, nick) do - IRC.UserTrack.parted(channel, nick) - :ok - end - - defp mode(channel, nick, "+o") do - IRC.UserTrack.change_privileges(channel, nick, {[:operator], []}) - :ok - end - - defp mode(channel, nick, "-o") do - IRC.UserTrack.change_privileges(channel, nick, {[], [:operator]}) - :ok - end - - defp rename(old, new) do - IRC.UserTrack.renamed(old, new) - :ok - end - -end @@ -1,5 +1,9 @@ defmodule LSG do + def data_path(suffix) do + Path.join(data_path(), suffix) + end + def data_path do Application.get_env(:lsg, :data_path) end diff --git a/lib/lsg/application.ex b/lib/lsg/application.ex index 972767f..a12253b 100644 --- a/lib/lsg/application.ex +++ b/lib/lsg/application.ex @@ -15,13 +15,18 @@ defmodule LSG.Application do worker(Registry, [[keys: :duplicate, name: LSG.BroadcastRegistry]], id: :registry_broadcast), worker(LSG.IcecastAgent, []), worker(LSG.Token, []), + worker(LSG.AuthToken, []), + {GenMagic.Pool, [name: LSG.GenMagic, pool_size: 2]}, + LSG.Telegram, #worker(LSG.Icecast, []), ] ++ LSG.IRC.application_childs # See https://hexdocs.pm/elixir/Supervisor.html # for other strategies and supported options opts = [strategy: :one_for_one, name: LSG.Supervisor] - Supervisor.start_link(children, opts) + sup = Supervisor.start_link(children, opts) + LSG.IRC.after_start() + sup end # Tell Phoenix to update the endpoint configuration diff --git a/lib/lsg/auth_token.ex b/lib/lsg/auth_token.ex new file mode 100644 index 0000000..0c5ba58 --- /dev/null +++ b/lib/lsg/auth_token.ex @@ -0,0 +1,59 @@ +defmodule LSG.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) + 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) + 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/telegram.ex b/lib/lsg/telegram.ex new file mode 100644 index 0000000..7541b13 --- /dev/null +++ b/lib/lsg/telegram.ex @@ -0,0 +1,254 @@ +defmodule LSG.Telegram do + require Logger + + use Telegram.Bot, + token: Keyword.get(Application.get_env(:lsg, :telegram, []), :key), + username: Keyword.get(Application.get_env(:lsg, :telegram), :nick, "beauttebot"), + purge: true + + def my_path() do + "https://t.me/beauttebot" + end + + def init() do + # Create users in track: IRC.UserTrack.connected(...) + :ok + end + + def handle_update(_, %{"message" => m = %{"chat" => %{"type" => "private"}, "text" => text = "/start"<>_}}) do + text = "Hello to beautte! Query the bot on IRC and say \"enable-telegram\" to continue." + send_message(m["chat"]["id"], text) + end + + def handle_update(_, %{"message" => m = %{"chat" => %{"type" => "private"}, "text" => text = "/enable"<>_}}) 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.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) + 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} + + def handle_update(token, data = %{"message" => %{"photo" => _}}) do + start_upload(token, "photo", data) + end + + def handle_update(token, data = %{"message" => %{"voice" => _}}) do + start_upload(token, "voice", data) + end + + def handle_update(token, data = %{"message" => %{"video" => _}}) do + start_upload(token, "video", data) + end + + def handle_update(token, data = %{"message" => %{"document" => _}}) do + start_upload(token, "document", data) + end + + def handle_update(token, data = %{"message" => %{"animation" => _}}) do + start_upload(token, "animation", data) + end + + def start_upload(token, _, %{"message" => m = %{"chat" => %{"id" => id, "type" => "private"}}}) 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" => i} end) + kb = if Enum.count(targets) > 1 do + [%{"text" => "everywhere", "callback_data" => "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"]) + 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(t, %{"callback_query" => cb = %{"data" => target, "id" => id, "message" => m = %{"message_id" => m_id, "chat" => %{"id" => chat_id}, "reply_to_message" => op}}}) 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), + <<smol_body::binary-size(20), _::binary>> = body, + {:ok, magic} <- GenMagic.Pool.perform(LSG.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: %{}), + s3req = ExAws.S3.put_object(bucket, s3path, body, acl: :public_read, content_type: magic.mime_type), + {:ok, _} <- ExAws.request(s3req) + do + path = "https://s3.wasabisys.com/#{bucket}/#{s3path}" + sent = for {net, chan} <- target do + user = IRC.UserTrack.find_by_account(net, account) + nick = if(user, do: user.nick, else: account.name) + txt = "#{nick} sent#{type}#{text} #{path}" + IRC.Connection.broadcast_message(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: %{}) + else + error -> + Telegram.Api.request(t, "editMessageText", chat_id: chat_id, message_id: m_id, text: "Something failed.", reply_markup: %{}) + Logger.error("Failed upload from Telegram: #{inspect error}") + end + end) + end + end + + def handle_update(_, %{"message" => m = %{"chat" => %{"id" => id, "type" => "private"}, "text" => text}}) do + account = IRC.Account.find_meta_account("telegram-id", id) + if account do + as_irc_message(id, text, account) + end + end + + def send_message(id, text) do + token = Keyword.get(Application.get_env(:lsg, :telegram, []), :key) + Telegram.Api.request(token, "sendMessage", chat_id: id, text: text) + end + + def handle_update(token__, m) do + Logger.debug("Unhandled update: #{inspect m}") + 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{ + 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) + } + IO.puts("converted telegram to message: #{inspect message}") + IRC.Connection.publish(message, ["message:private", "message:telegram"]) + message + end + + command "start" do + text = "Hello to beautte! Query the bot on IRC and say \"enable-telegram\" to continue." + request("sendMessage", chat_id: update["chat"]["id"], + text: text) + end + + command "start", args do + IO.inspect(update) + key = "none" + 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", update["chat"]["id"]) + IRC.Account.delete_meta(account, "telegram-validation-code") + IRC.Account.delete_meta(account, "telegram-validation-target") + IRC.Connection.broadcast_message(net, account, "Telegram account added!") + "Yay! Linked to account #{account.name}\n\nThe bot doesn't work by sending telegram commands, just send what you would usually send on IRC." + else + "Token invalid" + end + request("sendMessage", chat_id: update["chat"]["id"], text: text) + end + + command unknown do + request("sendMessage", chat_id: update["chat"]["id"], + text: "Hey! You sent me a command #{unknown} #{inspect update}") + end + + message do + request("sendMessage", chat_id: update["chat"]["id"], + text: "Hey! You sent me a message: #{inspect update}") + end +end + + diff --git a/lib/lsg_irc.ex b/lib/lsg_irc.ex index cb1e382..c811d18 100644 --- a/lib/lsg_irc.ex +++ b/lib/lsg_irc.ex @@ -1,29 +1,29 @@ defmodule LSG.IRC do def application_childs do - {:ok, irc_client} = ExIRC.start_link! + env = Application.get_env(:lsg, :irc) import Supervisor.Spec + + IRC.Connection.setup() + IRC.Plugin.setup() + for plugin <- Application.get_env(:lsg, :irc)[:plugins], do: IRC.Plugin.declare(plugin) + [ + 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.ConnectionHandler, [irc_client]), - worker(IRC.LoginHandler, [irc_client]), - worker(IRC.UserTrackHandler, [irc_client]), - worker(IRC.PubSubHandler, [irc_client], [name: :irc_pub_sub]), - ] - ++ - for handler <- Application.get_env(:lsg, :irc)[:handlers] do - worker(handler, [irc_client], [name: handler]) - end - ++ - for plugin <- Application.get_env(:lsg, :irc)[:plugins] do - worker(plugin, [], [name: plugin]) - end - ++ [ - worker(LSG.IRC.AlcoologAnnouncerPlugin, []) + worker(IRC.Account.AccountPlugin, []), + supervisor(IRC.Plugin.Supervisor, [], [name: IRC.Plugin.Supervisor]), + supervisor(IRC.Connection.Supervisor, [], [name: IRC.Connection.Supervisor]), ] end - + def after_start() do + # Start plugins first to let them get on connection events. + IRC.Plugin.start_all() + IRC.Connection.start_all() + end end diff --git a/lib/lsg_irc/alcolog_plugin.ex b/lib/lsg_irc/alcolog_plugin.ex index f27dcd8..600dc1a 100644 --- a/lib/lsg_irc/alcolog_plugin.ex +++ b/lib/lsg_irc/alcolog_plugin.ex @@ -2,91 +2,29 @@ defmodule LSG.IRC.AlcoologPlugin do require Logger @moduledoc """ - # alcoolisme + # [alcoolog]({{context_path}}/alcoolog) - * **`!santai <montant> <degrés d'alcool> [annotation]`**: enregistre un nouveau verre de `montant` d'une boisson à `degrés d'alcool`. + * **!santai `<cl | (calc)>` `<degrés d'alcool> [annotation]`**: enregistre un nouveau verre de `montant` d'une boisson à `degrés d'alcool`. + * **!santai `<cl | (calc)>` `<beer name>`**: enregistre un nouveau verre de `cl` de la bière `beer name`, et checkin sur Untappd.com. * **-santai**: annule la dernière entrée d'alcoolisme. * **.alcoolisme**: état du channel en temps réel. + * **.alcoolisme `<semaine | Xj>`**: points par jour, sur X j. * **!alcoolisme `[pseudo]`**: affiche les points d'alcoolisme. - * **!alcoolisme `semaine`**: affiche le top des 7 derniers jours + * **!alcoolisme `[pseudo]` `<semaine | Xj>`**: affiche les points d'alcoolisme par jour sur X j. * **+alcoolisme `<h|f>` `<poids en kg>` `[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 `<date>`**: affiche tu pourras être sobre pour `<date>`, et si oui, combien de volumes d'alcool peuvent encore être consommés. - * **!alcoolog**: lien pour voir l'état/statistiques et historique de l'alcoolémie du channel. + * **!alcoolog**: ([voir]({{context_path}}/alcoolog)) lien pour voir l'état/statistiques et historique de l'alcoolémie du channel. * **!alcool `<cl>` `<degrés>`**: donne le nombre d'unités d'alcool dans `<cl>` à `<degrés>°`. * **!soif**: c'est quand l'apéro ? - 1 point = 1 volume d'alcool - - Anciennes commandes: - - * **!`<trigger>` `[coeff]` `[annotation]`**: enregistre de l'alcoolisme. - - Triggers/Coeffs: - - * bière: (coeffs: 25, 50/+, 75/++, 100/+++, ++++) - * pinard: (coeffs: +, ++, +++, ++++) - * shot/fort/whisky/rhum/..: (coeffs: +, ++, +++, ++++) - * eau (-1) + 1 point = 1 volume d'alcool. Annotation: champ libre! """ - @triggers %{ - "apero" => - %{ - triggers: ["apero", "apéro", "apairo", "apaireau"], - default_coeff: "", - coeffs: %{ - "" => 1.0, - "+" => 2.0, - "++" => 3.0, - "+++" => 4.0, - "++++" => 5.0, - } - }, - "bière" => - %{ - triggers: ["beer", "bière", "biere", "biaire"], - default_coeff: "25", - coeffs: %{ - "25" => 1.0, - "50" => 2.0, - "75" => 3.0, - "100" => 4.0, - "+" => 2.0, - "++" => 3, - "+++" => 4, - "++++" => 5, - } - }, - "pinard" => %{ - triggers: ["pinard", "vin", "rouge", "blanc", "rosé", "rose"], - annotations: ["rouge", "blanc", "rosé", "rose"], - default_coeff: "", - coeffs: %{ - "" => 1, - "+" => 2, - "++" => 3, - "+++" => 4, - "++++" => 5, - "vase" => 6, - } - }, - "fort" => %{ - triggers: ["shot", "royaume", "whisky", "rhum", "armagnac", "dijo"], - default_coeff: "", - coeffs: %{ - "" => 1, - "+" => 2, - "++" => 3, - "+++" => 4, - "++++" => 5 - } - } - } @user_states %{ :sober => %{ false => ["n'a pas encore bu", "est actuellement sobre"], @@ -149,9 +87,64 @@ defmodule LSG.IRC.AlcoologPlugin do "ZHU NIN JIANKANG", "祝您健康", "SANTÉ", - "CHEERS" + "CHEERS", + "Nuş olsun", + "Živjeli", + "Biba", + "Pura vida", + "Terviseks", + "Mabuhay", + "Kippis", + "Sláinte", + "건배", + "į sveikatą", + "Эрүүл мэндийн төлөө", + "Saúde", + "Sanatate", + "živeli", + "Proscht", + "Chai yo", + "Choc tee", + "Şerefe", + "будьмо", + "Budmo", + "Iechyd da", + "Sei gesund", + "за наш", # Russe original. --kienma + ] - ] + @bad_drinks %{ + :negative => [ + "on peut pas boire en négatif...", + "tu crois que ça marche comme ça ? :s", + "e{{'u' | rrepeat}}h" + ], + :null => [ + "tu me prends pas un peu pour un con là ?", + "zéro ? agis comme un homme et bois une pinte de whisky", + "gros naze", + "FATAL ERROR: java.lang.ArithmeticException (cannot divide by zero)", + "?!?!", + "votre médecin vous conseille de boire une bouteille de rouge cul sec plutôt" + ], + :huge => [ + "tu veux pas aller à l'hopital plutôt ?", + "tu me prends pas un peu pour un con là ?", + "ok; j'ai appelé le SAMU, ils arrivent", + "j'accepterais le !santai quand tu m'auras envoyé la preuve vidéo" + ], + :eau => [ + "Le barman, c'est moi ! C'est moi qui suis barman ! Vous êtes la police républicaine ou une bande ? Vous savez qui je suis ? Enfoncez la bouteille, camarades !", + "L'alcool est notre pire ennemi, fuir est lâche !", + "attention... l'alcool permet de rendre l'eau potable", + "{{'QUOI ?' | bold}}", + "QUO{{'I' | rrepeat}}?", + "{{'COMMENT ÇA DE L'EAU ?' | red}}", + "resaisis toi et va ouvrir une bonne teille de rouge...", + "bwais tu veux pas un \"petit\" rhum plutôt ?" + ] + + } def irc_doc, do: @moduledoc @@ -170,21 +163,16 @@ defmodule LSG.IRC.AlcoologPlugin do end def init(_) do + {:ok, _} = Registry.register(IRC.PubSub, "account", []) {:ok, _} = Registry.register(IRC.PubSub, "trigger:santai", []) + {:ok, _} = Registry.register(IRC.PubSub, "trigger:santo", []) + {:ok, _} = Registry.register(IRC.PubSub, "trigger:santeau", []) {:ok, _} = Registry.register(IRC.PubSub, "trigger:alcoolog", []) {:ok, _} = Registry.register(IRC.PubSub, "trigger:sobre", []) {:ok, _} = Registry.register(IRC.PubSub, "trigger:sobrepour", []) {:ok, _} = Registry.register(IRC.PubSub, "trigger:soif", []) {:ok, _} = Registry.register(IRC.PubSub, "trigger:alcoolisme", []) {:ok, _} = Registry.register(IRC.PubSub, "trigger:alcool", []) - for {_, config} <- @triggers do - for trigger <- config.triggers do - {:ok, _} = Registry.register(IRC.PubSub, "trigger:#{trigger}", []) - for coeff when byte_size(coeff) > 0 <- Map.keys(config.coeffs) do - {:ok, _} = Registry.register(IRC.PubSub, "trigger:#{trigger}#{coeff}", []) - end - end - end dets_filename = (LSG.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}]) @@ -192,42 +180,41 @@ defmodule LSG.IRC.AlcoologPlugin do {:ok, meta} = :dets.open_file(dets_meta_filename, [{:type,:set}]) state = %{dets: dets, meta: meta, ets: ets} - # Upgrade dets format - update_fun = fn(obj, dets) -> + traverse_fun = fn(obj, dets) -> case obj do - object = {nick, date, volumes, name, comment} -> - :dets.delete_object(dets, object) - :dets.insert(dets, {nick, date, volumes || 0.0, 0, name, comment}) - dets object = {nick, date, volumes, active, name, comment} -> - volumes = if is_integer(volumes) do - volumes = volumes+0.0 - :dets.delete_object(dets, object) - :dets.insert(dets, {nick, date, volumes || 0.0, 0, name, comment}) - volumes - else - volumes - end :ets.insert(ets, {{nick, date}, volumes, active, name, comment}) dets _ -> dets end end - :dets.foldl(update_fun, dets, dets) + :dets.foldl(traverse_fun, dets, dets) :dets.sync(dets) + {: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 + m.replyfun.(Tmpl.render(Enum.random(Enum.shuffle(Map.get(@bad_drinks, :eau))), m)) + {: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 = cond do now.hour >= 0 && now.hour < 6 -> ["apéro tardif ? Je dis OUI ! SANTAI !"] now.hour >= 6 && now.hour < 12 -> - ["C'est quand même un peu tôt non ? Prochain apéro #{apero}"] + if day_of_week >= 6 do + ["C'est quand même un peu tôt non ? Prochain apéro #{apero}"] + else + ["de l'alcool pour le petit dej ? le week-end, pas de problème !"] + 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 !" @@ -240,9 +227,14 @@ defmodule LSG.IRC.AlcoologPlugin do "préparez les teilles, apéro dans #{apero}!" ] now.hour >= 14 && now.hour < 18 -> - ["tiens bon! apéro #{apero}", - "courage... apéro dans #{apero}", - "pas encore :'( apéro dans #{apero}" + 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}" ] true -> [ @@ -293,8 +285,8 @@ defmodule LSG.IRC.AlcoologPlugin do end if time do - meta = get_user_meta(state, m.sender.nick) - stats = get_full_statistics(state, m.sender.nick) + meta = get_user_meta(state, m.account.id) + stats = get_full_statistics(state, m.account.id) duration = round(DateTime.diff(time, now)/60.0) @@ -318,9 +310,15 @@ defmodule LSG.IRC.AlcoologPlugin do {: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) + m.replyfun.("-> #{url}") + {:noreply, state} + end + def handle_info({:irc, :trigger, "alcoolog", m = %IRC.Message{trigger: %IRC.Trigger{args: [], type: :bang}}}, state) do - {:ok, token} = LSG.Token.new({:alcoolog, :index, m.channel}) - url = LSGWeb.Router.Helpers.alcoolog_url(LSGWeb.Endpoint, :index, token) + url = LSGWeb.Router.Helpers.alcoolog_url(LSGWeb.Endpoint, :index, m.network, LSGWeb.format_chan(m.channel)) m.replyfun.("-> #{url}") {:noreply, state} end @@ -329,7 +327,7 @@ defmodule LSG.IRC.AlcoologPlugin do {cl, _} = Util.float_paparse(cl) {deg, _} = Util.float_paparse(deg) points = Alcool.units(cl, deg) - meta = get_user_meta(state, m.sender.nick) + 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) @@ -345,41 +343,67 @@ defmodule LSG.IRC.AlcoologPlugin do {:noreply, state} end - def handle_info({:irc, :trigger, "santai", m = %IRC.Message{trigger: %IRC.Trigger{args: args, type: :bang}}}, state) do - case args do - [cl, deg | comment] -> - comment = if comment == [] do - nil - else - Enum.join(comment, " ") + def handle_info({:irc, :trigger, "santai", m = %IRC.Message{trigger: %IRC.Trigger{args: [cl, deg | comment], type: :bang}}}, state) do + comment = if comment == [] do + nil + else + Enum.join(comment, " ") + end + + {cl, cl_extra} = case {Util.float_paparse(cl), cl} do + {{cl, extra}, _} -> {cl, extra} + {:error, "("<>_} -> + try do + {:ok, result} = Abacus.eval(cl) + {result, nil} + rescue + _ -> {nil, "cl: invalid calc expression"} end + {:error, _} -> {nil, "cl: invalid value"} + end - {cl, _} = Util.float_paparse(cl) - {deg, _} = Util.float_paparse(deg) - points = Alcool.units(cl, deg) + {deg, comment, auto_set, beer_id} = case Util.float_paparse(deg) do + {deg, _} -> {deg, comment, 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 - if cl > 0 && deg > 0 do - now = DateTime.to_unix(DateTime.utc_now(), :millisecond) - user_meta = get_user_meta(state, m.sender.nick) - name = "#{cl}cl #{deg}°" - old_stats = get_full_statistics(state, m.sender.nick) - :ok = :dets.insert(state.dets, {String.downcase(m.sender.nick), now, points, if(old_stats, do: old_stats.active, else: 0), name, comment}) - true = :ets.insert(state.ets, {{String.downcase(m.sender.nick), now}, points, if(old_stats, do: old_stats.active, else: 0),name, comment}) - sante = @santai |> Enum.shuffle() |> Enum.random() - meta = get_user_meta(state, m.sender.nick) - k = if meta.sex, do: 0.7, else: 0.6 - weight = meta.weight - peak = Float.round((10*points||0.0)/(k*weight), 4) - stats = get_full_statistics(state, m.sender.nick) - 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") + + cond do + cl == nil -> m.replyfun.(cl_extra) + deg == nil -> m.replyfun.(comment) + cl >= 500 || deg >= 100 -> m.replyfun.(Tmpl.render(Enum.random(Enum.shuffle(Map.get(@bad_drinks, :huge))), m)) + cl == 0 || deg == 0 -> m.replyfun.(Tmpl.render(Enum.random(Enum.shuffle(Map.get(@bad_drinks, :null))), m)) + cl < 0 || deg < 0 -> m.replyfun.(Tmpl.render(Enum.random(Enum.shuffle(Map.get(@bad_drinks, :negative))), m)) + true -> + points = Alcool.units(cl, deg) + now = DateTime.to_unix(DateTime.utc_now(), :millisecond) + user_meta = get_user_meta(state, m.account.id) + name = "#{cl}cl #{deg}°" + old_stats = get_full_statistics(state, m.account.id) + :ok = :dets.insert(state.dets, {m.account.id, now, points, if(old_stats, do: old_stats.active, else: 0), name, comment}) + true = :ets.insert(state.ets, {{m.account.id, now}, points, if(old_stats, do: old_stats.active, else: 0),name, comment}) + sante = @santai |> Enum.map(fn(s) -> String.trim(String.upcase(s)) end) |> Enum.shuffle() |> Enum.random() + meta = get_user_meta(state, m.account.id) + k = if meta.sex, do: 0.7, else: 0.6 + weight = 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 @@ -388,29 +412,139 @@ defmodule LSG.IRC.AlcoologPlugin do detail end - up = if stats.active_drinks > 1 do - " " <> Enum.join(for(_ <- 1..stats.active_drinks, do: "▲")) <> "" - else - "" - end + up = if stats.active_drinks > 1 do + " " <> Enum.join(for(_ <- 1..stats.active_drinks, do: "▲")) <> "" + else + "" + end - m.replyfun.("#{sante} #{m.sender.nick}#{up} #{format_points(points)} @#{stats.active}g/l [+#{peak} g/l]" + 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}) (sobriété #{at} (dans #{stats.sober_in_s})#{sober_add}) !" - <> " (aujourd'hui #{stats.daily_volumes} points - #{stats.daily_gl} g/l)") - else - m.replyfun.("on ne peut pas boire en négatif...") + <> " (aujourd'hui #{stats.daily_volumes} points - #{stats.daily_gl} g/l)" end - _ -> - m.replyfun.("!santai <cl> <degrés> [commentaire]") + + 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(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: " #{comment} (#{deg}°)", else: "" + 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 + stats.active30m >= 2.9 && stats.active30m < 3 -> :miss3 + stats.active30m >= 1.9 && stats.active30m < 2 -> :miss2 + stats.active30m >= 0.9 && stats.active30m < 1 -> :miss1 + stats.active30m >= 0.45 && stats.active30m < 0.5 -> :miss05 + stats.active30m >= 0.20 && stats.active30m < 0.20 -> :miss025 + stats.active30m >= 3 && stats.active1h < 3.15 -> :small3 + stats.active30m >= 2 && stats.active1h < 2.15 -> :small2 + stats.active30m >= 1.5 && stats.active1h < 1.5 -> :small15 + stats.active30m >= 1 && stats.active1h < 1.15 -> :small1 + stats.active30m >= 0.5 && stats.active1h <= 0.51 -> :small05 + stats.active30m >= 0.25 && stats.active30m <= 0.255 -> :small025 + true -> nil + end + + miss = case miss do + :miss025 -> [ + "si peu ?" + ] + :miss05 -> [ + "tu pourras encore conduire, faut boire plus!" + ] + :miss1 -> [ + "alerte, tu vas rater le gramme !" + ] + :miss2 -> [ + "petit joueur ? rater deux grammes de si peu c'est pas sérieux" + ] + :miss3 -> [ + "même pas 3 grammes ? allez ! dernière ligne droite!" + ] + :small025 -> [ + "tu vas quand même pas en rester là si ?" + ] + :small05 -> [ + "c'est un bon début, faut continuer sur cette lancée !" + ] + :small1 -> [ + "un peu plus de motivation sur le gramme et c'est parfait !" + ] + :small15 -> [ + "essaie de garder ton gramme et demi plus longtemps !" + ] + :small2 -> [ + "même pas une demi heure de deux grammes ? allez, fait un effort !", + "installe toi mieux aux deux grammes !" + ] + :small3 -> [ + "ALLAIIIII CONTINUE FAUT MAINTENIR !", + "tu vas quand même pas en rester là ?" + ] + _ -> nil + end + if miss do + msg = Enum.random(Enum.shuffle(miss)) + 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}: #{msg}") + end + end + end {:noreply, state} end - def get_channel_statistics(channel) do - IRC.UserTrack.to_list() - |> Enum.filter(fn({_, nick, _, _, _, _, channels}) -> Map.get(channels, channel) end) - |> Enum.map(fn({_, nick, _, _, _, _, _}) -> nick end) - |> Enum.map(fn(nick) -> {nick, get_full_statistics(nick)} end) + def handle_info({:irc, :trigger, "santai", m = %IRC.Message{trigger: %IRC.Trigger{args: _, type: :bang}}}, state) do + m.replyfun.("!santai <cl> <degrés> [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 @@ -423,8 +557,9 @@ defmodule LSG.IRC.AlcoologPlugin do case get_statistics_for_nick(state, nick) do {count, {_, last_at, last_points, last_active, last_type, last_descr}} -> {active, active_drinks} = current_alcohol_level(state, nick) - {rising, m30} = alcohol_level_rising(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 @@ -474,7 +609,7 @@ defmodule LSG.IRC.AlcoologPlugin do %{active: active, last_at: last_at, last_points: last_points, last_type: last_type, last_descr: last_descr, trend_symbol: trend, - active15m: m15, active30m: m30, active1h: h1, + active5m: m5, active15m: m15, active30m: m30, active1h: h1, rising: rising, active_drinks: active_drinks, user_status: user_status, @@ -487,9 +622,8 @@ defmodule LSG.IRC.AlcoologPlugin do end def handle_info({:irc, :trigger, "sobre", m = %IRC.Message{trigger: %IRC.Trigger{args: args, type: :dot}}}, state) do - nicks = Enum.filter(IRC.UserTrack.to_list, fn({_, nick, _, _, _, _, channels}) -> Map.get(channels, m.channel) end) |> Enum.map(fn({_, nick, _, _, _, _, _}) -> nick end) - nicks = nicks - |> Enum.map(fn(nick) -> {nick, get_full_statistics(state, nick)} end) + 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, &</2) |> Enum.map(fn({nick, stats}) -> @@ -518,34 +652,39 @@ defmodule LSG.IRC.AlcoologPlugin do end def handle_info({:irc, :trigger, "sobre", m = %IRC.Message{trigger: %IRC.Trigger{args: args, type: :bang}}}, state) do - nick = case args do - [nick] -> nick - [] -> m.sender.nick + account = case args do + [nick] -> IRC.Account.find_always_by_nick(m.network, m.channel, nick) + [] -> m.account end - stats = get_full_statistics(state, nick) - 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})!") + 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, nick) + 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.("#{nick} est déjà sobre. aidez le !") + 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 = Enum.filter(IRC.UserTrack.to_list, fn({_, nick, _, _, _, _, channels}) -> Map.get(channels, m.channel) end) |> Enum.map(fn({_, nick, _, _, _, _, _}) -> nick end) - nicks = nicks - |> Enum.map(fn(nick) -> {nick, get_full_statistics(state, nick)} end) + 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}) -> @@ -554,7 +693,7 @@ defmodule LSG.IRC.AlcoologPlugin do else status.trend_symbol end - "#{nick} #{status.user_status} #{trend_symbol} #{status.active} g/l" + "#{nick} #{status.user_status} #{trend_symbol} #{Float.round(status.active, 4)} g/l" end) |> Enum.intersperse(", ") |> Enum.join("") @@ -569,21 +708,116 @@ defmodule LSG.IRC.AlcoologPlugin do {: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 + + # TODO Fix with user/channel membership def handle_info({:irc, :trigger, "alcoolisme", m = %IRC.Message{trigger: %IRC.Trigger{args: ["semaine"], type: :bang}}}, state) do aday = 7*((24 * 60)*60) now = DateTime.utc_now() before = now |> DateTime.add(-aday, :second) - |> DateTime.to_unix(:millisecond) + |> DateTime.to_unix(:millisecond) + over_time_stats(before, 7, m, state) + end + + def handle_info({:irc, :trigger, "alcoolisme", m = %IRC.Message{trigger: %IRC.Trigger{args: ["30j"], type: :bang}}}, state) do + aday = 31*((24 * 60)*60) + now = DateTime.utc_now() + before = now + |> DateTime.add(-aday, :second) + |> DateTime.to_unix(:millisecond) + over_time_stats(before, 30, m, state) + end + + def handle_info({:irc, :trigger, "alcoolisme", m = %IRC.Message{trigger: %IRC.Trigger{args: ["90j"], type: :bang}}}, state) do + aday = 91*((24 * 60)*60) + now = DateTime.utc_now() + before = now + |> DateTime.add(-aday, :second) + |> DateTime.to_unix(:millisecond) + over_time_stats(before, 90, m, state) + end + + def handle_info({:irc, :trigger, "alcoolisme", m = %IRC.Message{trigger: %IRC.Trigger{args: ["180j"], type: :bang}}}, state) do + aday = 180*((24 * 60)*60) + now = DateTime.utc_now() + before = now + |> DateTime.add(-aday, :second) + |> DateTime.to_unix(:millisecond) + over_time_stats(before, 180, m, state) + end + + + def handle_info({:irc, :trigger, "alcoolisme", m = %IRC.Message{trigger: %IRC.Trigger{args: ["365j"], type: :bang}}}, state) do + aday = 365*((24 * 60)*60) + now = DateTime.utc_now() + before = now + |> DateTime.add(-aday, :second) + |> DateTime.to_unix(:millisecond) + over_time_stats(before, 365, m, state) + end + + defp 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 + + defp over_time_stats(before, j, m, state) do #match = :ets.fun2ms(fn(obj = {{^nick, date}, _, _, _, _}) when date > before -> obj end) - match = [ - {{{:_, :"$1"}, :_, :_, :_, :_}, - [ - {:>, :"$1", {:const, before}}, - ], [:"$_"]} + match = [{{{:_, :"$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) -> @@ -591,15 +825,20 @@ defmodule LSG.IRC.AlcoologPlugin do Map.put(acc, nick, all + vol) end) |> Enum.sort_by(fn({_nick, count}) -> count end, &>/2) - |> Enum.map(fn({nick, count}) -> "#{nick}: #{Float.round(count, 4)}" end) + |> 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 7 jours: #{top}") + 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.sender.nick) + 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} @@ -629,13 +868,15 @@ defmodule LSG.IRC.AlcoologPlugin do if h == nil || weight == nil do m.replyfun.("paramètres invalides") else - old_meta = get_user_meta(state, m.sender.nick) + 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.sender.nick, meta) - msg = if old_meta.weight < meta.weight do - "t'as grossi..." - else - "ok" + put_user_meta(state, m.account.id, meta) + fat = ["t'as grossi...", "bientôt la tonne ?", "t'as encore abusé du fromage ?"] + thin = ["en route vers l'anorexie ?", "t'as grossi...", "faut manger plus de fromage!"] + msg = cond do + old_meta.weight < meta.weight -> Enum.random(Enum.shuffle(fat)) + old_meta.weight == meta.weight -> "ok" + true -> Enum.random(Enum.shuffle(thin)) end m.replyfun.(msg) end @@ -644,12 +885,18 @@ defmodule LSG.IRC.AlcoologPlugin do 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.sender.nick) do + case get_statistics_for_nick(state, m.account.id) do {_, obj = {_, date, points, _last_active, type, descr}} -> :dets.delete_object(state.dets, obj) - :ets.delete(state.ets, {String.downcase(m.sender.nick), date}) + :ets.delete(state.ets, {m.account.id, date}) m.replyfun.("supprimé: #{m.sender.nick} #{points} #{type} #{descr}") m.replyfun.("faudrait quand même penser à boire") + 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} @@ -657,61 +904,134 @@ defmodule LSG.IRC.AlcoologPlugin do end def handle_info({:irc, :trigger, "alcoolisme", m = %IRC.Message{trigger: %IRC.Trigger{args: args, type: :bang}}}, state) do - nick = case args do - [nick] -> nick - [] -> m.sender.nick + {account, duration} = case args do + [nick | rest] -> {IRC.Account.find_always_by_nick(m.network, m.channel, nick), rest} + [] -> {m.account, []} end - if stats = get_full_statistics(state, nick) do - trend_symbol = if stats.active_drinks > 1 do - Enum.join(for(_ <- 1..stats.active_drinks, do: stats.trend_symbol)) + 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 - stats.trend_symbol + # 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 - msg = "#{nick} #{stats.user_status} " - <> (if stats.active > 0 || stats.active15m > 0 || stats.active30m > 0 || stats.active1h > 0, do: ": #{trend_symbol} #{stats.active}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: "") - <> "— 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") + 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: "") + <> "— 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 - for {name, config} <- @triggers do - coeffs = Map.get(config, "coeffs") - default_coeff_value = Map.get(config.coeffs, config.default_coeff) - + # 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 - #IO.puts "at triggers #{inspect config}" - # Handle each trigger - for trigger <- config.triggers do + def terminate(_, state) do + for dets <- [state.dets, state.meta] do + :dets.sync(dets) + :dets.close(dets) + end + end - # … with a known coeff … - for {coef, value} when byte_size(coef) > 0 <- config.coeffs do - def handle_info({:irc, :trigger, unquote(trigger), m = %IRC.Message{trigger: %IRC.Trigger{args: [unquote(coef)<>_ | args], type: :bang}}}, state) do - handle_bang(unquote(name), unquote(value), m, args, state) - {:noreply, state} - end - def handle_info({:irc, :trigger, unquote(trigger)<>unquote(coef), m = %IRC.Message{trigger: %IRC.Trigger{args: args, type: :bang}}}, state) do - handle_bang(unquote(name), unquote(value), m, args, state) - {:noreply, state} - end - end + defp rename_object_owner(table, ets, object = {old_id, date, volume, current, name, comment}, old_id, new_id) do + :dets.delete_object(table, object) + :ets.delete(ets, {old_id, date}) + :dets.insert(table, {new_id, date, volume, current, name, comment}) + :ets.insert(ets, {{new_id, date}, volume, current, name, comment}) + end - # … or without - def handle_info({:irc, :trigger, unquote(trigger), m = %IRC.Message{trigger: %IRC.Trigger{args: args, type: :bang}}}, state) do - handle_bang(unquote(name), unquote(default_coeff_value), m, args, state) - {:noreply, state} + # 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, _name, _comment}) -> + #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 @@ -719,35 +1039,8 @@ defmodule LSG.IRC.AlcoologPlugin do {:noreply, state} end - defp handle_bang(name, points, message, args, state) do - description = case args do - [] -> nil - [something] -> something - something when is_list(something) -> Enum.join(something, " ") - _ -> nil - end - now = DateTime.to_unix(DateTime.utc_now(), :milliseconds) - points = points+0.0 - user_meta = get_user_meta(state, message.sender.nick) - # TODO: Calculer la perte sur last_active depuis last_at. - # TODO: Ajouter les g/L de la nouvelle boisson. - :ok = :dets.insert(state.dets, {String.downcase(message.sender.nick), now, points, 0, name, description}) - true = :ets.insert(state.ets, {{String.downcase(message.sender.nick), now}, points, 0, name, description}) - {active,_} = current_alcohol_level(state, message.sender.nick) - {count, {_, last_at, last_points, _active, last_type, last_descr}} = get_statistics_for_nick(state, message.sender.nick) - sante = @santai |> Enum.shuffle() |> Enum.random() - k = if user_meta.sex, do: 0.7, else: 0.6 - weight = user_meta.weight - peak = (10*points)/(k*weight) - {_, m30} = alcohol_level_rising(state, message.sender.nick) - {_, m15} = alcohol_level_rising(state, message.sender.nick, 15) - {_, h1} = alcohol_level_rising(state, message.sender.nick, 60) - #message.replyfun.("#{sante} #{message.sender.nick} #{format_points(points)}! #{active}~ g/L (total #{Float.round(count+0.0, 4)} points)") - message.replyfun.("#{sante} #{message.sender.nick} #{format_points(points)} @#{active}~ g/l [+#{Float.round(peak+0.0, 4)} g/l] (15m: #{m15}, 30m: #{m30}, 1h: #{h1})! (total #{Float.round(count+0.0, 4)} points)") - end - - defp get_statistics_for_nick(state, nick) do - qvc = :dets.lookup(state.dets, String.downcase(nick)) + defp get_statistics_for_nick(state, account_id) do + qvc = :dets.lookup(state.dets, account_id) |> Enum.sort_by(fn({_, ts, _, _, _, _}) -> ts end, &</2) count = Enum.reduce(qvc, 0, fn({_nick, _ts, points, _active, _type, _descr}, acc) -> acc + (points||0) end) last = List.last(qvc) || nil @@ -785,13 +1078,13 @@ defmodule LSG.IRC.AlcoologPlugin do relative <> detail end - defp put_user_meta(state, nick, meta) do - :dets.insert(state.meta, {{:meta, String.downcase(nick)}, meta}) + 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}, nick) do - case :dets.lookup(meta, {:meta, String.downcase(nick)}) do + 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) _ -> @@ -809,8 +1102,8 @@ defmodule LSG.IRC.AlcoologPlugin do # stop folding when ? # - defp user_stats(state = %{ets: ets}, nick) do - meta = get_user_meta(state, nick) + 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 @@ -818,12 +1111,12 @@ defmodule LSG.IRC.AlcoologPlugin do |> DateTime.to_unix(:millisecond) #match = :ets.fun2ms(fn(obj = {{^nick, date}, _, _, _, _}) when date > before -> obj end) match = [ - {{{:"$1", :"$2"}, :_, :_, :_, :_}, - [ - {:>, :"$2", {:const, before}}, - {:"=:=", {:const, String.downcase(nick)}, :"$1"} - ], [:"$_"]} -] + {{{:"$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} @@ -836,11 +1129,11 @@ defmodule LSG.IRC.AlcoologPlugin do {Float.round(total_volume + 0.0, 4), Float.round(gl + 0.0, 4)} end - defp alcohol_level_rising(state, nick, minutes \\ 30) do - {now, _} = current_alcohol_level(state, nick) + 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, nick, soon_date) + {soon, _} = current_alcohol_level(state, account_id, soon_date) soon = cond do soon < 0 -> 0.0 true -> soon @@ -849,8 +1142,8 @@ defmodule LSG.IRC.AlcoologPlugin do {soon > now, Float.round(soon+0.0, 4)} end - defp current_alcohol_level(state = %{ets: ets}, nick, now \\ nil) do - meta = get_user_meta(state, nick) + 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 @@ -860,15 +1153,14 @@ defmodule LSG.IRC.AlcoologPlugin do before = now |> DateTime.add(-aday, :second) |> DateTime.to_unix(:millisecond) - nick = String.downcase(nick) #match = :ets.fun2ms(fn(obj = {{^nick, date}, _, _, _, _}) when date > before -> obj end) match = [ - {{{:"$1", :"$2"}, :_, :_, :_, :_}, - [ - {:>, :"$2", {:const, before}}, - {:"=:=", {:const, nick}, :"$1"} - ], [:"$_"]} -] + {{{:"$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, &</2) @@ -880,7 +1172,7 @@ defmodule LSG.IRC.AlcoologPlugin do date = DateTime.from_unix!(date, :millisecond) 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}" + #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 @@ -898,7 +1190,7 @@ defmodule LSG.IRC.AlcoologPlugin do 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})" + #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} diff --git a/lib/lsg_irc/alcoolisme_plugin.ex b/lib/lsg_irc/alcoolisme_plugin.ex deleted file mode 100644 index 03dc75b..0000000 --- a/lib/lsg_irc/alcoolisme_plugin.ex +++ /dev/null @@ -1,198 +0,0 @@ -defmodule LSG.IRC.AlcoolismePlugin do - require Logger - - @moduledoc """ - # Alcoolisme - - * **!`<trigger>` `[coeff]` `[annotation]`** enregistre de l'alcoolisme. - * **!alcoolisme `[pseudo]`** affiche les points d'alcoolisme. - - Triggers/Coeffs: - - * bière: (coeffs: 25, 50/+, 75/++, 100/+++, ++++) - * pinard: (coeffs: +, ++, +++, ++++) - * shot/fort/whisky/rhum/..: (coeffs: +, ++, +++, ++++) - * eau (-1) - - Annotation: champ libre! - - """ - - @triggers %{ - "apero" => - %{ - triggers: ["apero", "apéro", "apairo", "santai"], - default_coeff: "25", - coeffs: %{ - "+" => 2, - "++" => 3, - "+++" => 4, - "++++" => 5, - } - }, - "bière" => - %{ - triggers: ["beer", "bière", "biere", "biaire"], - default_coeff: "25", - coeffs: %{ - "25" => 1, - "50" => 2, - "75" => 3, - "100" => 4, - "+" => 2, - "++" => 3, - "+++" => 4, - "++++" => 5, - } - }, - "pinard" => %{ - triggers: ["pinard", "vin", "rouge", "blanc", "rosé", "rose"], - annotations: ["rouge", "blanc", "rosé", "rose"], - default_coeff: "", - coeffs: %{ - "" => 1, - "+" => 2, - "++" => 3, - "+++" => 4, - "++++" => 5, - "vase" => 6, - } - }, - "fort" => %{ - triggers: ["shot", "royaume", "whisky", "rhum", "armagnac", "dijo"], - default_coeff: "", - coeffs: %{ - "" => 3, - "+" => 5, - "++" => 7, - "+++" => 9, - "++++" => 11 - } - }, - "eau" => %{ - triggers: ["eau"], - default_coeff: "1", - coeffs: %{ - "1" => -2, - "+" => -3, - "++" => -4, - "+++" => -6, - "++++" => -8 - } - } - } - - @santai ["SANTÉ", "SANTÉ", "SANTAIIII", "SANTAIIIIIIIIII", "SANTAI", "A LA TIENNE"] - - def irc_doc, do: @moduledoc - - def start_link(), do: GenServer.start_link(__MODULE__, []) - - def init(_) do - {:ok, _} = Registry.register(IRC.PubSub, "trigger:alcoolisme", []) - for {_, config} <- @triggers do - for trigger <- config.triggers do - {:ok, _} = Registry.register(IRC.PubSub, "trigger:#{trigger}", []) - for coeff when byte_size(coeff) > 0 <- Map.keys(config.coeffs) do - {:ok, _} = Registry.register(IRC.PubSub, "trigger:#{trigger}#{coeff}", []) - end - end - end - dets_filename = (LSG.data_path() <> "/" <> "alcoolisme.dets") |> String.to_charlist - {:ok, dets} = :dets.open_file(dets_filename, [{:type,:bag}]) - {:ok, dets} - end - - for {name, config} <- @triggers do - coeffs = Map.get(config, "coeffs") - default_coeff_value = Map.get(config.coeffs, config.default_coeff) - - - IO.puts "at triggers #{inspect config}" - # Handle each trigger - for trigger <- config.triggers do - - # … with a known coeff … - for {coef, value} when byte_size(coef) > 0 <- config.coeffs do - def handle_info({:irc, :trigger, unquote(trigger), m = %IRC.Message{trigger: %IRC.Trigger{args: [unquote(coef)<>_ | args], type: :bang}}}, dets) do - handle_bang(unquote(name), unquote(value), m, args, dets) - {:noreply, dets} - end - def handle_info({:irc, :trigger, unquote(trigger)<>unquote(coef), m = %IRC.Message{trigger: %IRC.Trigger{args: args, type: :bang}}}, dets) do - handle_bang(unquote(name), unquote(value), m, args, dets) - {:noreply, dets} - end - end - - # … or without - def handle_info({:irc, :trigger, unquote(trigger), m = %IRC.Message{trigger: %IRC.Trigger{args: args, type: :bang}}}, dets) do - handle_bang(unquote(name), unquote(default_coeff_value), m, args, dets) - {:noreply, dets} - end - - end - - end - - def handle_info({:irc, :trigger, "alcoolisme", m = %IRC.Message{trigger: %IRC.Trigger{args: args, type: :bang}}}, dets) do - nick = case args do - [nick] -> nick - [] -> m.sender.nick - end - case get_statistics_for_nick(dets, nick) do - {count, {_, last_at, last_points, last_type, last_descr}} -> - m.replyfun.("#{nick} a #{count} points d'alcoolisme. Dernier verre: #{present_type(last_type, - last_descr)} [#{last_points}] #{format_relative_timestamp(last_at)}") - _ -> - m.replyfun.("honteux mais #{nick} n'a pas l'air alcoolique du tout. /kick") - end - {:noreply, dets} - end - - defp handle_bang(name, points, message, args, dets) do - description = case args do - [] -> nil - [something] -> something - something when is_list(something) -> Enum.join(something, " ") - _ -> nil - end - now = DateTime.to_unix(DateTime.utc_now(), :milliseconds) - :ok = :dets.insert(dets, {String.downcase(message.sender.nick), now, points, name, description}) - {count, {_, last_at, last_points, last_type, last_descr}} = get_statistics_for_nick(dets, message.sender.nick) - sante = @santai |> Enum.shuffle() |> Enum.random() - message.replyfun.("#{sante} #{message.sender.nick} #{format_points(points)}! (total #{count} points)") - end - - defp get_statistics_for_nick(dets, nick) do - qvc = :dets.lookup(dets, String.downcase(nick)) - IO.puts inspect(qvc) - count = Enum.reduce(qvc, 0, fn({_nick, _ts, points, _type, _descr}, acc) -> acc + points 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 int > 0 do - "+#{Integer.to_string(int)}" - end - def format_points(int) when int < 0 do - Integer.to_string(int) - end - def format_points(0), do: "0" - - defp format_relative_timestamp(timestamp) do - alias Timex.Format.DateTime.Formatters - alias Timex.Timezone - date = timestamp - |> DateTime.from_unix!(:milliseconds) - |> 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 -end - diff --git a/lib/lsg_irc/alcoolog_announcer_plugin.ex b/lib/lsg_irc/alcoolog_announcer_plugin.ex index 214debd..28973ca 100644 --- a/lib/lsg_irc/alcoolog_announcer_plugin.ex +++ b/lib/lsg_irc/alcoolog_announcer_plugin.ex @@ -27,19 +27,22 @@ defmodule LSG.IRC.AlcoologAnnouncerPlugin do def start_link(), do: GenServer.start_link(__MODULE__, []) 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 {:ok, dets} = :dets.open_file(dets_filename, [{:type,:bag}]) + #:ok = LSG.IRC.SettingPlugin.declare("alcoolog.alerts", __MODULE__, true, :boolean) + #:ok = LSG.IRC.SettingPlugin.declare("alcoolog.aperoalert", __MODULE__, true, :boolean) {:ok, {stats, now(), dets}} end def alcohol_reached(old, new, level) do - (old.active < level && new.active >= level) + (old.active < level && new.active >= level) && (new.active5m >= level) end - + def alcohol_below(old, new, level) do - (old.active > level && new.active <= level) + (old.active > level && new.active <= level) && (new.active5m <= level) end @@ -55,25 +58,25 @@ defmodule LSG.IRC.AlcoologAnnouncerPlugin do {:timed, list} -> spawn(fn() -> for line <- list do - IRC.PubSubHandler.privmsg(@channel, line) + IRC.Connection.broadcast_message("evolu.net", "#dmz", line) :timer.sleep(:timer.seconds(5)) end end) string -> - IRC.PubSubHandler.privmsg(@channel, string) + IRC.Connection.broadcast_message("evolu.net", "#dmz", string) end end - IO.puts "newstats #{inspect stats}" - events = for {nick, old} <- old_stats do - new = Map.get(stats, nick, nil) - IO.puts "#{nick}: #{inspect(old)} -> #{inspect(new)}" + #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)}" if new && new[:active] do - :dets.insert(dets, {nick, DateTime.utc_now(), new[:active]}) + :dets.insert(dets, {acct, DateTime.utc_now(), new[:active]}) else - :dets.insert(dets, {nick, DateTime.utc_now(), 0.0}) + :dets.insert(dets, {acct, DateTime.utc_now(), 0.0}) end event = cond do @@ -98,10 +101,10 @@ defmodule LSG.IRC.AlcoologAnnouncerPlugin do (old.rising) && (!new.rising) -> :lowering true -> nil end - {nick, event} + {acct, event} end - for {nick, event} <- events do + for {acct, event} <- events do message = case event do :g1 -> [ "[vigicuite jaune] LE GRAMME! LE GRAMME O/", @@ -141,6 +144,15 @@ defmodule LSG.IRC.AlcoologAnnouncerPlugin do ] :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 !!" ] @@ -148,6 +160,8 @@ defmodule LSG.IRC.AlcoologAnnouncerPlugin do "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 -> [ @@ -157,13 +171,19 @@ defmodule LSG.IRC.AlcoologAnnouncerPlugin do "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 @@ -172,23 +192,34 @@ defmodule LSG.IRC.AlcoologAnnouncerPlugin do m when is_list(m) -> m |> Enum.shuffle() |> Enum.random() nil -> nil end - if message, do: IO.puts "#{nick}: #{message}" - if message, do: IRC.PubSubHandler.privmsg(@channel, "#{nick}: #{message}") + 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" + #IO.puts "tick stats ok" {:noreply, {stats,now,dets}} 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_channel_statistics(@channel), %{}) + Enum.into(LSG.IRC.AlcoologPlugin.get_all_stats(), %{}) end defp timer() do diff --git a/lib/lsg_irc/base_plugin.ex b/lib/lsg_irc/base_plugin.ex index c0cfc59..69b02e8 100644 --- a/lib/lsg_irc/base_plugin.ex +++ b/lib/lsg_irc/base_plugin.ex @@ -9,12 +9,85 @@ defmodule LSG.IRC.BasePlugin do def init([]) do {:ok, _} = Registry.register(IRC.PubSub, "trigger:version", []) {:ok, _} = Registry.register(IRC.PubSub, "trigger:help", []) + {:ok, _} = Registry.register(IRC.PubSub, "trigger:liquidrender", []) + {:ok, _} = Registry.register(IRC.PubSub, "trigger:plugin", []) {:ok, nil} end - def handle_info({:irc, :trigger, "help", message = %{trigger: %{type: :bang}}}, _) do - url = LSGWeb.Router.Helpers.irc_url(LSGWeb.Endpoint, :index) - message.replyfun.(url) + def handle_info({:irc, :trigger, "plugin", %{trigger: %{type: :query, args: [plugin]}} = m}, _) do + module = Module.concat([LSG.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")]) + 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")]) + 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")]) + 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)) + m.replyfun.("-> #{url}") {:noreply, nil} end diff --git a/lib/lsg_irc/calc_plugin.ex b/lib/lsg_irc/calc_plugin.ex index 6e4e30c..b8eee39 100644 --- a/lib/lsg_irc/calc_plugin.ex +++ b/lib/lsg_irc/calc_plugin.ex @@ -24,7 +24,7 @@ defmodule LSG.IRC.CalcPlugin do error -> inspect(error) end rescue - error -> "#{error.message}" + error -> if(error[:message], do: "#{error.message}", else: "erreur") end message.replyfun.("#{message.sender.nick}: #{expr} = #{result}") {:noreply, state} diff --git a/lib/lsg_irc/coronavirus_plugin.ex b/lib/lsg_irc/coronavirus_plugin.ex index debefa3..8038d14 100644 --- a/lib/lsg_irc/coronavirus_plugin.ex +++ b/lib/lsg_irc/coronavirus_plugin.ex @@ -102,7 +102,8 @@ defmodule LSG.IRC.CoronavirusPlugin do |> Enum.reduce(%{}, fn(line, acc) -> case line do # FIPS,Admin2,Province_State,Country_Region,Last_Update,Lat,Long_,Confirmed,Deaths,Recovered,Active,Combined_Key - [_, _, state, region, update, _lat, _lng, 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) diff --git a/lib/lsg_irc/correction_plugin.ex b/lib/lsg_irc/correction_plugin.ex index a5d3d31..e7b2577 100644 --- a/lib/lsg_irc/correction_plugin.ex +++ b/lib/lsg_irc/correction_plugin.ex @@ -12,10 +12,21 @@ defmodule LSG.IRC.CorrectionPlugin do def init(_) do {:ok, _} = Registry.register(IRC.PubSub, "message", []) - {:ok, []} + {:ok, _} = Registry.register(IRC.PubSub, "triggers", []) + {:ok, %{}} end - def handle_info({:irc, :text, m = %IRC.Message{}}, history) do + # 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 + + defp 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 | _] -> @@ -31,7 +42,7 @@ defmodule LSG.IRC.CorrectionPlugin do end _ -> m.replyfun.("correction: invalid regex format") end - {:noreply, history} + state else history = if length(history) > 100 do {_, history} = List.pop_at(history, 99) @@ -39,8 +50,10 @@ defmodule LSG.IRC.CorrectionPlugin do else [m | history] end - {:noreply, history} + Map.put(state, key(m), history) end end + defp key(%{network: net, channel: chan}), do: "#{net}/#{chan}" + end diff --git a/lib/lsg_irc/last_fm_plugin.ex b/lib/lsg_irc/last_fm_plugin.ex index 2446473..067a44e 100644 --- a/lib/lsg_irc/last_fm_plugin.ex +++ b/lib/lsg_irc/last_fm_plugin.ex @@ -18,6 +18,7 @@ defmodule LSG.IRC.LastFmPlugin do end def init([]) do + {:ok, _} = Registry.register(IRC.PubSub, "account", []) {:ok, _} = Registry.register(IRC.PubSub, "trigger:lastfm", []) {:ok, _} = Registry.register(IRC.PubSub, "trigger:lastfmall", []) dets_filename = (LSG.data_path() <> "/" <> "lastfm.dets") |> String.to_charlist @@ -27,13 +28,13 @@ defmodule LSG.IRC.LastFmPlugin do def handle_info({:irc, :trigger, "lastfm", message = %{trigger: %{type: :plus, args: [username]}}}, state) do username = String.strip(username) - :ok = :dets.insert(state.dets, {String.downcase(message.sender.nick), 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.sender.nick) do + text = case :dets.lookup(state.dets, message.account.id) do [{_nick, username}] -> :dets.delete(state.dets, String.downcase(message.sender.nick)) message.replyfun.("#{message.sender.nick}: nom d'utilisateur last.fm enlevé.") @@ -43,7 +44,7 @@ defmodule LSG.IRC.LastFmPlugin do end def handle_info({:irc, :trigger, "lastfm", message = %{trigger: %{type: :bang, args: []}}}, state) do - irc_now_playing(message.sender.nick, message, state) + irc_now_playing(message.account.id, message, state) {:noreply, state} end @@ -53,9 +54,12 @@ defmodule LSG.IRC.LastFmPlugin do end def handle_info({:irc, :trigger, "lastfmall", message = %{trigger: %{type: :bang}}}, state) do - foldfun = fn({_nick, user}, acc) -> [user|acc] end + 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.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 @@ -74,6 +78,12 @@ defmodule LSG.IRC.LastFmPlugin do defp irc_now_playing(nick_or_user, message, state) do nick_or_user = String.strip(nick_or_user) + if account = 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, String.downcase(nick_or_user)) do [{^nick_or_user, username}] -> username _ -> nick_or_user diff --git a/lib/lsg_irc/link_plugin.ex b/lib/lsg_irc/link_plugin.ex index bc9764a..97835e4 100644 --- a/lib/lsg_irc/link_plugin.ex +++ b/lib/lsg_irc/link_plugin.ex @@ -39,7 +39,7 @@ defmodule LSG.IRC.LinkPlugin do require Logger def start_link() do - GenServer.start_link(__MODULE__, []) + GenServer.start_link(__MODULE__, [], name: __MODULE__) end @callback match(uri :: URI.t, options :: Keyword.t) :: {true, params :: Map.t} | false @@ -49,6 +49,7 @@ defmodule LSG.IRC.LinkPlugin do def init([]) do {:ok, _} = Registry.register(IRC.PubSub, "message", []) + #{:ok, _} = Registry.register(IRC.PubSub, "message:telegram", []) Logger.info("Link handler started") {:ok, %__MODULE__{}} end @@ -66,7 +67,12 @@ defmodule LSG.IRC.LinkPlugin do [uri] -> text [uri | _] -> ["-> #{URI.to_string(uri)}", text] end - message.replyfun.(text) + IO.inspect(text) + if is_list(text) do + for line <- text, do: message.replyfun.(line) + else + message.replyfun.(text) + end _ -> nil end end) @@ -91,8 +97,8 @@ defmodule LSG.IRC.LinkPlugin do # 4. ? # Over five redirections: cancel. - def expand_link([_, _, _, _, _, _ | _]) do - :error + def expand_link(acc = [_, _, _, _, _ | _]) do + {:ok, acc, "link redirects more than five times"} end def expand_link(acc=[uri | _]) do @@ -128,14 +134,14 @@ defmodule LSG.IRC.LinkPlugin do end defp get(url, headers \\ [], options \\ []) do - get_req(:hackney.get(url, headers, <<>>, options)) + get_req(url, :hackney.get(url, headers, <<>>, options)) end - defp get_req({:error, reason}) do + defp get_req(_, {:error, reason}) do {:error, reason} end - defp get_req({:ok, 200, headers, client}) do + 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) @@ -145,14 +151,14 @@ defmodule LSG.IRC.LinkPlugin do cond do String.starts_with?(content_type, "text/html") && length <= 30_000_000 -> - get_body(30_000_000, client, <<>>) + get_body(url, 30_000_000, client, <<>>) true -> :hackney.close(client) {:ok, "file: #{content_type}, size: #{length} bytes"} end end - defp get_req({:ok, redirect, headers, client}) when redirect in 300..399 do + 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) @@ -162,32 +168,70 @@ defmodule LSG.IRC.LinkPlugin do {:redirect, location} end - defp get_req({:ok, status, headers, client}) do + defp get_req(_, {:ok, status, headers, client}) do :hackney.close(client) {:error, status, headers} end - defp get_body(len, client, acc) when len >= byte_size(acc) do + defp get_body(url, len, client, acc) when len >= byte_size(acc) do case :hackney.stream_body(client) do {:ok, data} -> - get_body(len, client, << acc::binary, data::binary >>) + get_body(url, len, client, << acc::binary, data::binary >>) :done -> html = Floki.parse(acc) - title = case Floki.find(html, "title") do - [{"title", [], [title]} | _] -> - String.trim(title) - _ -> - nil + title = collect_title(html) + opengraph = collect_open_graph(html) + itemprops = collect_itemprops(html) + Logger.debug("OG: #{inspect opengraph}") + 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, title} + {:ok, text} {:error, reason} -> {:ok, "failed to fetch body: #{inspect reason}"} end end + defp clean_text(text) do + text + |> String.replace("\n", " ") + |> HtmlEntities.decode() + end + defp get_body(len, client, _acc) do :hackney.close(client) - {:ok, "mais il rentrera jamais en ram ce fichier !"} + {:ok, "Error: file over 30"} end def expand_default(acc = [uri = %URI{scheme: scheme} | _]) when scheme in ["http", "https"] do @@ -201,9 +245,10 @@ defmodule LSG.IRC.LinkPlugin do new_uri = %URI{new_uri | scheme: scheme, authority: uri.authority, host: uri.host, port: uri.port} expand_link([new_uri | acc]) {:error, status, _headers} -> - {:ok, acc, "Error #{status}"} + text = Plug.Conn.Status.reason_phrase(status) + {:ok, acc, "Error: HTTP #{text} (#{status})"} {:error, reason} -> - {:ok, acc, "Error #{to_string(reason)}"} + {:ok, acc, "Error: #{to_string(reason)}"} end end @@ -212,4 +257,47 @@ defmodule LSG.IRC.LinkPlugin do {:ok, [uri], "-> #{URI.to_string(uri)}"} 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 + end diff --git a/lib/lsg_irc/link_plugin/github.ex b/lib/lsg_irc/link_plugin/github.ex new file mode 100644 index 0000000..c7444c2 --- /dev/null +++ b/lib/lsg_irc/link_plugin/github.ex @@ -0,0 +1,44 @@ +defmodule LSG.IRC.LinkPlugin.Github do + @behaviour LSG.IRC.LinkPlugin + + 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 + + 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/reddit_plugin.ex b/lib/lsg_irc/link_plugin/reddit_plugin.ex new file mode 100644 index 0000000..a7f5235 --- /dev/null +++ b/lib/lsg_irc/link_plugin/reddit_plugin.ex @@ -0,0 +1,114 @@ +defmodule LSG.IRC.LinkPlugin.Reddit do + @behaviour LSG.IRC.LinkPlugin + + 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 + + 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 04dea7c..a6b6e29 100644 --- a/lib/lsg_irc/link_plugin/twitter.ex +++ b/lib/lsg_irc/link_plugin/twitter.ex @@ -55,6 +55,17 @@ defmodule LSG.IRC.LinkPlugin.Twitter do {:ok, at} = Timex.parse(tweet.created_at, "%a %b %e %H:%M:%S %z %Y", :strftime) {:ok, format} = Timex.format(at, "{relative}", :relative) + replyto = if tweet.in_reply_to_status_id do + replyurl = "https://twitter.com/#{tweet.in_reply_to_screen_name}/status/#{tweet.in_reply_to_status_id}" + if tweet.in_reply_to_screen_name == tweet.user.screen_name do + "— continued from #{replyurl}" + else + "— replying to #{replyurl}" + end + else + "" + end + quoted = if tweet.quoted_status do quote_url = "https://twitter.com/#{tweet.quoted_status.user.screen_name}/status/#{tweet.quoted_status.id}" full_text = expand_twitter_text(tweet.quoted_status) @@ -66,7 +77,7 @@ defmodule LSG.IRC.LinkPlugin.Twitter do foot = "— #{format} - #{tweet.retweet_count} retweets - #{tweet.favorite_count} likes" - text = ["#{tweet.user.name} (@#{tweet.user.screen_name}):"] ++ text ++ quoted ++ [foot] + text = ["#{tweet.user.name} (@#{tweet.user.screen_name}):", replyto] ++ text ++ quoted ++ [foot] {:ok, text} end diff --git a/lib/lsg_irc/preums_plugin.ex b/lib/lsg_irc/preums_plugin.ex index 98cd539..1f9a76b 100644 --- a/lib/lsg_irc/preums_plugin.ex +++ b/lib/lsg_irc/preums_plugin.ex @@ -6,31 +6,94 @@ defmodule LSG.IRC.PreumsPlugin do * `.preums`: stats des preums """ + 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}, nick, time, perfect, text}, acc) -> + if channel == chan do + [%{date: date, nick: nick, time: time, perfect: perfect, text: text} | acc] + else + acc + end + end + :dets.foldl(fun, [], dets) + end + + def topnicks(dets, channel) do + fun = fn(x = {{chan, date}, nick, _time, _perfect, _text}, acc) -> + if (channel == nil and chan) or (channel == chan) do + count = Map.get(acc, nick, 0) + Map.put(acc, nick, count + 1) + else + acc + end + end + :dets.foldl(fun, %{}, dets) + |> Enum.sort_by(fn({nick, count}) -> count end, &>=/2) + end + def irc_doc, do: @moduledoc def start_link() do GenServer.start_link(__MODULE__, []) end + def dets do + (LSG.data_path() <> "/preums.dets") |> String.to_charlist() + end + def init([]) do + {:ok, _} = Registry.register(IRC.PubSub, "account", []) {:ok, _} = Registry.register(IRC.PubSub, "message", []) {:ok, _} = Registry.register(IRC.PubSub, "triggers", []) - dets_filename = (LSG.data_path() <> "/preums.dets") |> String.to_charlist() - {:ok, dets} = :dets.open_file(dets_filename, []) + {: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{channel: channel, trigger: %IRC.Trigger{type: :bang}}}, state) do + 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(channel) + tz = timezone(channelkey) {:ok, now} = DateTime.now(tz, Tzdata.TimeZoneDatabase) date = {now.year, now.month, now.day} - key = {channel, date} - chan_cache = Map.get(state, channel, %{}) + key = {channelkey, date} + chan_cache = Map.get(state, channelkey, %{}) item = if i = Map.get(chan_cache, date) do i else @@ -49,14 +112,30 @@ defmodule LSG.IRC.PreumsPlugin do end # Stats - def handle_info({:irc, :trigger, "preums", m = %IRC.Message{channel: channel, trigger: %IRC.Trigger{type: :dot}}}, state) do + 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) + |> Enum.map(fn({nick, count}) -> + "#{nick} (#{count})" + end) + |> Enum.filter(fn(x) -> x 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{channel: channel, trigger: %IRC.Trigger{type: :query}}}, state) do + 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 @@ -71,6 +150,43 @@ defmodule LSG.IRC.PreumsPlugin 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, []) channels = Keyword.get(env, :channels, %{}) @@ -79,7 +195,12 @@ defmodule LSG.IRC.PreumsPlugin do Keyword.get(channel_settings, :tz, default) || default end - defp handle_preums(m = %IRC.Message{channel: channel, text: text, sender: sender}, state) do + 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} @@ -89,15 +210,16 @@ defmodule LSG.IRC.PreumsPlugin do case :dets.lookup(state.dets, key) do [item = {^key, _nick, _now, _perfect, _text}] -> # Preums lost, but wasn't cached - state = Map.put(state, channel, %{date => item}) - state - _ -> + Map.put(state, channel, %{date => item}) + [] -> # Preums won! perfect? = Enum.any?(@perfects, fn(perfect) -> Regex.match?(perfect, text) end) - item = {key, sender.nick, now, perfect?, text} + item = {key, m.account.id, now, perfect?, text} :dets.insert(state.dets, item) :dets.sync(state.dets) - state = Map.put(state, channel, %{date => item}) + Map.put(state, channel, %{date => item}) + {:error, _} = error -> + Logger.error("#{__MODULE__} dets lookup failed: #{inspect error}") state end else diff --git a/lib/lsg_irc/quatre_cent_vingt_plugin.ex b/lib/lsg_irc/quatre_cent_vingt_plugin.ex index f6f8a63..db85d49 100644 --- a/lib/lsg_irc/quatre_cent_vingt_plugin.ex +++ b/lib/lsg_irc/quatre_cent_vingt_plugin.ex @@ -36,20 +36,21 @@ defmodule LSG.IRC.QuatreCentVingtPlugin do for coeff <- @coeffs do {:ok, _} = Registry.register(IRC.PubSub, "trigger:#{420*coeff}", []) end - dets_filename = (LSG.data_path() <> "/" <> "420.dets") |> String.to_charlist - {:ok, dets} = :dets.open_file(dets_filename, [{:type,:bag}]) + {:ok, _} = Registry.register(IRC.PubSub, "account", []) + dets_filename = (LSG.data_path() <> "/420.dets") |> String.to_charlist + {:ok, dets} = :dets.open_file(dets_filename, [{:type,:bag},{:repair,:force}]) {:ok, dets} 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.sender.nick) + {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, {String.downcase(m.sender.nick), now+i}) + :ok = :dets.insert(dets, {m.account.id, now+i}) end last_s = if last do last_s = format_relative_timestamp(last) @@ -63,15 +64,56 @@ defmodule LSG.IRC.QuatreCentVingtPlugin do end def handle_info({:irc, :trigger, "420", m = %IRC.Message{trigger: %IRC.Trigger{args: [nick], type: :bang}}}, dets) do - text = case get_statistics_for_nick(dets, nick) 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}" + 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 - m.replyfun.(text) {: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 @@ -86,8 +128,8 @@ defmodule LSG.IRC.QuatreCentVingtPlugin do relative <> detail end - defp get_statistics_for_nick(dets, nick) do - qvc = :dets.lookup(dets, String.downcase(nick)) |> Enum.sort + 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} diff --git a/lib/lsg_irc/say_plugin.ex b/lib/lsg_irc/say_plugin.ex new file mode 100644 index 0000000..6a4f547 --- /dev/null +++ b/lib/lsg_irc/say_plugin.ex @@ -0,0 +1,71 @@ +defmodule LSG.IRC.SayPlugin do + + def irc_doc do + """ + # say + + Say something... + + * **!say `<channel>` `<text>`** say something on `channel` + * **!asay `<channel>` `<text>`** same but anonymously + + You must be a member of the channel. + """ + end + + def start_link() do + GenServer.start_link(__MODULE__, []) + end + + def init([]) do + {:ok, _} = Registry.register(IRC.PubSub, "trigger:say", []) + {:ok, _} = Registry.register(IRC.PubSub, "trigger:asay", []) + {:ok, _} = Registry.register(IRC.PubSub, "message:private", []) + {: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 + user = IRC.UserTrack.find_by_account(net, account) + nick = if(user, do: user.nick, else: account.name) + prefix = if(with_nick?, do: "<#{nick}> ", else: "") + IRC.Connection.broadcast_message(net, chan, "#{prefix}#{text}") + end + end + end + +end diff --git a/lib/lsg_irc/script_plugin.ex b/lib/lsg_irc/script_plugin.ex new file mode 100644 index 0000000..28ae2a7 --- /dev/null +++ b/lib/lsg_irc/script_plugin.ex @@ -0,0 +1,43 @@ +defmodule LSG.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 `<name>` `[command]`** défini/lance un script + * **-script `<name>`** arrête un script + * **-script del `<name>`** supprime un script + """ + + def irc_doc, do: @ircdoc + + def start_link() do + GenServer.start_link(__MODULE__, []) + end + + def init([]) do + {:ok, _} = Registry.register(IRC.PubSub, "trigger:script", []) + dets_filename = (LSG.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/sms_plugin.ex b/lib/lsg_irc/sms_plugin.ex new file mode 100644 index 0000000..60554fb --- /dev/null +++ b/lib/lsg_irc/sms_plugin.ex @@ -0,0 +1,163 @@ +defmodule LSG.IRC.SmsPlugin do + @moduledoc """ + ## sms + + * **!sms `<nick>` `<message>`** 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{ + 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) + } + IO.puts("converted sms to message: #{inspect message}") + IRC.Connection.publish(message, ["message: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__, []) + 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", []) + :ok = register_ovh_callback() + {:ok, %{}} + 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), + "smsResponse" => %{ + "cgiUrl" => LSGWeb.Router.Helpers.sms_url(LSGWeb.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/txt_plugin.ex b/lib/lsg_irc/txt_plugin.ex index ca1be9c..f8c3a29 100644 --- a/lib/lsg_irc/txt_plugin.ex +++ b/lib/lsg_irc/txt_plugin.ex @@ -3,11 +3,11 @@ defmodule LSG.IRC.TxtPlugin do require Logger @moduledoc """ - # [txt](/irc/txt) + # [txt]({{context_path}}/txt) * **.txt**: liste des fichiers et statistiques. Les fichiers avec une `*` sont vérrouillés. - [Voir sur le web](/irc/txt). + [Voir sur le web]({{context_path}}/txt). * **!txt**: lis aléatoirement une ligne dans tous les fichiers. * **!txt `<recherche>`**: recherche une ligne dans tous les fichiers. @@ -56,7 +56,7 @@ defmodule LSG.IRC.TxtPlugin do # def handle_info({:irc, :trigger, "txtrw", msg = %{channel: channel, trigger: %{type: :plus}}}, state = %{rw: false}) do - if channel && UserTrack.operator?(channel, msg.sender.nick) 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 @@ -65,7 +65,7 @@ defmodule LSG.IRC.TxtPlugin do end def handle_info({:irc, :trigger, "txtrw", msg = %{channel: channel, trigger: %{type: :minus}}}, state = %{rw: true}) do - if channel && UserTrack.operator?(channel, msg.sender.nick) 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 @@ -80,7 +80,7 @@ defmodule LSG.IRC.TxtPlugin do def handle_info({:irc, :trigger, "txtlock", msg = %{trigger: %{type: :plus, args: [trigger]}}}, state) do with \ {trigger, _} <- clean_trigger(trigger), - true <- UserTrack.operator?(msg.channel, msg.sender.nick) + true <- UserTrack.operator?(msg.network, msg.channel, msg.sender.nick) do :dets.insert(state.locks, {trigger}) msg.replyfun.("txt: #{trigger} verrouillé") @@ -91,7 +91,7 @@ defmodule LSG.IRC.TxtPlugin do def handle_info({:irc, :trigger, "txtlock", msg = %{trigger: %{type: :minus, args: [trigger]}}}, state) do with \ {trigger, _} <- clean_trigger(trigger), - true <- UserTrack.operator?(msg.channel, msg.sender.nick), + true <- UserTrack.operator?(msg.network, msg.channel, msg.sender.nick), true <- :dets.member(state.locks, trigger) do :dets.delete(state.locks, trigger) @@ -132,7 +132,6 @@ defmodule LSG.IRC.TxtPlugin do def handle_info({:irc, :trigger, "txt", msg = %{trigger: %{type: :bang, args: []}}}, state) do result = Enum.reduce(state.triggers, [], fn({trigger, data}, acc) -> - IO.puts inspect(data) Enum.reduce(data, acc, fn({l, _}, acc) -> [{trigger, l} | acc] end) @@ -141,28 +140,92 @@ defmodule LSG.IRC.TxtPlugin do if !Enum.empty?(result) do {source, line} = Enum.random(result) - msg.replyfun.(format_line(line, "#{source}: ")) + 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, " ") - result = Enum.reduce(state.triggers, [], fn({trigger, data}, acc) -> - Enum.reduce(data, acc, fn({l, _}, acc) -> - [{trigger, l} | acc] + result = with_stateful_results(msg, {:bang,"txt",grep}, fn() -> + Enum.reduce(state.triggers, [], fn({trigger, data}, acc) -> + Enum.reduce(data, acc, fn({l, _}, acc) -> + [{trigger, l} | acc] + end) end) + |> Enum.filter(fn({_, line}) -> String.contains?(String.downcase(line), String.downcase(grep)) end) + |> Enum.shuffle() end) - |> Enum.filter(fn({_, line}) -> String.contains?(String.downcase(line), String.downcase(grep)) end) - |> Enum.shuffle() - if !Enum.empty?(result) do - {source, line} = Enum.random(result) + 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 + IO.puts("Stateful results key is #{inspect key}") + 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 # @@ -209,12 +272,25 @@ defmodule LSG.IRC.TxtPlugin do # 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) + else + LSGWeb.Router.Helpers.irc_url(LSGWeb.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) IO.puts "OPTS : #{inspect {trigger, opts}}" - line = get_random(state.triggers, trigger, String.trim(Enum.join(opts, " "))) + line = get_random(msg, state.triggers, trigger, String.trim(Enum.join(opts, " "))) if line do - msg.replyfun.(format_line(line)) + msg.replyfun.(format_line(line, nil, msg)) end {:noreply, state} end @@ -310,7 +386,7 @@ defmodule LSG.IRC.TxtPlugin do File.write!(directory() <> "/" <> trigger <> ".txt", data<>"\n", []) end - defp get_random(triggers, trigger, []) do + defp get_random(msg, triggers, trigger, []) do if data = Map.get(triggers, trigger) do {data, _idx} = Enum.random(data) data @@ -319,16 +395,16 @@ defmodule LSG.IRC.TxtPlugin do end end - defp get_random(triggers, trigger, opt) do + 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(triggers, trigger, arg) + get_with_param(msg, triggers, trigger, arg) end - defp get_with_param(triggers, trigger, {:index, pos}) do + 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 @@ -336,14 +412,15 @@ defmodule LSG.IRC.TxtPlugin do end end - defp get_with_param(triggers, trigger, {:grep, query}) do - data = Map.get(triggers, trigger, %{}) - regex = Regex.compile!("#{query}", "i") - out = Enum.filter(data, fn({txt, _}) -> Regex.match?(regex, txt) end) - |> Enum.map(fn({txt, _}) -> txt end) - if !Enum.empty?(out) do - Enum.random(out) - 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 @@ -380,7 +457,8 @@ defmodule LSG.IRC.TxtPlugin do {trigger, opts} end - defp format_line(line, prefix \\ "") do + defp format_line(line, prefix, msg) do + prefix = unless(prefix, do: "", else: prefix) prefix <> line |> String.split("\\\\") |> Enum.map(fn(line) -> @@ -389,6 +467,7 @@ defmodule LSG.IRC.TxtPlugin do |> List.flatten() |> Enum.map(fn(line) -> String.trim(line) + |> Tmpl.render(msg) end) end @@ -415,7 +494,7 @@ defmodule LSG.IRC.TxtPlugin do defp can_write?(state = %__MODULE__{rw: rw?, locks: locks}, msg = %{channel: channel, sender: sender}, trigger) do admin? = IRC.admin?(sender) - operator? = IRC.UserTrack.operator?(channel, sender.nick) + operator? = IRC.UserTrack.operator?(msg.network, channel, sender.nick) locked? = case :dets.lookup(locks, trigger) do [{trigger}] -> true _ -> false diff --git a/lib/lsg_irc/untappd_plugin.ex b/lib/lsg_irc/untappd_plugin.ex new file mode 100644 index 0000000..02b22e3 --- /dev/null +++ b/lib/lsg_irc/untappd_plugin.ex @@ -0,0 +1,66 @@ +defmodule LSG.IRC.UntappdPlugin do + + def irc_doc() do + """ + # [Untappd](https://untappd.com) + + * `!beer <beer name>` Information about the first beer matching `<beer name>` + * `?beer <beer name>` List the 10 firsts beer matching `<beer name>` + + _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", []) + {: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_web.ex b/lib/lsg_web.ex index e07d648..f5dee6f 100644 --- a/lib/lsg_web.ex +++ b/lib/lsg_web.ex @@ -17,6 +17,22 @@ defmodule LSGWeb do and import those modules here. """ + def format_chan("#") do + "♯" + end + + def format_chan("#"<>chan) do + chan + end + + def reformat_chan("♯") do + "" + end + + def reformat_chan(chan) do + "#"<>chan + end + def controller do quote do use Phoenix.Controller, namespace: LSGWeb diff --git a/lib/lsg_web/channels/user_socket.ex b/lib/lsg_web/channels/user_socket.ex index 6fedf18..691a26c 100644 --- a/lib/lsg_web/channels/user_socket.ex +++ b/lib/lsg_web/channels/user_socket.ex @@ -5,7 +5,7 @@ defmodule LSGWeb.UserSocket do # channel "room:*", LSGWeb.RoomChannel ## Transports - transport :websocket, Phoenix.Transports.WebSocket + #transport :websocket, Phoenix.Transports.WebSocket # transport :longpoll, Phoenix.Transports.LongPoll # Socket params are passed from the client and can diff --git a/lib/lsg_web/context_plug.ex b/lib/lsg_web/context_plug.ex new file mode 100644 index 0000000..7896ace --- /dev/null +++ b/lib/lsg_web/context_plug.ex @@ -0,0 +1,81 @@ +defmodule LSGWeb.ContextPlug do + import Plug.Conn + import Phoenix.Controller + + def init(opts \\ []) do + opts || [] + end + + def call(conn, opts) do + account = 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 + account + else + _ -> nil + end + + network = Map.get(conn.params, "network") + network = if network == "-", do: nil, else: network + + conns = IRC.Connection.get_network(network) + chan = if c = Map.get(conn.params, "chan") do + LSGWeb.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) + |> 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(:memberships, memberships) + end + end + +end diff --git a/lib/lsg_web/controllers/alcoolog_controller.ex b/lib/lsg_web/controllers/alcoolog_controller.ex index 9d5d9d9..b88faa3 100644 --- a/lib/lsg_web/controllers/alcoolog_controller.ex +++ b/lib/lsg_web/controllers/alcoolog_controller.ex @@ -2,9 +2,12 @@ defmodule LSGWeb.AlcoologController do use LSGWeb, :controller require Logger - def index(conn, %{"channel" => channel}) do - case LSG.Token.lookup(channel) do - {:ok, obj} -> index(conn, obj) + plug LSGWeb.ContextPlug when action not in [:token] + plug LSGWeb.ContextPlug, [restrict: :public] when action in [:token] + + def token(conn, %{"token" => token}) do + case LSG.Token.lookup(token) do + {:ok, {:alcoolog, :index, network, channel}} -> index(conn, nil, network, channel) err -> Logger.debug("AlcoologControler: token #{inspect err} invalid") conn @@ -13,8 +16,31 @@ defmodule LSGWeb.AlcoologController do end end - def index(conn, {:alcoolog, :index, channel}) do - aday = 7*((24 * 60)*60) + def index(conn = %{assigns: %{account: account}}, %{"network" => network, "chan" => channel}) do + index(conn, account, network, LSGWeb.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) + # 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 = 18*((24 * 60)*60) now = DateTime.utc_now() before = now |> DateTime.add(-aday, :second) @@ -27,22 +53,28 @@ defmodule LSGWeb.AlcoologController do ], [:"$_"]} ] - nicks_in_channel = IRC.UserTrack.channel(channel) - |> Enum.map(fn({_, nick, _, _, _, _, _}) -> String.downcase(nick) end) # 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) - #|> Enum.filter(fn({{nick, _}, _, _, _, _}) -> Enum.member?(nicks_in_channel, nick) end) - |> Enum.sort_by(fn({{_, ts}, _, _, _, _}) -> ts end, &>/2) + |> Enum.filter(fn({{account, _}, _, _, _, _}) -> 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(channel) + stats = LSG.IRC.AlcoologPlugin.get_channel_statistics(account, network, channel) - top = Enum.reduce(drinks, %{}, fn({{nick, _}, vol, _, _, _}, acc) -> + 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} - render(conn, "index.html", channel: channel, drinks: drinks, top: top, stats: stats) + # + conn + |> assign(:title, "alcoolog") + |> render("index.html", network: network, channel: channel, drinks: drinks, top: top, stats: stats) end end diff --git a/lib/lsg_web/controllers/irc_auth_sse_controller.ex b/lib/lsg_web/controllers/irc_auth_sse_controller.ex new file mode 100644 index 0000000..c39a866 --- /dev/null +++ b/lib/lsg_web/controllers/irc_auth_sse_controller.ex @@ -0,0 +1,66 @@ +defmodule LSGWeb.IrcAuthSseController do + use LSGWeb, :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, "message: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) + 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 022807d..e0bf24d 100644 --- a/lib/lsg_web/controllers/irc_controller.ex +++ b/lib/lsg_web/controllers/irc_controller.ex @@ -1,13 +1,21 @@ defmodule LSGWeb.IrcController do use LSGWeb, :controller - def index(conn, _) do - commands = for mod <- (Application.get_env(:lsg, :irc)[:plugins] ++ Application.get_env(:lsg, :irc)[:handlers]) do + plug LSGWeb.ContextPlug + + def index(conn, params) do + network = Map.get(params, "network") + channel = if c = Map.get(params, "channel"), do: LSGWeb.reformat_chan(c) + commands = for mod <- ([IRC.Account.AccountPlugin] ++ Application.get_env(:lsg, :irc)[:plugins] ++ Application.get_env(:lsg, :irc)[:handlers]) do identifier = Module.split(mod) |> List.last |> String.replace("Plugin", "") |> Macro.underscore {identifier, mod.irc_doc()} end |> Enum.reject(fn({_, i}) -> i == nil end) - render conn, "index.html", commands: commands + members = cond do + network -> IRC.Membership.expanded_members_or_friends(conn.assigns.account, network, channel) + 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 @@ -33,13 +41,22 @@ defmodule LSGWeb.IrcController do doc = LSG.IRC.TxtPlugin.irc_doc() data = data() lines = Enum.reduce(data, 0, fn({_, lines}, acc) -> acc + Enum.count(lines) end) - render conn, "txts.html", data: data, doc: doc, files: Enum.count(data), lines: lines + conn + |> assign(:title, "txt") + |> render("txts.html", data: data, doc: doc, files: Enum.count(data), lines: lines) 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)}" + true -> "/-" + end if Map.has_key?(data, txt) do - render(conn, "txt.html", name: txt, data: data[txt], doc: nil) + conn + |> assign(:breadcrumbs, [{"txt", "#{base_url}/txt"}]) + |> assign(:title, "#{txt}.txt") + |> render("txt.html", name: txt, data: data[txt], doc: nil) else conn |> put_status(404) diff --git a/lib/lsg_web/controllers/network_controller.ex b/lib/lsg_web/controllers/network_controller.ex new file mode 100644 index 0000000..537c2f6 --- /dev/null +++ b/lib/lsg_web/controllers/network_controller.ex @@ -0,0 +1,11 @@ +defmodule LSGWeb.NetworkController do + use LSGWeb, :controller + plug LSGWeb.ContextPlug + + def index(conn, %{"network" => network}) do + conn + |> assign(:title, network) + |> render("index.html") + end + +end diff --git a/lib/lsg_web/controllers/page_controller.ex b/lib/lsg_web/controllers/page_controller.ex index b356b9c..a87cf1d 100644 --- a/lib/lsg_web/controllers/page_controller.ex +++ b/lib/lsg_web/controllers/page_controller.ex @@ -1,12 +1,35 @@ defmodule LSGWeb.PageController do use LSGWeb, :controller - def index(conn, _params) do - render conn, "index.html" + plug LSGWeb.ContextPlug when action not in [:token] + plug LSGWeb.ContextPlug, [restrict: :public] when action in [:token] + + def token(conn, %{"token" => token}) do + with \ + {:ok, account, perks} <- LSG.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 api(conn, _params) do - render conn, "api.html" + 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 @@ -16,16 +39,15 @@ defmodule LSGWeb.PageController do render conn, "irc.html", bot_helps: bot_helps end - def icecast(conn, _params) do - conn - |> json(LSG.IcecastAgent.get) - end - - def widget(conn, options) do - icecast = LSG.IcecastAgent.get - conn - |> put_layout(false) - |> render("widget.html", icecast: icecast) + 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 new file mode 100644 index 0000000..00c6352 --- /dev/null +++ b/lib/lsg_web/controllers/sms_controller.ex @@ -0,0 +1,10 @@ +defmodule LSGWeb.SmsController do + use LSGWeb, :controller + require Logger + + def ovh_callback(conn, %{"senderid" => from, "message" => message}) do + spawn(fn() -> LSG.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 new file mode 100644 index 0000000..1c3ceb1 --- /dev/null +++ b/lib/lsg_web/controllers/untappd_controller.ex @@ -0,0 +1,18 @@ +defmodule LSGWeb.UntappdController do + use LSGWeb, :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 2d0a6be..e89dc12 100644 --- a/lib/lsg_web/endpoint.ex +++ b/lib/lsg_web/endpoint.ex @@ -1,7 +1,7 @@ defmodule LSGWeb.Endpoint do use Phoenix.Endpoint, otp_app: :lsg - socket "/socket", LSGWeb.UserSocket + socket "/socket", LSGWeb.UserSocket, websocket: true # Serve at "/" the static files from "priv/static" directory. # diff --git a/lib/lsg_web/router.ex b/lib/lsg_web/router.ex index a834083..de7fafb 100644 --- a/lib/lsg_web/router.ex +++ b/lib/lsg_web/router.ex @@ -3,8 +3,8 @@ defmodule LSGWeb.Router do pipeline :browser do plug :accepts, ["html", "txt"] - #plug :fetch_session - #plug :fetch_flash + plug :fetch_session + plug :fetch_flash #plug :protect_from_forgery #plug :put_secure_browser_headers end @@ -13,22 +13,33 @@ defmodule LSGWeb.Router do plug :accepts, ["json", "sse"] end + scope "/api", LSGWeb do + pipe_through :api + get "/irc-auth.sse", IrcAuthSseController, :sse + post "/sms/callback/Ovh", SmsController, :ovh_callback, as: :sms + end + scope "/", LSGWeb do pipe_through :browser get "/", PageController, :index get "/embed/widget", PageController, :widget get "/api", PageController, :api - get "/irc", IrcController, :index - get "/irc/txt", IrcController, :txt - get "/irc/txt/:name", IrcController, :txt - get "/irc/alcoolog/:channel", AlcoologController, :index - get "/irc/alcoolog/~/:nick", AlcoologController, :nick + get "/login/irc/:token", PageController, :token, as: :login + get "/api/untappd/callback", UntappdController, :callback, as: :untappd_callback + get "/-", IrcController, :index + get "/-/txt", IrcController, :txt + get "/-/txt/:name", IrcController, :txt + get "/-/alcoolog", AlcoologController, :index + get "/-/alcoolog/~/:account_name", AlcoologController, :index + get "/:network", NetworkController, :index + get "/:network/:chan", IrcController, :index + get "/:network/:chan/txt", IrcController, :txt + get "/:network/:chan/txt/:name", IrcController, :txt + get "/:network/:channel/preums", IrcController, :preums + get "/:network/:chan/alcoolog", AlcoologController, :index + get "/:network/:chan/alcoolog/t/:token", AlcoologController, :token + get "/:network/alcoolog/~/:nick", AlcoologController, :nick end - scope "/api", LSGWeb do - pipe_through :api - get "/icecast.json", PageController, :icecast - get "/icecast.sse", IcecastSseController, :sse - end end diff --git a/lib/lsg_web/templates/alcoolog/auth.html.eex b/lib/lsg_web/templates/alcoolog/auth.html.eex new file mode 100644 index 0000000..af6db53 --- /dev/null +++ b/lib/lsg_web/templates/alcoolog/auth.html.eex @@ -0,0 +1,39 @@ +<h1>authentication</h1> + +<div id="authenticator"> + <p> + <%= if @bot, do: "Send this to #{@bot} on #{@network}:", else: "Find your bot nickname and send:" %><br /><br /> + <strong>/msg <%= @bot || "the-bot-nickname" %> web</strong> + <br /><br /> + ... then come back to this address. + </p> +</div> + +<script type="text/javascript"> + var authSse = new EventSource("/api/irc-auth.sse?redirect_to=" + window.location.pathname); + authSse.addEventListener("token", function(event) { + html = "<%= if @bot, do: "Send this to #{@bot} on #{@network}:", else: "Find your bot nickname and send:" %><br /><br /><strong>/msg <%= @bot || "the-bot-nickname" %> "+event.data+"</strong>"; + elem = document.getElementById("authenticator"); + elem.innerHTML = html; + }); + authSse.addEventListener("authenticated", function(event) { + elem = document.getElementById("authenticator"); + elem.innerHTML = "<strong>success, redirecting...</strong>"; + window.keepInner = true; + window.location.pathname = event.data; + }); + authSse.addEventListener("expire", function(event) { + elem = document.getElementById("authenticator"); + elem.innerHTML = "<strong>authentication expired</strong>"; + window.keepInner = true; + }); + authSse.onerror = function() { + authSse.close(); + if (!window.keepInner) { + elem = document.getElementById("authenticator"); + elem.innerHTML = "<strong>server closed connection :(</strong>"; + } + }; + +</script> + diff --git a/lib/lsg_web/templates/alcoolog/index.html.eex b/lib/lsg_web/templates/alcoolog/index.html.eex index 507be71..e656e64 100644 --- a/lib/lsg_web/templates/alcoolog/index.html.eex +++ b/lib/lsg_web/templates/alcoolog/index.html.eex @@ -1,16 +1,12 @@ <style type="text/css"> -h1 small { - font-size: 14px; -} ol li { margin-bottom: 5px } </style> -<h1> - <small><a href="/irc"><%= Keyword.get(Application.get_env(:lsg, :irc), :name, "ircbot") %></a> › </small><br/> - alcoolog <%= @channel %> -</h1> +<%= if @stats == [] do %> + </strong><i>:o personne ne boit</i></strong> +<% end %> <ul> <%= for {nick, status} <- @stats do %> @@ -28,28 +24,35 @@ ol li { <% end %> </ul> -<p> - top consommateur par volume, les 7 derniers jours: <%= Enum.intersperse(for({nick, count} <- @top, do: "#{nick}: #{Float.round(count,4)}"), ", ") %> -</p> +<%= if @stats == %{} do %> + <strong><i>... et personne n'a bu :o :o :o</i></strong> +<% else %> + <p> + top consommateur par volume, les 7 derniers jours: <%= Enum.intersperse(for({nick, count} <- @top, do: "#{nick}: #{Float.round(count,4)}"), ", ") %> + </p> -<table class="table"> - <thead> - <tr> - <th scope="col">date</th> - <th scope="col">nick</th> - <th scope="col">points</th> - <th scope="col">nom</th> - </tr> - </thead> - <tbody> - <%= for {{nick, date}, points, _, nom, comment} <- @drinks do %> - <% date = DateTime.from_unix!(date, :millisecond) %> - <th scope="row"><%= LSGWeb.LayoutView.format_time(date, false) %></th> - <td><%= nick %></td> - <td><%= Float.round(points+0.0, 5) %></td> - <td><%= nom||"" %> <%= comment||"" %></td> + <table class="table"> + <thead> + <tr> + <th scope="col">date</th> + <th scope="col">nick</th> + <th scope="col">points</th> + <th scope="col">nom</th> </tr> - <% end %> - </tbody> -</table> + </thead> + <tbody> + <%= for {{{account, date}, points, _, nom, comment}, nick} <- @drinks do %> + <% date = DateTime.from_unix!(date, :millisecond) %> + <th scope="row"><%= LSGWeb.LayoutView.format_time(date, false) %></th> + <td><%= nick %></td> + <td><%= Float.round(points+0.0, 5) %></td> + <td><%= nom||"" %> <%= comment||"" %></td> + </tr> + <% end %> + </tbody> + </table> +<% end %> +<%= if @conn.assigns.account && (@network || @channel) do %> + <%= link("alcoolog global", to: alcoolog_path(@conn, :index)) %> +<% end %> diff --git a/lib/lsg_web/templates/irc/index.html.eex b/lib/lsg_web/templates/irc/index.html.eex index f127101..9e4f724 100644 --- a/lib/lsg_web/templates/irc/index.html.eex +++ b/lib/lsg_web/templates/irc/index.html.eex @@ -1,23 +1,4 @@ <style type="text/css"> -h1 small { - font-size: 14px; -} -</style> - -<h1><%= Keyword.get(Application.get_env(:lsg, :irc), :name, "ircbot") %></h1> - -<!--<p> -Serveur IRC: <a href="irc://irc.quakenet.org/lsg">irc.quakenet.org #lsg</a>. - Matrix: <a href="https://matrix.random.sh/riot/#rooms/#lsg:random.sh">#lsg:random.sh</a>. -<br /> -<a href="https://115ans.net/irc/">Webchat</a>. -<br /> -<a href="/irc/stats/">Statistiques</a>. -</del> -</p>--> - - -<style type="text/css"> .help-entry h1 { font-size: 18px; } @@ -29,15 +10,22 @@ Serveur IRC: <a href="irc://irc.quakenet.org/lsg">irc.quakenet.org #lsg</a>. <p> <% list = for {identifier, _} <- @commands do %> <% name = String.replace(identifier, "_", " ") %> - <%= link(name, to: "##{identifier}") #raw("<a href='##{identifier}'>#{name}</a>") %> + <%= link(name, to: "##{identifier}") %> <% end %> <small><%= Enum.intersperse(list, " - ") %></small> </p> +<%= if @members != [] do %> +<% users = for {_acc, user, nick} <- @members, do: if(user, do: nick, else: content_tag(:i, nick)) %> +<%= for u <- Enum.intersperse(users, ", ") do %> + <%= u %> +<% end %> +<% end %> + <div class="irchelps"> <%= for {identifier, help} <- @commands do %> <%= if help do %> - <div class="help-entry" id="<%= identifier %>"><%= help |> Earmark.as_html! |> raw() %></div> + <div class="help-entry" id="<%= identifier %>"><%= LSGWeb.LayoutView.liquid_markdown(@conn, help) %></div> <% end %> <% end %> </div> diff --git a/lib/lsg_web/templates/irc/txt.html.eex b/lib/lsg_web/templates/irc/txt.html.eex index 5b31320..e9df681 100644 --- a/lib/lsg_web/templates/irc/txt.html.eex +++ b/lib/lsg_web/templates/irc/txt.html.eex @@ -1,16 +1,9 @@ <style type="text/css"> -h1 small { - font-size: 14px; -} ol li { margin-bottom: 5px } </style> -<h1> - <small><a href="/irc"><%= Keyword.get(Application.get_env(:lsg, :irc), :name, "ircbot") %></a> › <a href="/irc/txt">txt</a> ›</small><br/> - <%= @name %>.txt</h1> - <ol> <%= for {txt, id} <- Enum.with_index(@data) do %> <li id="<%= @name %>-<%= id %>"><%= txt %></li> diff --git a/lib/lsg_web/templates/irc/txts.html.eex b/lib/lsg_web/templates/irc/txts.html.eex index 28d0220..5971ffa 100644 --- a/lib/lsg_web/templates/irc/txts.html.eex +++ b/lib/lsg_web/templates/irc/txts.html.eex @@ -5,13 +5,7 @@ .help-entry h2 { font-size: 16px; } -h1 small { - font-size: 14px; -} </style> -<h1> - <small><a href="/irc"><%= Keyword.get(Application.get_env(:lsg, :irc), :name, "ircbot") %></a> › </small><br/> - txt</h1> <p><strong><%= @lines %></strong> lignes dans <strong><%= @files %></strong> fichiers. <a href="#help">Aide</a>. @@ -19,9 +13,13 @@ h1 small { <ul> <%= for {txt, data} <- @data do %> - <li><a href="/irc/txt/<%= txt %>"><%= txt %></a> <i>(<%= Enum.count(data) %>)</i></li> + <% base_url = cond do + @conn.assigns[:chan] -> "/#{@conn.assigns.network}/#{LSGWeb.format_chan(@conn.assigns.chan)}" + true -> "/-" + end %> + <li><a href="<%= base_url %>/txt/<%= txt %>"><%= txt %></a> <i>(<%= Enum.count(data) %>)</i></li> <% end %> </ul> -<div class="help-entry" id="help"><%= @doc |> Earmark.as_html! |> raw() %></div> +<div class="help-entry" id="help"><%= LSGWeb.LayoutView.liquid_markdown(@conn, @doc) %></div> diff --git a/lib/lsg_web/templates/layout/app.html.eex b/lib/lsg_web/templates/layout/app.html.eex index 4c2ad44..1871e8b 100644 --- a/lib/lsg_web/templates/layout/app.html.eex +++ b/lib/lsg_web/templates/layout/app.html.eex @@ -1,16 +1,32 @@ <!DOCTYPE html> <html lang="en"> <head> + <%= page_title(@conn) %> <meta charset="utf-8"> <meta name="robots" content="noindex, nofollow, nosnippet"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="width=device-width, initial-scale=1"> + <meta name="robots" content="noindex, noarchive, nofollow, nosnippet" /> + <title><%= Map.get(assigns, :title, "") %></title> <link rel="stylesheet" href="<%= static_path(@conn, "/assets/css/app.css") %>"> </head> <body> <div class="container"> <main role="main"> + <%= if !@conn.assigns[:no_header] do %> + <h1> + <small style="font-size: 14px;"> + <a href="/"><%= Keyword.get(Application.get_env(:lsg, :irc), :name, "ircbot") %></a> + <%= if @conn.assigns[:account], do: @conn.assigns.account.name %> + › + <%= if n = @conn.assigns[:network] do %><a href="/<%= n %>"><%= n %></a> › <% end %> + <%= if c = @conn.assigns[:chan] do %><a href="/<%= @conn.assigns.network %>/<%= LSGWeb.format_chan(c) %>"><%= c %></a> › <% end %> + <%= for({name, href} <- @conn.assigns[:breadcrumbs]||[], do: [link(name, to: href), raw(" › ")]) %> + </small><br /> + <%= @conn.assigns[:title] || @conn.assigns[:chan] || @conn.assigns[:network] %> + </h1> + <% end %> <%= render @view_module, @view_template, assigns %> </main> </div> diff --git a/lib/lsg_web/templates/network/index.html.eex b/lib/lsg_web/templates/network/index.html.eex new file mode 100644 index 0000000..fc024dd --- /dev/null +++ b/lib/lsg_web/templates/network/index.html.eex @@ -0,0 +1 @@ +pouet diff --git a/lib/lsg_web/templates/page/user.html.eex b/lib/lsg_web/templates/page/user.html.eex new file mode 100644 index 0000000..8a043a0 --- /dev/null +++ b/lib/lsg_web/templates/page/user.html.eex @@ -0,0 +1,38 @@ +<ul> + <li><%= link("Help", to: "/-") %></li> +</ul> + +<h2>channels</h2> +<ul> + <%= for {net, channel} <- @memberships do %> + <li> + <% url = LSGWeb.Router.Helpers.irc_path(LSGWeb.Endpoint, :index, net, LSGWeb.format_chan(channel)) %> + <%= link([net, ": ", content_tag(:strong, channel)], to: url) %> + </li> + <% end %> +</ul> + +<h2>connections</h2> +<ul> + <%= for user <- @users do %> + <li> + <strong><%= user.network %></strong>: <strong><%= user.nick %></strong>!<%= user.username %>@<%= user.host %> <i><%= user.realname %></i><br /> + <%= Enum.join(Enum.intersperse(Enum.map(user.privileges, fn({c, _}) -> c end), ", ")) %> + </li> + <% end %> +</ul> + +<h2>account</h2> +<ul> + <li>account-id: <%= @conn.assigns.account.id %></li> + <%= for {k, v} <- @metas do %> + <li><%= k %>: <%= to_string(v) %></li> + <% end %> +</ul> + +<strong>irc auths:</strong> +<ul> + <%= for {net, {predicate, v}} <- @predicates do %> + <li><%= net %>: <%= to_string(predicate) %>, <%= v %></li> + <% end %> +</ul> diff --git a/lib/lsg_web/views/layout_view.ex b/lib/lsg_web/views/layout_view.ex index 956d703..b28d3c5 100644 --- a/lib/lsg_web/views/layout_view.ex +++ b/lib/lsg_web/views/layout_view.ex @@ -1,20 +1,78 @@ defmodule LSGWeb.LayoutView do use LSGWeb, :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[: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(Application.get_env(:lsg, :irc), :name, "ircbot") + 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) - |> Timezone.convert("Europe/Paris") + |> 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) + + format = cond do + date.year != now.year -> "{D}/{M}/{YYYY} {h24}:{m}" + date.day == now.day -> "{h24}:{m}" + (now_week == date_week) || (date_week == now_last_week && (Date.day_of_week(date) >= now_last_roll)) -> "{WDfull} {h24}:{m}" + (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, detail} = Formatters.Default.lformat(date, "{D}/{M}/{YYYY} {h24}:{m}", "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: detail]) + 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 new file mode 100644 index 0000000..c369ce6 --- /dev/null +++ b/lib/lsg_web/views/network_view.ex @@ -0,0 +1,4 @@ +defmodule LSGWeb.NetworkView do + use LSGWeb, :view + +end diff --git a/lib/tmpl.ex b/lib/tmpl.ex new file mode 100644 index 0000000..cc5ece2 --- /dev/null +++ b/lib/tmpl.ex @@ -0,0 +1,123 @@ +defmodule Tmpl do + require Logger + + defmodule Filter do + use Liquex.Filter + + def repeat(text, val, _) do + String.duplicate(text, val) + end + + def rrepeat(text, max, _) do + String.duplicate(text, :random.uniform(max)) + end + + def rrepeat(text, var) do + rrepeat(text, 20, var) + end + + def bold(text, %{variables: variables}) do + unless Map.get(variables, "_no_format") || Map.get(variables, "_no_bold") do + <<2>> <> text <> <<2>> + else + text + end + end + + @colors [:white, :black, :blue, :green, :red, :brown, :purple, :orange, :yellow, :light_green, :cyan, :light_blue, :pink, :grey, :light_grey] + + for {color, index} <- Enum.with_index(@colors) do + code = 48+index + + def color_code(unquote(color)) do + unquote(code) + end + + def unquote(color)(text, %{variables: variables}) do + unless Map.get(variables, "_no_format") || Map.get(variables, "_no_colors") do + <<3, unquote(code)>> <> text <> <<3>> + else + text + end + end + end + + def account_nick(%{"id" => id, "name" => name}, %{variables: %{"message" => %{"network" => network}}}) do + if user = IRC.UserTrack.find_by_account(network, %IRC.Account{id: id}) do + user.nick + else + name + end + end + + def account_nick(val, ctx) do + "{{account_nick}}" + end + + end + + def render(template, msg = %IRC.Message{}, context \\ %{}, safe \\ true) do + do_render(template, Map.put(context, "message", msg), safe) + end + + defp do_render(template, context, safe) when is_binary(template) do + case Liquex.parse(template) do + {:ok, template_ast} -> + do_render(template_ast, context, safe) + {:error, err, pos} -> + "[liquid ast error (at #{pos}): #{inspect err}]" + end + end + + defp do_render(template_ast, context, safe) when is_list(template_ast) do + context = Liquex.Context.new(mapify(context, safe)) + |> Map.put(:filter_module, Tmpl.Filter) + {content, _context} = Liquex.render(template_ast, context) + to_string(content) + rescue + e -> + Logger.error("Liquid error: #{inspect e}") + "[liquid rendering error]" + end + + defp mapify(struct = %{__struct__: _}, safe) do + mapify(Map.from_struct(struct), safe) + end + + defp mapify(map = %{}, safe) do + map + |> Enum.reduce(Map.new, fn({k,v}, acc) -> + k = to_string(k) + if safe?(k, safe) do + if v = mapify(v, safe) do + Map.put(acc, k, v) + else + acc + end + else + acc + end + end) + end + + defp mapify(fun, _) when is_function(fun) do + nil + end + + defp mapify(atom, _) when is_atom(atom) do + to_string(atom) + end + + defp mapify(v, _) do + v + end + + defp safe?(_, false) do + true + end + + defp safe?("token", true), do: false + defp safe?("password", true), do: false + defp safe?(_, true), do: true +end + diff --git a/lib/untappd.ex b/lib/untappd.ex new file mode 100644 index 0000000..1f78376 --- /dev/null +++ b/lib/untappd.ex @@ -0,0 +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) + "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) + 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 diff --git a/lib/util.ex b/lib/util.ex index f515936..22d1034 100644 --- a/lib/util.ex +++ b/lib/util.ex @@ -10,4 +10,38 @@ defmodule Util do |> Float.parse() end + def ets_mutate_select_each(ets, table, spec, fun) do + ets.safe_fixtable(table, true) + first = ets.select(table, spec, 1) + do_ets_mutate_select_each(ets, table, fun, first) + after + ets.safe_fixtable(table, false) + end + + defp do_ets_mutate_select_each(_, _, _, :'$end_of_table') do + :ok + end + + defp do_ets_mutate_select_each(ets, table, fun, {objs, continuation}) do + for obj <- objs, do: fun.(table, obj) + do_ets_mutate_select_each(ets, table, fun, ets.select(continuation)) + end + + + def ets_mutate_each(ets, table, fun) do + ets.safe_fixtable(table, true) + first = ets.first(table) + do_ets_mutate_each(ets, table, fun, first) + after + ets.safe_fixtable(table, false) + end + + defp do_ets_mutate_each(ets, table, fun, key) do + case ets.lookup(table, key) do + [elem] -> fun.(table, elem) + _ -> nil + end + do_ets_mutate_each(ets, table, fun, ets.next(table, key)) + end + end |