diff --git a/lib/irc/nola_irc.ex b/lib/irc/nola_irc.ex index 2d355d3..7e23f50 100644 --- a/lib/irc/nola_irc.ex +++ b/lib/irc/nola_irc.ex @@ -1,33 +1,33 @@ defmodule Nola.IRC do require Logger def env(), do: Nola.env(:irc) def env(key, default \\ nil), do: Keyword.get(env(), key, default) def application_childs do import Supervisor.Spec IRC.Connection.setup() Nola.Plugins.setup() [ worker(Registry, [[keys: :duplicate, name: IRC.ConnectionPubSub]], id: :registr_irc_conn), - worker(IRC.Membership, []), + worker(Nola.Membership, []), worker(Nola.Account, []), worker(IRC.UserTrack.Storage, []), worker(Nola.Plugins.Account, []), supervisor(Nola.Plugins.Supervisor, [], [name: Nola.Plugins.Supervisor]), supervisor(IRC.Connection.Supervisor, [], [name: IRC.Connection.Supervisor]), supervisor(IRC.PuppetConnection.Supervisor, [], [name: IRC.PuppetConnection.Supervisor]), ] end # Start plugins first to let them get on connection events. def after_start() do Logger.info("Starting plugins") Nola.Plugins.start_all() Logger.info("Starting connections") IRC.Connection.start_all() end end diff --git a/lib/irc/user_track.ex b/lib/irc/user_track.ex index 3f144d5..56a319f 100644 --- a/lib/irc/user_track.ex +++ b/lib/irc/user_track.ex @@ -1,329 +1,329 @@ defmodule IRC.UserTrack do @moduledoc """ User Track DB & Utilities """ @ets IRC.UserTrack.Storage # {uuid, network, nick, nicks, privilege_map} # Privilege map: # %{"#channel" => [:operator, :voice] defmodule Storage do def delete(id) do op(fn(ets) -> :ets.delete(ets, id) end) end def insert(tuple) do op(fn(ets) -> :ets.insert(ets, tuple) end) end def clear_network(network) do op(fn(ets) -> spec = [ {{:_, :"$1", :_, :_, :_, :_, :_, :_, :_, :_, :_, :_}, [ {:==, :"$1", {:const, network}} ], [:"$_"]} ] :ets.match_delete(ets, spec) end) end def op(fun) do GenServer.call(__MODULE__, {:op, fun}) end def start_link do GenServer.start_link(__MODULE__, [], [name: __MODULE__]) end def init([]) do ets = :ets.new(__MODULE__, [:set, :named_table, :protected, {:read_concurrency, true}]) {:ok, ets} end def handle_call({:op, fun}, _from, ets) do returned = try do {:ok, fun.(ets)} rescue rescued -> {:error, rescued} catch rescued -> {:error, rescued} end {:reply, returned, ets} end def terminate(_reason, ets) do :ok end end defmodule Id, do: use EntropyString defmodule User do defstruct [:id, :account, :network, :nick, {:nicks, []}, :username, :host, :realname, {:privileges, %{}}, {:last_active, %{}}, {:options, %{}}] def to_tuple(u = %__MODULE__{}) do {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, u.options} end #tuple size: 11 def from_tuple({id, network, account, _downcased_nick, nick, nicks, username, host, realname, privs, last_active, opts}) do struct = %__MODULE__{id: id, account: account, network: network, nick: nick, nicks: nicks, username: username, host: host, realname: realname, privileges: privs, last_active: last_active, options: opts} end end def find_by_account(%Nola.Account{id: id}) do #iex(15)> :ets.fun2ms(fn(obj = {_, net, acct, _, _, _, _, _, _}) when net == network and acct == account -> obj end) spec = [ {{:_, :_, :"$2", :_, :_, :_, :_, :_, :_, :_, :_, :_}, [ {:==, :"$2", {:const, id}} ], [:"$_"]} ] results = :ets.select(@ets, spec) |> Enum.filter(& &1) for obj <- results, do: User.from_tuple(obj) end def find_by_account(network, nil) do nil end def find_by_account(network, %Nola.Account{id: id}) do #iex(15)> :ets.fun2ms(fn(obj = {_, net, acct, _, _, _, _, _, _}) when net == network and acct == account -> obj end) spec = [ {{:_, :"$1", :"$2", :_, :_, :_, :_, :_, :_, :_, :_, :_}, [ {:andalso, {:==, :"$1", {:const, network}}, {:==, :"$2", {:const, id}}} ], [:"$_"]} ] case :ets.select(@ets, spec) do results = [_r | _] -> result = results |> Enum.reject(fn({_, net, _, _, _, _, _, _, _, _, actives, opts}) -> network != "matrix" && net == "matrix" end) |> Enum.reject(fn({_, net, _, _, _, _, _, _, _, _, actives, opts}) -> network != "telegram" && net == "telegram" end) |> Enum.reject(fn({_, _, _, _, _, _, _, _, _, _, actives, opts}) -> network not in ["matrix", "telegram"] && Map.get(opts, :puppet) end) |> Enum.sort_by(fn({_, _, _, _, _, _, _, _, _, _, actives, _}) -> Map.get(actives, nil) end, {:desc, NaiveDateTime}) |> List.first if result, do: User.from_tuple(result) _ -> nil end end def clear_network(network) do Storage.clear_network(network) end def merge_account(old_id, new_id) do #iex(15)> :ets.fun2ms(fn(obj = {_, net, acct, _, _, _, _, _, _}) when net == network and acct == account -> obj end) spec = [ {{:_, :_, :"$1", :_, :_, :_, :_, :_, :_, :_, :_, :_}, [ {:==, :"$1", {:const, old_id}} ], [:"$_"]} ] Enum.each(:ets.select(@ets, spec), fn({id, net, _, downcased_nick, nick, nicks, username, host, realname, privs, active, opts}) -> Storage.op(fn(ets) -> :ets.insert(@ets, {id, net, new_id, downcased_nick, nick, nicks, username, host, realname, privs, active, opts}) 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 case :ets.lookup(@ets, id) do [] -> nil [tuple] -> User.from_tuple(tuple) end end def operator?(network, channel, nick) do if user = find_by_nick(network, nick) do privs = Map.get(user.privileges, channel, []) Enum.member?(privs, :admin) || Enum.member?(privs, :operator) else false end end def channel(network, channel) do Enum.filter(to_list(), fn({_, network, _, _, _, _, _, _, _, channels, _, _}) -> Map.get(channels, channel) end) end # TODO def connected(network, nick, user, host, account_id, opts \\ %{}) do if account = Nola.Account.get(account_id) do user = if user = find_by_nick(network, nick) do user else user = %User{id: IRC.UserTrack.Id.large_id, account: account_id, network: network, nick: nick, username: user, host: host, privileges: %{}, options: opts} Storage.op(fn(ets) -> :ets.insert(ets, User.to_tuple(user)) end) user end IRC.Connection.publish_event(network, %{type: :connect, user_id: user.id, account_id: user.account}) :ok else :error end end def joined(c, s), do: joined(c,s,[]) def joined(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(sender.network, nick) do %User{user | username: uname, host: host, privileges: Map.put(user.privileges || %{}, channel, privileges)} else user = %User{id: IRC.UserTrack.Id.large_id, network: sender.network, nick: nick, username: uname, host: host, privileges: %{channel => privileges}} account = Nola.Account.lookup(user).id user = %User{user | account: account} end user = touch_struct(user, channel) if touch && user.account do - IRC.Membership.touch(user.account, sender.network, channel) + Nola.Membership.touch(user.account, sender.network, channel) end Storage.op(fn(ets) -> :ets.insert(ets, User.to_tuple(user)) end) IRC.Connection.publish_event({sender.network, channel}, %{type: :join, user_id: user.id, account_id: user.account}) user end #def joined(network, channel, nick, privileges) do # 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 || Nola.Account.lookup(user)} else user = %User{network: network, nick: nick, privileges: %{}} account = Nola.Account.lookup(user) {%User{user | account: account.id}, account} end Storage.insert(User.to_tuple(user)) - if chan, do: IRC.Membership.touch(account, network, chan) + if chan, do: Nola.Membership.touch(account, network, chan) if !m.account do {:ok, %IRC.Message{m | account: account}} else :ok end end def renamed(network, old_nick, new_nick) do if user = find_by_nick(network, old_nick) do old_account = Nola.Account.lookup(user) user = %User{user | nick: new_nick, nicks: [old_nick|user.nicks]} account = Nola.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)) channels = for {channel, _} <- user.privileges, do: channel IRC.Connection.publish_event(network, %{type: :nick, user_id: user.id, account_id: account.id, nick: new_nick, old_nick: old_nick}) end end def change_privileges(network, channel, nick, {add, remove}) do if user = find_by_nick(network, nick) do privs = Map.get(user.privileges, channel) privs = Enum.reduce(add, privs, fn(priv, acc) -> [priv|acc] end) privs = Enum.reduce(remove, privs, fn(priv, acc) -> List.delete(acc, priv) end) user = %User{user | privileges: Map.put(user.privileges, channel, privs)} Storage.insert(User.to_tuple(user)) IRC.Connection.publish_event({network, channel}, %{type: :privileges, user_id: user.id, account_id: user.account, added: add, removed: remove}) end end # XXX: Reason def parted(channel, %{network: network, nick: nick}) do parted(network, channel, nick) end def parted(network, channel, nick) do if user = find_by_nick(network, nick) do if user.account do - IRC.Membership.touch(user.account, network, channel) + Nola.Membership.touch(user.account, network, channel) end privs = Map.delete(user.privileges, channel) lasts = Map.delete(user.last_active, channel) if Enum.count(privs) > 0 do user = %User{user | privileges: privs} Storage.insert(User.to_tuple(user)) IRC.Connection.publish_event({network, channel}, %{type: :part, user_id: user.id, account_id: user.account, reason: nil}) else IRC.Connection.publish_event(network, %{type: :quit, user_id: user.id, account_id: user.account, reason: "Left all known channels"}) Storage.delete(user.id) end end end def quitted(sender, reason) do if user = find_by_nick(sender.network, sender.nick) do if user.account do for {channel, _} <- user.privileges do - IRC.Membership.touch(user.account, sender.network, channel) + Nola.Membership.touch(user.account, sender.network, channel) end IRC.Connection.publish_event(sender.network, %{type: :quit, user_id: user.id, account_id: user.account, reason: reason}) end Storage.delete(user.id) end end defp touch_struct(user = %User{last_active: last_active}, channel) do now = NaiveDateTime.utc_now() last_active = last_active |> Map.put(channel, now) |> Map.put(nil, now) %User{user | last_active: last_active} end defp userchans(%{privileges: privileges}) do for({chan, _} <- privileges, do: chan) end end diff --git a/lib/matrix/room.ex b/lib/matrix/room.ex index 4c5cf7e..57f35b8 100644 --- a/lib/matrix/room.ex +++ b/lib/matrix/room.ex @@ -1,196 +1,196 @@ defmodule Nola.Matrix.Room do require Logger alias Nola.Matrix alias Polyjuice.Client import Matrix, only: [client: 0, client: 1, user_name: 1, myself?: 1] defmodule Supervisor do use DynamicSupervisor def start_link() do DynamicSupervisor.start_link(__MODULE__, [], name: __MODULE__) end def start_child(room_id) do spec = %{id: room_id, start: {Nola.Matrix.Room, :start_link, [room_id]}, restart: :transient} DynamicSupervisor.start_child(__MODULE__, spec) end @impl true def init(_init_arg) do DynamicSupervisor.init( strategy: :one_for_one, max_restarts: 10, max_seconds: 1 ) end end def start(room_id) do __MODULE__.Supervisor.start_child(room_id) end def start_link(room_id) do GenServer.start_link(__MODULE__, [room_id], name: name(room_id)) end def start_and_send_matrix_event(room_id, event) do pid = if pid = whereis(room_id) do pid else case __MODULE__.start(room_id) do {:ok, pid} -> pid {:error, {:already_started, pid}} -> pid :ignore -> nil end end if(pid, do: send(pid, {:matrix_event, event})) end def whereis(room_id) do {:global, name} = name(room_id) case :global.whereis_name(name) do :undefined -> nil pid -> pid end end def name(room_id) do {:global, {__MODULE__, room_id}} end def init([room_id]) do case Matrix.lookup_room(room_id) do {:ok, state} -> Logger.metadata(matrix_room: room_id) {:ok, _} = Registry.register(Nola.PubSub, "#{state.network}:events", plugin: __MODULE__) for t <- ["messages", "triggers", "outputs", "events"] do {:ok, _} = Registry.register(Nola.PubSub, "#{state.network}/#{state.channel}:#{t}", plugin: __MODULE__) end state = state |> Map.put(:id, room_id) Logger.info("Started Matrix room #{room_id}") {:ok, state, {:continue, :update_state}} error -> Logger.info("Received event for nonexistent room #{inspect room_id}: #{inspect error}") :ignore end end def handle_continue(:update_state, state) do {:ok, s} = Client.Room.get_state(client(), state.id) members = Enum.reduce(s, [], fn(s, acc) -> if s["type"] == "m.room.member" do if s["content"]["membership"] == "join" do [s["user_id"] | acc] else - # XXX: The user left, remove from IRC.Memberships ? + # XXX: The user left, remove from Nola.Memberships ? acc end else acc end end) |> Enum.filter(& &1) for m <- members, do: IRC.UserTrack.joined(state.id, %{network: "matrix", nick: m, user: m, host: "matrix."}, [], true) accounts = IRC.UserTrack.channel(state.network, state.channel) |> Enum.filter(& &1) |> Enum.map(fn(tuple) -> IRC.UserTrack.User.from_tuple(tuple).account end) |> Enum.uniq() |> Enum.each(fn(account_id) -> introduce_irc_account(account_id, state) end) {:noreply, state} end def handle_info({:irc, :text, message}, state), do: handle_irc(message, state) def handle_info({:irc, :out, message}, state), do: handle_irc(message, state) def handle_info({:irc, :trigger, _, message}, state), do: handle_irc(message, state) def handle_info({:irc, :event, event}, state), do: handle_irc(event, state) def handle_info({:matrix_event, event}, state) do if myself?(event.user_id) do {:noreply, state} else handle_matrix(event, state) end end def handle_irc(message = %IRC.Message{account: account}, state) do unless Map.get(message.meta, :puppet) && Map.get(message.meta, :from) == self() do opts = if Map.get(message.meta, :self) || is_nil(account) do [] else mxid = Matrix.get_or_create_matrix_user(account.id) [user_id: mxid] end Client.Room.send_message(client(opts),state.id, message.text) end {:noreply, state} end def handle_irc(%{type: :join, account_id: account_id}, state) do introduce_irc_account(account_id, state) {:noreply, state} end def handle_irc(%{type: quit_or_part, account_id: account_id}, state) when quit_or_part in [:quit, :part] do mxid = Matrix.get_or_create_matrix_user(account_id) Client.Room.leave(client(user_id: mxid), state.id) {:noreply, state} end def handle_irc(event, state) do Logger.warn("Skipped irc event #{inspect event}") {:noreply, state} end def handle_matrix(event = %{type: "m.room.member", user_id: user_id, content: %{"membership" => "join"}}, state) do _account = get_account(event, state) IRC.UserTrack.joined(state.id, %{network: "matrix", nick: user_id, user: user_id, host: "matrix."}, [], true) {:noreply, state} end def handle_matrix(event = %{type: "m.room.member", user_id: user_id, content: %{"membership" => "leave"}}, state) do IRC.UserTrack.parted(state.id, %{network: "matrix", nick: user_id}) {:noreply, state} end def handle_matrix(event = %{type: "m.room.message", user_id: user_id, content: %{"msgtype" => "m.text", "body" => text}}, state) do IRC.send_message_as(get_account(event, state), state.network, state.channel, text, true) {:noreply, state} end def handle_matrix(event, state) do Logger.warn("Skipped matrix event #{inspect event}") {:noreply, state} end def get_account(%{user_id: user_id}, %{id: id}) do Nola.Account.find_by_nick("matrix", user_id) end defp introduce_irc_account(account_id, state) do mxid = Matrix.get_or_create_matrix_user(account_id) account = Nola.Account.get(account_id) user = IRC.UserTrack.find_by_account(state.network, account) base_nick = if(user, do: user.nick, else: account.name) case Client.Profile.put_displayname(client(user_id: mxid), base_nick) do :ok -> :ok error -> Logger.warn("Failed to update profile for #{mxid}: #{inspect error}") end case Client.Room.join(client(user_id: mxid), state.id) do {:ok, _} -> :ok error -> Logger.warn("Failed to join room for #{mxid}: #{inspect error}") end :ok end end diff --git a/lib/nola/account.ex b/lib/nola/account.ex index cd424ef..4f1e7ff 100644 --- a/lib/nola/account.ex +++ b/lib/nola/account.ex @@ -1,263 +1,263 @@ defmodule Nola.Account do alias IRC.UserTrack.User @moduledoc """ Account registry.... Maps a network predicate: * `{net, {:nick, nickname}}` * `{net, {:account, account}}` * `{net, {:mask, user@host}}` to an unique identifier, that can be shared over multiple networks. If a predicate cannot be found for an existing account, a new account will be made in the database. To link two existing accounts from different network onto a different one, a merge operation is provided. """ # FIXME: Ensure uniqueness of name? @derive {Poison.Encoder, except: [:token]} defstruct [:id, :name, :token] @type t :: %__MODULE__{id: id(), name: String.t()} @type id :: String.t() defimpl Inspect, for: __MODULE__ do import Inspect.Algebra def inspect(%{id: id, name: name}, opts) do concat(["#Nola.Account[", id, " ", name, "]"]) end end def file(base) do to_charlist(Nola.data_path() <> "/account_#{base}.dets") end defp from_struct(%__MODULE__{id: id, name: name, token: token}) do {id, name, token} end defp from_tuple({id, name, token}) do %__MODULE__{id: id, name: name, token: token} end def start_link() do GenServer.start_link(__MODULE__, [], [name: __MODULE__]) end def init(_) do {:ok, accounts} = :dets.open_file(file("db"), []) {:ok, meta} = :dets.open_file(file("meta"), []) {:ok, predicates} = :dets.open_file(file("predicates"), [{:type, :set}]) {:ok, %{accounts: accounts, meta: meta, predicates: predicates}} end def get(id) do case :dets.lookup(file("db"), id) do [account] -> from_tuple(account) _ -> nil end end def get_by_name(name) do spec = [{{:_, :"$1", :_}, [{:==, :"$1", {:const, name}}], [:"$_"]}] case :dets.select(file("db"), spec) do [account] -> from_tuple(account) _ -> nil end end def get_meta(%__MODULE__{id: id}, key, default \\ nil) do case :dets.lookup(file("meta"), {id, key}) do [{_, value}] -> (value || default) _ -> default end end @spec find_meta_accounts(String.t()) :: [{account :: t(), value :: String.t()}, ...] @doc "Find all accounts that have a meta of `key`." def find_meta_accounts(key) do spec = [{{{:"$1", :"$2"}, :"$3"}, [{:==, :"$2", {:const, key}}], [{{:"$1", :"$3"}}]}] for {id, val} <- :dets.select(file("meta"), spec), do: {get(id), val} end @doc "Find an account given a specific meta `key` and `value`." @spec find_meta_account(String.t(), String.t()) :: t() | nil def find_meta_account(key, value) do #spec = [{{{:"$1", :"$2"}, :"$3"}, [:andalso, {:==, :"$2", {:const, key}}, {:==, :"$3", {:const, value}}], [:"$1"]}] spec = [{{{:"$1", :"$2"}, :"$3"}, [{:andalso, {:==, :"$2", {:const, key}}, {:==, {:const, value}, :"$3"}}], [:"$1"]}] case :dets.select(file("meta"), spec) do [id] -> get(id) _ -> nil end end def get_all_meta(%__MODULE__{id: id}) do spec = [{{{:"$1", :"$2"}, :"$3"}, [{:==, :"$1", {:const, id}}], [{{:"$2", :"$3"}}]}] :dets.select(file("meta"), spec) end def put_user_meta(account = %__MODULE__{}, key, value) do put_meta(account, "u:"<>key, value) end def put_meta(%__MODULE__{id: id}, key, value) do :dets.insert(file("meta"), {{id, key}, value}) end def delete_meta(%__MODULE__{id: id}, key) do :dets.delete(file("meta"), {id, key}) end def all_accounts() do :dets.traverse(file("db"), fn(obj) -> {:continue, from_tuple(obj)} end) end def all_predicates() do :dets.traverse(file("predicates"), fn(obj) -> {:continue, obj} end) end def all_meta() do :dets.traverse(file("meta"), fn(obj) -> {:continue, obj} end) end def merge_account(old_id, new_id) do if old_id != new_id do spec = [{{:"$1", :"$2"}, [{:==, :"$2", {:const, old_id}}], [:"$1"]}] predicates = :dets.select(file("predicates"), spec) for pred <- predicates, do: :ok = :dets.insert(file("predicates"), {pred, new_id}) spec = [{{{:"$1", :"$2"}, :"$3"}, [{:==, :"$1", {:const, old_id}}], [{{:"$2", :"$3"}}]}] metas = :dets.select(file("meta"), spec) for {k,v} <- metas do :dets.delete(file("meta"), {{old_id, k}}) :ok = :dets.insert(file("meta"), {{new_id, k}, v}) end :dets.delete(file("db"), old_id) - IRC.Membership.merge_account(old_id, new_id) + Nola.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) + memberships = Nola.Membership.of_account(account) if Enum.any?(memberships, fn({net, ch}) -> (net == network) or (chan && chan == ch) end) do account else nil end end end def find(something) do do_lookup(something, false) end def lookup(something, make_default \\ true) do account = do_lookup(something, make_default) if account && Map.get(something, :nick) do IRC.Connection.dispatch("account", {:account_auth, Map.get(something, :nick), account.id}) end account end def handle_info(_, state) do {:noreply, state} end def handle_cast(_, state) do {:noreply, state} end def handle_call(_, _, state) do {:noreply, state} end def terminate(_, state) do for {_, dets} <- state do :dets.sync(dets) :dets.close(dets) end end defp do_lookup(message = %IRC.Message{account: account_id}, make_default) when is_binary(account_id) do get(account_id) end defp do_lookup(sender = %ExIRC.Who{}, make_default) do if user = IRC.UserTrack.find_by_nick(sender) do lookup(user, make_default) else #FIXME this will never work with continued lookup by other methods as Who isn't compatible lookup_by_nick(sender, :dets.lookup(file("predicates"), {sender.network,{:nick, sender.nick}}), make_default) end end defp do_lookup(sender = %ExIRC.SenderInfo{}, make_default) do lookup(IRC.UserTrack.find_by_nick(sender), make_default) end defp do_lookup(user = %User{account: id}, make_default) when is_binary(id) do get(id) end defp do_lookup(user = %User{network: server, nick: nick}, make_default) do lookup_by_nick(user, :dets.lookup(file("predicates"), {server,{:nick, nick}}), make_default) end defp do_lookup(nil, _) do nil end defp lookup_by_nick(_, [{_, id}], _make_default) do get(id) end defp lookup_by_nick(user, _, make_default) do #authenticate_by_host(user) if make_default, do: new_account(user), else: nil end def new_account(nick) do id = EntropyString.large_id() :dets.insert(file("db"), {id, nick, EntropyString.token()}) get(id) end def new_account(%{nick: nick, network: server}) do id = EntropyString.large_id() :dets.insert(file("db"), {id, nick, EntropyString.token()}) :dets.insert(file("predicates"), {{server, {:nick, nick}}, id}) get(id) end def update_account_name(account = %__MODULE__{id: id}, name) do account = %__MODULE__{account | name: name} :dets.insert(file("db"), from_struct(account)) get(id) end def get_predicates(%__MODULE__{} = account) do spec = [{{:"$1", :"$2"}, [{:==, :"$2", {:const, account.id}}], [:"$1"]}] :dets.select(file("predicates"), spec) end end diff --git a/lib/irc/membership.ex b/lib/nola/membership.ex similarity index 99% rename from lib/irc/membership.ex rename to lib/nola/membership.ex index 25a0cfc..b98efd7 100644 --- a/lib/irc/membership.ex +++ b/lib/nola/membership.ex @@ -1,129 +1,129 @@ -defmodule IRC.Membership do +defmodule Nola.Membership do @moduledoc """ Memberships (users in channels) """ # Key: {account, net, channel} # Format: {key, last_seen} defp dets() do to_charlist(Nola.data_path <> "/memberships.dets") 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(%Nola.Account{id: id}) do spec = [{{{:"$1", :"$2", :"$3"}, :_}, [{:==, :"$1", {:const, id}}], [{{:"$2", :"$3"}}]}] :dets.select(dets(), spec) end def merge_account(old_id, new_id) do #iex(37)> :ets.fun2ms(fn({{old_id, _, _}, _}=obj) when old_id == "42" -> obj end) 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(%Nola.Account{id: id}, network, channel) do :dets.insert(dets(), {{id, network, channel}, NaiveDateTime.utc_now()}) end def touch(account_id, network, channel) do if account = Nola.Account.get(account_id) do touch(account, network, channel) end end def notify_channels(account, minutes \\ 30, last_active \\ true) do not_before = NaiveDateTime.add(NaiveDateTime.utc_now(), (minutes*-60), :second) 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 = %Nola.Account{id: id}) do for({net, chan} <- of_account(account), do: members(net, chan)) |> List.flatten() |> Enum.uniq() end def handle_info(_, dets) do {:noreply, dets} end def handle_cast(_, dets) do {:noreply, dets} end def handle_call(_, _, dets) do {:noreply, dets} end def terminate(_, dets) do :dets.sync(dets) :dets.close(dets) end defp expand(network, list) do for id <- list do if account = Nola.Account.get(id) do user = 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/plugins/alcoolog.ex b/lib/plugins/alcoolog.ex index 9958889..738be71 100644 --- a/lib/plugins/alcoolog.ex +++ b/lib/plugins/alcoolog.ex @@ -1,1229 +1,1229 @@ defmodule Nola.Plugins.Alcoolog do require Logger @moduledoc """ # [alcoolog]({{context_path}}/alcoolog) * **!santai `` ` [annotation]`**: enregistre un nouveau verre de `montant` d'une boisson à `degrés d'alcool`. * **!santai `` ``**: enregistre un nouveau verre de `cl` de la bière `beer name`, et checkin sur Untappd.com. * **!moar `[cl]` : enregistre un verre équivalent au dernier !santai. * **-santai**: annule la dernière entrée d'alcoolisme. * **.alcoolisme**: état du channel en temps réel. * **.alcoolisme ``**: points par jour, sur X j. * **!alcoolisme `[pseudo]`**: affiche les points d'alcoolisme. * **!alcoolisme `[pseudo]` ``**: affiche les points d'alcoolisme par jour sur X j. * **+alcoolisme `` `` `[facteur de perte en mg/l (10, 15, 20, 25)]`**: Configure votre profil d'alcoolisme. * **.sobre**: affiche quand la sobriété frappera sur le chan. * **!sobre `[pseudo]`**: affiche quand la sobriété frappera pour `[pseudo]`. * **!sobrepour ``**: affiche tu pourras être sobre pour ``, et si oui, combien de volumes d'alcool peuvent encore être consommés. * **!alcoolog**: ([voir]({{context_path}}/alcoolog)) lien pour voir l'état/statistiques et historique de l'alcoolémie du channel. * **!alcool `` ``**: donne le nombre d'unités d'alcool dans `` à `°`. * **!soif**: c'est quand l'apéro ? 1 point = 1 volume d'alcool. Annotation: champ libre! --- ## `!txt`s * status utilisateur: `alcoolog.user_(sober|legal|legalhigh|high|toohigh|sick)(|_rising)` * mauvaises boissons: `alcoolog.drink_(negative|zero|negative)` * santo: `alcoolog.santo` * santai: `alcoolog.santai` * plus gros, moins gros: `alcoolog.(fatter|thinner)` """ def irc_doc, do: @moduledoc def start_link(), do: GenServer.start_link(__MODULE__, [], name: __MODULE__) # tuple dets: {nick, date, volumes, current_alcohol_level, nom, commentaire} # tuple ets: {{nick, date}, volumes, current, nom, commentaire} # tuple meta dets: {nick, map} # %{:weight => float, :sex => true(h),false(f)} @pubsub ~w(account) @pubsub_triggers ~w(santai moar again bis santo santeau alcoolog sobre sobrepour soif alcoolisme alcool) @default_user_meta %{weight: 77.4, sex: true, loss_factor: 15} def data_state() do dets_filename = (Nola.data_path() <> "/" <> "alcoolisme.dets") |> String.to_charlist dets_meta_filename = (Nola.data_path() <> "/" <> "alcoolisme_meta.dets") |> String.to_charlist %{dets: dets_filename, meta: dets_meta_filename, ets: __MODULE__.ETS} end def init(_) do triggers = for(t <- @pubsub_triggers, do: "trigger:"<>t) for sub <- @pubsub ++ triggers do {:ok, _} = Registry.register(Nola.PubSub, sub, plugin: __MODULE__) end dets_filename = (Nola.data_path() <> "/" <> "alcoolisme.dets") |> String.to_charlist {:ok, dets} = :dets.open_file(dets_filename, [{:type,:bag}]) ets = :ets.new(__MODULE__.ETS, [:ordered_set, :named_table, :protected, {:read_concurrency, true}]) dets_meta_filename = (Nola.data_path() <> "/" <> "alcoolisme_meta.dets") |> String.to_charlist {:ok, meta} = :dets.open_file(dets_meta_filename, [{:type,:set}]) traverse_fun = fn(obj, dets) -> case obj do object = {nick, naive = %NaiveDateTime{}, volumes, active, cl, deg, name, comment} -> date = naive |> DateTime.from_naive!("Etc/UTC") |> DateTime.to_unix() new = {nick, date, volumes, active, cl, deg, name, comment, Map.new()} :dets.delete_object(dets, object) :dets.insert(dets, new) :ets.insert(ets, {{nick, date}, volumes, active, cl, deg, name, comment, Map.new()}) dets object = {nick, naive = %NaiveDateTime{}, volumes, active, cl, deg, name, comment, meta} -> date = naive |> DateTime.from_naive!("Etc/UTC") |> DateTime.to_unix() new = {nick, date, volumes, active, cl, deg, name, comment, Map.new()} :dets.delete_object(dets, object) :dets.insert(dets, new) :ets.insert(ets, {{nick, date}, volumes, active, cl, deg, name, comment, Map.new()}) dets object = {nick, date, volumes, active, cl, deg, name, comment, meta} -> :ets.insert(ets, {{nick, date}, volumes, active, cl, deg, name, comment, meta}) dets _ -> dets end end :dets.foldl(traverse_fun, dets, dets) :dets.sync(dets) state = %{dets: dets, meta: meta, ets: ets} {:ok, state} end @eau ["santo", "santeau"] def handle_info({:irc, :trigger, santeau, m = %IRC.Message{trigger: %IRC.Trigger{args: _, type: :bang}}}, state) when santeau in @eau do Nola.Plugins.Txt.reply_random(m, "alcoolog.santo") {:noreply, state} end def handle_info({:irc, :trigger, "soif", m = %IRC.Message{trigger: %IRC.Trigger{args: _, type: :bang}}}, state) do now = DateTime.utc_now() |> Timex.Timezone.convert("Europe/Paris") apero = format_duration_from_now(%DateTime{now | hour: 18, minute: 0, second: 0}, false) day_of_week = Date.day_of_week(now) {txt, apero?} = cond do now.hour >= 0 && now.hour < 6 -> {["apéro tardif ? Je dis OUI ! SANTAI !"], true} now.hour >= 6 && now.hour < 12 -> if day_of_week >= 6 do {["de l'alcool pour le petit dej ? le week-end, pas de problème !"], true} else {["C'est quand même un peu tôt non ? Prochain apéro #{apero}"], false} end now.hour >= 12 && (now.hour < 14) -> {["oui! c'est l'apéro de midi! (et apéro #{apero})", "tu peux attendre #{apero} ou y aller, il est midi !" ], true} now.hour == 17 -> {[ "ÇA APPROCHE !!! Apéro #{apero}", "BIENTÔT !!! Apéro #{apero}", "achetez vite les teilles, apéro dans #{apero}!", "préparez les teilles, apéro dans #{apero}!" ], false} now.hour >= 14 && now.hour < 18 -> weekend = if day_of_week >= 6 do " ... ou maintenant en fait, c'est le week-end!" else "" end {["tiens bon! apéro #{apero}#{weekend}", "courage... apéro dans #{apero}#{weekend}", "pas encore :'( apéro dans #{apero}#{weekend}" ], false} true -> {[ "C'EST L'HEURE DE L'APÉRO !!! SANTAIIIIIIIIIIII !!!!" ], true} end txt = txt |> Enum.shuffle() |> Enum.random() m.replyfun.(txt) stats = get_full_statistics(state, m.account.id) if !apero? && stats.active > 0.1 do m.replyfun.("(... ou continue en fait, je suis pas ta mère !)") end {:noreply, state} end def handle_info({:irc, :trigger, "sobrepour", m = %IRC.Message{trigger: %IRC.Trigger{args: args, type: :bang}}}, state) do args = Enum.join(args, " ") {:ok, now} = DateTime.now("Europe/Paris", Tzdata.TimeZoneDatabase) time = case args do "demain " <> time -> {h, m} = case String.split(time, [":", "h"]) do [hour, ""] -> IO.puts ("h #{inspect hour}") {h, _} = Integer.parse(hour) {h, 0} [hour, min] when min != "" -> {h, _} = Integer.parse(hour) {m, _} = Integer.parse(min) {h, m} [hour] -> IO.puts ("h #{inspect hour}") {h, _} = Integer.parse(hour) {h, 0} _ -> {0, 0} end secs = ((60*60)*24) day = DateTime.add(now, secs, :second, Tzdata.TimeZoneDatabase) %DateTime{day | hour: h, minute: m, second: 0} "après demain " <> time -> secs = 2*((60*60)*24) DateTime.add(now, secs, :second, Tzdata.TimeZoneDatabase) datetime -> case Timex.Parse.DateTime.Parser.parse(datetime, "{}") do {:ok, dt} -> dt _ -> nil end end if time do meta = get_user_meta(state, m.account.id) stats = get_full_statistics(state, m.account.id) duration = round(DateTime.diff(time, now)/60.0) IO.puts "diff #{inspect duration} sober in #{inspect stats.sober_in}" if duration < stats.sober_in do int = stats.sober_in - duration m.replyfun.("désolé, aucune chance! tu seras sobre #{format_minute_duration(int)} après!") else remaining = duration - stats.sober_in if remaining < 30 do m.replyfun.("moins de 30 minutes de sobriété, c'est impossible de boire plus") else loss_per_minute = ((meta.loss_factor/100)/60) remaining_gl = (remaining-30)*loss_per_minute m.replyfun.("marge de boisson: #{inspect remaining} minutes, #{remaining_gl} g/l") end end end {:noreply, state} end def handle_info({:irc, :trigger, "alcoolog", m = %IRC.Message{trigger: %IRC.Trigger{args: [], type: :plus}}}, state) do {:ok, token} = Nola.Token.new({:alcoolog, :index, m.sender.network, m.channel}) url = NolaWeb.Router.Helpers.alcoolog_url(NolaWeb.Endpoint, :index, m.network, NolaWeb.format_chan(m.channel), token) m.replyfun.("-> #{url}") {:noreply, state} end def handle_info({:irc, :trigger, "alcoolog", m = %IRC.Message{trigger: %IRC.Trigger{args: [], type: :bang}}}, state) do url = NolaWeb.Router.Helpers.alcoolog_url(NolaWeb.Endpoint, :index, m.network, NolaWeb.format_chan(m.channel)) m.replyfun.("-> #{url}") {:noreply, state} end def handle_info({:irc, :trigger, "alcool", m = %IRC.Message{trigger: %IRC.Trigger{args: args = [cl, deg], type: :bang}}}, state) do {cl, _} = Util.float_paparse(cl) {deg, _} = Util.float_paparse(deg) points = Alcool.units(cl, deg) meta = get_user_meta(state, m.account.id) k = if meta.sex, do: 0.7, else: 0.6 weight = meta.weight gl = (10*points)/(k*weight) duration = round(gl/((meta.loss_factor/100)/60))+30 sober_in_s = if duration > 0 do duration = Timex.Duration.from_minutes(duration) Timex.Format.Duration.Formatter.lformat(duration, "fr", :humanized) else "" end m.replyfun.("Il y a #{Float.round(points+0.0, 4)} unités d'alcool dans #{cl}cl à #{deg}° (#{Float.round(gl + 0.0, 4)} g/l, #{sober_in_s})") {:noreply, state} end def handle_info({:irc, :trigger, "santai", m = %IRC.Message{trigger: %IRC.Trigger{args: [cl, deg | comment], type: :bang}}}, state) do santai(m, state, cl, deg, comment) {:noreply, state} end @moar [ "{{message.sender.nick}}: la même donc ?", "{{message.sender.nick}}: et voilà la petite sœur !" ] def handle_info({:irc, :trigger, "bis", m = %IRC.Message{trigger: %IRC.Trigger{args: args, type: :bang}}}, state) do handle_info({:irc, :trigger, "moar", m}, state) end def handle_info({:irc, :trigger, "again", m = %IRC.Message{trigger: %IRC.Trigger{args: args, type: :bang}}}, state) do handle_info({:irc, :trigger, "moar", m}, state) end def handle_info({:irc, :trigger, "moar", m = %IRC.Message{trigger: %IRC.Trigger{args: args, type: :bang}}}, state) do case get_statistics_for_nick(state, m.account.id) do {_, obj = {_, _date, _points, _active, cl, deg, _name, comment, _meta}} -> cl = case args do [cls] -> case Util.float_paparse(cls) do {cl, _} -> cl _ -> cl end _ -> cl end moar = @moar |> Enum.shuffle() |> Enum.random() |> Tmpl.render(m) |> m.replyfun.() santai(m, state, cl, deg, comment, auto_set: true) {_, obj = {_, date, points, _last_active, type, descr}} -> case Regex.named_captures(~r/^(?\d+[.]\d+)cl\s+(?\d+[.]\d+)°$/, type) do nil -> m.replyfun.("suce") u -> moar = @moar |> Enum.shuffle() |> Enum.random() |> Tmpl.render(m) |> m.replyfun.() santai(m, state, u["cl"], u["deg"], descr, auto_set: true) end _ -> nil end {:noreply, state} end defp santai(m, state, cl, deg, comment, options \\ []) do comment = cond do comment == [] -> nil is_binary(comment) -> comment comment == nil -> nil true -> Enum.join(comment, " ") end {cl, cl_extra} = case {Util.float_paparse(cl), cl} do {{cl, extra}, _} -> {cl, extra} {:error, "("<>_} -> try do {:ok, result} = Abacus.eval(cl) {result, nil} rescue _ -> {nil, "cl: invalid calc expression"} end {:error, _} -> {nil, "cl: invalid value"} end {deg, comment, auto_set, beer_id} = case Util.float_paparse(deg) do {deg, _} -> {deg, comment, Keyword.get(options, :auto_set, false), nil} :error -> beername = if(comment, do: "#{deg} #{comment}", else: deg) case Untappd.search_beer(beername, limit: 1) do {:ok, %{"response" => %{"beers" => %{"count" => count, "items" => [%{"beer" => beer, "brewery" => brewery} | _]}}}} -> {Map.get(beer, "beer_abv"), "#{Map.get(brewery, "brewery_name")}: #{Map.get(beer, "beer_name")}", true, Map.get(beer, "bid")} _ -> {deg, "could not find beer", false, nil} end end cond do cl == nil -> m.replyfun.(cl_extra) deg == nil -> m.replyfun.(comment) cl >= 500 || deg >= 100 -> Nola.Plugins.Txt.reply_random(m, "alcoolog.drink_toohuge") cl == 0 || deg == 0 -> Nola.Plugins.Txt.reply_random(m, "alcoolog.drink_zero") cl < 0 || deg < 0 -> Nola.Plugins.Txt.reply_random(m, "alcoolog.drink_negative") true -> points = Alcool.units(cl, deg) now = m.at || DateTime.utc_now() |> DateTime.to_unix(:millisecond) user_meta = get_user_meta(state, m.account.id) name = "#{cl}cl #{deg}°" old_stats = get_full_statistics(state, m.account.id) meta = %{} meta = Map.put(meta, "timestamp", now) meta = Map.put(meta, "weight", user_meta.weight) meta = Map.put(meta, "sex", user_meta.sex) :ok = :dets.insert(state.dets, {m.account.id, now, points, if(old_stats, do: old_stats.active, else: 0), cl, deg, name, comment, meta}) true = :ets.insert(state.ets, {{m.account.id, now}, points, if(old_stats, do: old_stats.active, else: 0),cl, deg, name, comment, meta}) #sante = @santai |> Enum.map(fn(s) -> String.trim(String.upcase(s)) end) |> Enum.shuffle() |> Enum.random() sante = Nola.Plugins.Txt.random("alcoolog.santai") k = if user_meta.sex, do: 0.7, else: 0.6 weight = user_meta.weight peak = Float.round((10*points||0.0)/(k*weight), 4) stats = get_full_statistics(state, m.account.id) sober_add = if old_stats && Map.get(old_stats || %{}, :sober_in) do mins = round(stats.sober_in - old_stats.sober_in) " [+#{mins}m]" else "" end nonow = DateTime.utc_now() sober = nonow |> DateTime.add(round(stats.sober_in*60), :second) |> Timex.Timezone.convert("Europe/Paris") at = if nonow.day == sober.day do {:ok, detail} = Timex.Format.DateTime.Formatters.Default.lformat(sober, "aujourd'hui {h24}:{m}", "fr") detail else {:ok, detail} = Timex.Format.DateTime.Formatters.Default.lformat(sober, "{WDfull} {h24}:{m}", "fr") detail end up = if stats.active_drinks > 1 do " " <> Enum.join(for(_ <- 1..stats.active_drinks, do: "▲")) <> "" else "" end since_str = if stats.since && stats.since_min > 180 do "(depuis: #{stats.since_s}) " else "" end msg = fn(nick, extra) -> "#{sante} #{nick} #{extra}#{up} #{format_points(points)} @#{stats.active}g/l [+#{peak} g/l]" <> " (15m: #{stats.active15m}, 30m: #{stats.active30m}, 1h: #{stats.active1h}) #{since_str}(sobriété #{at} (dans #{stats.sober_in_s})#{sober_add}) !" <> " (aujourd'hui #{stats.daily_volumes} points - #{stats.daily_gl} g/l)" end meta = if beer_id do Map.put(meta, "untappd:beer_id", beer_id) else meta end if beer_id do spawn(fn() -> case Untappd.maybe_checkin(m.account, beer_id) do {:ok, body} -> badges = get_in(body, ["badges", "items"]) if badges != [] do badges_s = Enum.map(badges, fn(badge) -> Map.get(badge, "badge_name") end) |> Enum.filter(fn(b) -> b end) |> Enum.intersperse(", ") |> Enum.join("") badge = if(length(badges) > 1, do: "badges", else: "badge") m.replyfun.("\\O/ Unlocked untappd #{badge}: #{badges_s}") end :ok {:error, {:http_error, error}} when is_integer(error) -> m.replyfun.("Checkin to Untappd failed: #{to_string(error)}") {:error, {:http_error, error}} -> m.replyfun.("Checkin to Untappd failed: #{inspect error}") _ -> :error end end) end local_extra = if auto_set do if comment do " #{comment} (#{cl}cl @ #{deg}°)" else "#{cl}cl @ #{deg}°" end else "" end m.replyfun.(msg.(m.sender.nick, local_extra)) - notify = IRC.Membership.notify_channels(m.account) -- [{m.network,m.channel}] + notify = Nola.Membership.notify_channels(m.account) -- [{m.network,m.channel}] for {net, chan} <- notify do user = IRC.UserTrack.find_by_account(net, m.account) nick = if(user, do: user.nick, else: m.account.name) extra = " " <> present_type(name, comment) <> "" IRC.Connection.broadcast_message(net, chan, msg.(nick, extra)) end miss = cond do points <= 0.6 -> :small stats.active30m >= 2.9 && stats.active30m < 3 -> :miss3 stats.active30m >= 1.9 && stats.active30m < 2 -> :miss2 stats.active30m >= 0.9 && stats.active30m < 1 -> :miss1 stats.active30m >= 0.45 && stats.active30m < 0.5 -> :miss05 stats.active30m >= 0.20 && stats.active30m < 0.20 -> :miss025 stats.active30m >= 3 && stats.active1h < 3.15 -> :small3 stats.active30m >= 2 && stats.active1h < 2.15 -> :small2 stats.active30m >= 1.5 && stats.active1h < 1.5 -> :small15 stats.active30m >= 1 && stats.active1h < 1.15 -> :small1 stats.active30m >= 0.5 && stats.active1h <= 0.51 -> :small05 stats.active30m >= 0.25 && stats.active30m <= 0.255 -> :small025 true -> nil end if miss do miss = Nola.Plugins.Txt.random("alcoolog.#{to_string(miss)}") if miss do - for {net, chan} <- IRC.Membership.notify_channels(m.account) do + for {net, chan} <- Nola.Membership.notify_channels(m.account) do user = IRC.UserTrack.find_by_account(net, m.account) nick = if(user, do: user.nick, else: m.account.name) IRC.Connection.broadcast_message(net, chan, "#{nick}: #{miss}") end end end end end def handle_info({:irc, :trigger, "santai", m = %IRC.Message{trigger: %IRC.Trigger{args: _, type: :bang}}}, state) do m.replyfun.("!santai [commentaire]") {:noreply, state} end def get_all_stats() do Nola.Account.all_accounts() |> Enum.map(fn(account) -> {account.id, get_full_statistics(account.id)} end) |> Enum.filter(fn({_nick, status}) -> status && (status.active > 0 || status.active30m > 0) end) |> Enum.sort_by(fn({_, status}) -> status.active end, &>/2) end def get_channel_statistics(account, network, nil) do - IRC.Membership.expanded_members_or_friends(account, network, nil) + Nola.Membership.expanded_members_or_friends(account, network, nil) |> Enum.map(fn({account, _, nick}) -> {nick, get_full_statistics(account.id)} end) |> Enum.filter(fn({_nick, status}) -> status && (status.active > 0 || status.active30m > 0) end) |> Enum.sort_by(fn({_, status}) -> status.active end, &>/2) 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) + Nola.Membership.expanded_members(network, channel) |> Enum.map(fn({account, _, nick}) -> {nick, get_full_statistics(account.id)} end) |> Enum.filter(fn({_nick, status}) -> status && (status.active > 0 || status.active30m > 0) end) |> Enum.sort_by(fn({_, status}) -> status.active end, &>/2) end @spec since() :: %{Nola.Account.id() => DateTime.t()} @doc "Returns the last time the user was at 0 g/l" def since() do :ets.foldr(fn({{acct, timestamp_or_date}, _vol, current, _cl, _deg, _name, _comment, _m}, acc) -> if !Map.get(acc, acct) && current == 0 do date = Util.to_date_time(timestamp_or_date) Map.put(acc, acct, date) else acc end end, %{}, __MODULE__.ETS) end def get_full_statistics(nick) do get_full_statistics(data_state(), nick) end defp get_full_statistics(state, nick) do case get_statistics_for_nick(state, nick) do {count, {_, last_at, last_points, last_active, last_cl, last_deg, last_type, last_descr, _meta}} -> {active, active_drinks} = current_alcohol_level(state, nick) {_, m30} = alcohol_level_rising(state, nick) {rising, m15} = alcohol_level_rising(state, nick, 15) {_, m5} = alcohol_level_rising(state, nick, 5) {_, h1} = alcohol_level_rising(state, nick, 60) trend = if rising do "▲" else "▼" end user_state = cond do active <= 0.0 -> :sober active <= 0.25 -> :low active <= 0.50 -> :legal active <= 1.0 -> :legalhigh active <= 2.5 -> :high active < 3 -> :toohigh true -> :sick end rising_file_key = if rising, do: "_rising", else: "" txt_file = "alcoolog." <> "user_" <> to_string(user_state) <> rising_file_key user_status = Nola.Plugins.Txt.random(txt_file) meta = get_user_meta(state, nick) minutes_til_sober = h1/((meta.loss_factor/100)/60) minutes_til_sober = cond do active < 0 -> 0 m15 < 0 -> 15 m30 < 0 -> 30 h1 < 0 -> 60 minutes_til_sober > 0 -> Float.round(minutes_til_sober+60) true -> 0 end duration = Timex.Duration.from_minutes(minutes_til_sober) sober_in_s = if minutes_til_sober > 0 do Timex.Format.Duration.Formatter.lformat(duration, "fr", :humanized) else nil end since = if active > 0 do since() |> Map.get(nick) end since_diff = if since, do: Timex.diff(DateTime.utc_now(), since, :minutes) since_duration = if since, do: Timex.Duration.from_minutes(since_diff) since_s = if since, do: Timex.Format.Duration.Formatter.lformat(since_duration, "fr", :humanized) {total_volumes, total_gl} = user_stats(state, nick) %{active: active, last_at: last_at, last_cl: last_cl, last_deg: last_deg, last_points: last_points, last_type: last_type, last_descr: last_descr, trend_symbol: trend, active5m: m5, active15m: m15, active30m: m30, active1h: h1, rising: rising, active_drinks: active_drinks, user_status: user_status, daily_gl: total_gl, daily_volumes: total_volumes, sober_in: minutes_til_sober, sober_in_s: sober_in_s, since: since, since_min: since_diff, since_s: since_s, } _ -> nil end end def handle_info({:irc, :trigger, "sobre", m = %IRC.Message{trigger: %IRC.Trigger{args: args, type: :dot}}}, state) do - nicks = IRC.Membership.expanded_members_or_friends(m.account, m.network, m.channel) + nicks = Nola.Membership.expanded_members_or_friends(m.account, m.network, m.channel) |> Enum.map(fn({account, _, nick}) -> {nick, get_full_statistics(state, account.id)} end) |> Enum.filter(fn({_nick, status}) -> status && status.sober_in && status.sober_in > 0 end) |> Enum.sort_by(fn({_, status}) -> status.sober_in end, & Enum.map(fn({nick, stats}) -> now = DateTime.utc_now() sober = now |> DateTime.add(round(stats.sober_in*60), :second) |> Timex.Timezone.convert("Europe/Paris") at = if now.day == sober.day do {:ok, detail} = Timex.Format.DateTime.Formatters.Default.lformat(sober, "aujourd'hui {h24}:{m}", "fr") detail else {:ok, detail} = Timex.Format.DateTime.Formatters.Default.lformat(sober, "{WDfull} {h24}:{m}", "fr") detail end "#{nick} sobre #{at} (dans #{stats.sober_in_s})" end) |> Enum.intersperse(", ") |> Enum.join("") |> (fn(line) -> case line do "" -> "tout le monde est sobre......." line -> line end end).() |> m.replyfun.() {:noreply, state} end def handle_info({:irc, :trigger, "sobre", m = %IRC.Message{trigger: %IRC.Trigger{args: args, type: :bang}}}, state) do account = case args do [nick] -> Nola.Account.find_always_by_nick(m.network, m.channel, nick) [] -> m.account end if account do user = IRC.UserTrack.find_by_account(m.network, account) nick = if(user, do: user.nick, else: account.name) stats = get_full_statistics(state, account.id) if stats && stats.sober_in > 0 do now = DateTime.utc_now() sober = now |> DateTime.add(round(stats.sober_in*60), :second) |> Timex.Timezone.convert("Europe/Paris") at = if now.day == sober.day do {:ok, detail} = Timex.Format.DateTime.Formatters.Default.lformat(sober, "aujourd'hui {h24}:{m}", "fr") detail else {:ok, detail} = Timex.Format.DateTime.Formatters.Default.lformat(sober, "{WDfull} {h24}:{m}", "fr") detail end m.replyfun.("#{nick} sera sobre #{at} (dans #{stats.sober_in_s})!") else m.replyfun.("#{nick} est déjà sobre. aidez le !") end else m.replyfun.("inconnu") end {:noreply, state} end def handle_info({:irc, :trigger, "alcoolisme", m = %IRC.Message{trigger: %IRC.Trigger{args: [], type: :dot}}}, state) do - nicks = IRC.Membership.expanded_members_or_friends(m.account, m.network, m.channel) + nicks = Nola.Membership.expanded_members_or_friends(m.account, m.network, m.channel) |> Enum.map(fn({account, _, nick}) -> {nick, get_full_statistics(state, account.id)} end) |> Enum.filter(fn({_nick, status}) -> status && (status.active > 0 || status.active30m > 0) end) |> Enum.sort_by(fn({_, status}) -> status.active end, &>/2) |> Enum.map(fn({nick, status}) -> trend_symbol = if status.active_drinks > 1 do Enum.join(for(_ <- 1..status.active_drinks, do: status.trend_symbol)) else status.trend_symbol end since_str = if status.since_min > 180 do "depuis: #{status.since_s} | " else "" end "#{nick} #{status.user_status} #{trend_symbol} #{Float.round(status.active, 4)} g/l [#{since_str}sobre dans: #{status.sober_in_s}]" end) |> Enum.intersperse(", ") |> Enum.join("") msg = if nicks == "" do "wtf?!?! personne n'a bu!" else nicks end m.replyfun.(msg) {:noreply, state} end def handle_info({:irc, :trigger, "alcoolisme", m = %IRC.Message{trigger: %IRC.Trigger{args: [time], type: :dot}}}, state) do time = case time do "semaine" -> 7 string -> case Integer.parse(string) do {time, "j"} -> time {time, "J"} -> time _ -> nil end end if time do aday = time*((24 * 60)*60) now = DateTime.utc_now() before = now |> DateTime.add(-aday, :second) |> DateTime.to_unix(:millisecond) over_time_stats(before, time, m, state) else m.replyfun.(".alcooolisme semaine|Xj") end {:noreply, state} end def user_over_time(account, count) do user_over_time(data_state(), account, count) end def user_over_time(state, account, count) do delay = count*((24 * 60)*60) now = DateTime.utc_now() before = DateTime.utc_now() |> DateTime.shift_zone!("Europe/Paris", Tzdata.TimeZoneDatabase) |> DateTime.add(-delay, :second, Tzdata.TimeZoneDatabase) |> DateTime.to_unix(:millisecond) #[ # {{{:"$1", :"$2"}, :_, :_, :_, :_, :_, :_, :_}, # [{:andalso, {:==, :"$1", :"$1"}, {:<, :"$2", {:const, 3000}}}], [:lol]} #] match = [{{{:"$1", :"$2"}, :_, :_, :_, :_, :_, :_, :_}, [{:andalso, {:>, :"$2", {:const, before}}, {:==, :"$1", {:const, account.id}}}], [:"$_"]} ] :ets.select(state.ets, match) |> Enum.reduce(Map.new, fn({{_, ts}, vol, _, _, _, _, _, _}, acc) -> date = DateTime.from_unix!(ts, :millisecond) |> DateTime.shift_zone!("Europe/Paris", Tzdata.TimeZoneDatabase) date = if date.hour <= 8 do DateTime.add(date, -(60*(60*(date.hour+1))), :second, Tzdata.TimeZoneDatabase) else date end |> DateTime.to_date() Map.put(acc, date, Map.get(acc, date, 0) + vol) end) end def user_over_time_gl(account, count) do state = data_state() meta = get_user_meta(state, account.id) delay = count*((24 * 60)*60) now = DateTime.utc_now() before = DateTime.utc_now() |> DateTime.shift_zone!("Europe/Paris", Tzdata.TimeZoneDatabase) |> DateTime.add(-delay, :second, Tzdata.TimeZoneDatabase) |> DateTime.to_unix(:millisecond) #[ # {{{:"$1", :"$2"}, :_, :_, :_, :_, :_, :_, :_}, # [{:andalso, {:==, :"$1", :"$1"}, {:<, :"$2", {:const, 3000}}}], [:lol]} #] match = [{{{:"$1", :"$2"}, :_, :_, :_, :_, :_, :_, :_}, [{:andalso, {:>, :"$2", {:const, before}}, {:==, :"$1", {:const, account.id}}}], [:"$_"]} ] :ets.select(state.ets, match) |> Enum.reduce(Map.new, fn({{_, ts}, vol, _, _, _, _, _, _}, acc) -> date = DateTime.from_unix!(ts, :millisecond) |> DateTime.shift_zone!("Europe/Paris", Tzdata.TimeZoneDatabase) date = if date.hour <= 8 do DateTime.add(date, -(60*(60*(date.hour+1))), :second, Tzdata.TimeZoneDatabase) else date end |> DateTime.to_date() weight = meta.weight k = if meta.sex, do: 0.7, else: 0.6 gl = (10*vol)/(k*weight) Map.put(acc, date, Map.get(acc, date, 0) + gl) end) end defp over_time_stats(before, j, m, state) do #match = :ets.fun2ms(fn(obj = {{^nick, date}, _, _, _, _, _, _, _}) when date > before -> obj end) match = [{{{:_, :"$1"}, :_, :_, :_, :_, :_, :_, :_}, [{:>, :"$1", {:const, before}}], [:"$_"]} ] # tuple ets: {{nick, date}, volumes, current, nom, commentaire} - members = IRC.Membership.members_or_friends(m.account, m.network, m.channel) + members = Nola.Membership.members_or_friends(m.account, m.network, m.channel) drinks = :ets.select(state.ets, match) |> Enum.filter(fn({{account, _}, _, _, _, _, _, _, _}) -> Enum.member?(members, account) end) |> Enum.sort_by(fn({{_, ts}, _, _, _, _, _, _, _}) -> ts end, &>/2) top = Enum.reduce(drinks, %{}, fn({{nick, _}, vol, _, _, _, _, _, _}, acc) -> all = Map.get(acc, nick, 0) Map.put(acc, nick, all + vol) end) |> Enum.sort_by(fn({_nick, count}) -> count end, &>/2) |> Enum.map(fn({nick, count}) -> account = Nola.Account.get(nick) user = IRC.UserTrack.find_by_account(m.network, account) nick = if(user, do: user.nick, else: account.name) "#{nick}: #{Float.round(count, 4)}" end) |> Enum.intersperse(", ") m.replyfun.("sur #{j} jours: #{top}") {:noreply, state} end def handle_info({:irc, :trigger, "alcoolisme", m = %IRC.Message{trigger: %IRC.Trigger{args: [], type: :plus}}}, state) do meta = get_user_meta(state, m.account.id) hf = if meta.sex, do: "h", else: "f" m.replyfun.("+alcoolisme sexe: #{hf} poids: #{meta.weight} facteur de perte: #{meta.loss_factor}") {:noreply, state} end def handle_info({:irc, :trigger, "alcoolisme", m = %IRC.Message{trigger: %IRC.Trigger{args: [h, weight | rest], type: :plus}}}, state) do h = case h do "h" -> true "f" -> false _ -> nil end weight = case Util.float_paparse(weight) do {weight, _} -> weight _ -> nil end {factor} = case rest do [factor] -> case Util.float_paparse(factor) do {float, _} -> {float} _ -> {@default_user_meta.loss_factor} end _ -> {@default_user_meta.loss_factor} end if h == nil || weight == nil do m.replyfun.("paramètres invalides") else old_meta = get_user_meta(state, m.account.id) meta = Map.merge(@default_user_meta, %{sex: h, weight: weight, loss_factor: factor}) put_user_meta(state, m.account.id, meta) cond do old_meta.weight < meta.weight -> Nola.Plugins.Txt.reply_random(m, "alcoolog.fatter") old_meta.weight == meta.weight -> m.replyfun.("aucun changement!") true -> Nola.Plugins.Txt.reply_random(m, "alcoolog.thinner") end end {:noreply, state} end def handle_info({:irc, :trigger, "santai", m = %IRC.Message{trigger: %IRC.Trigger{args: args, type: :minus}}}, state) do case get_statistics_for_nick(state, m.account.id) do {_, obj = {_, date, points, _last_active, _cl, _deg, type, descr, _meta}} -> :dets.delete_object(state.dets, obj) :ets.delete(state.ets, {m.account.id, date}) m.replyfun.("supprimé: #{m.sender.nick} #{points} #{type} #{descr}") Nola.Plugins.Txt.reply_random(m, "alcoolog.delete") - notify = IRC.Membership.notify_channels(m.account) -- [{m.network,m.channel}] + notify = Nola.Membership.notify_channels(m.account) -- [{m.network,m.channel}] for {net, chan} <- notify do user = IRC.UserTrack.find_by_account(net, m.account) nick = if(user, do: user.nick, else: m.account.name) IRC.Connection.broadcast_message(net, chan, "#{nick} -santai #{points} #{type} #{descr}") end {:noreply, state} _ -> {:noreply, state} end end def handle_info({:irc, :trigger, "alcoolisme", m = %IRC.Message{trigger: %IRC.Trigger{args: args, type: :bang}}}, state) do {account, duration} = case args do [nick | rest] -> {Nola.Account.find_always_by_nick(m.network, m.channel, nick), rest} [] -> {m.account, []} end if account do duration = case duration do ["semaine"] -> 7 [j] -> case Integer.parse(j) do {j, "j"} -> j _ -> nil end _ -> nil end user = IRC.UserTrack.find_by_account(m.network, account) nick = if(user, do: user.nick, else: account.name) if duration do if duration > 90 do m.replyfun.("trop gros, ça rentrera pas") else # duration stats stats = user_over_time(state, account, duration) |> Enum.sort_by(fn({k,_v}) -> k end, {:asc, Date}) |> Enum.map(fn({date, count}) -> "#{date.day}: #{Float.round(count, 2)}" end) |> Enum.intersperse(", ") |> Enum.join("") if stats == "" do m.replyfun.("alcoolisme a zéro sur #{duration}j :/") else m.replyfun.("alcoolisme de #{nick}, #{duration} derniers jours: #{stats}") end end else if stats = get_full_statistics(state, account.id) do trend_symbol = if stats.active_drinks > 1 do Enum.join(for(_ <- 1..stats.active_drinks, do: stats.trend_symbol)) else stats.trend_symbol end # TODO: Lookup nick for account_id msg = "#{nick} #{stats.user_status} " <> (if stats.active > 0 || stats.active15m > 0 || stats.active30m > 0 || stats.active1h > 0, do: ": #{trend_symbol} #{Float.round(stats.active, 4)}g/l ", else: "") <> (if stats.active30m > 0 || stats.active1h > 0, do: "(15m: #{stats.active15m}, 30m: #{stats.active30m}, 1h: #{stats.active1h}) ", else: "") <> (if stats.sober_in > 0, do: "— Sobre dans #{stats.sober_in_s} ", else: "") <> (if stats.since && stats.since_min > 180, do: "— Paitai depuis #{stats.since_s} ", else: "") <> "— Dernier verre: #{present_type(stats.last_type, stats.last_descr)} [#{Float.round(stats.last_points+0.0, 4)}] " <> "#{format_duration_from_now(stats.last_at)} " <> (if stats.daily_volumes > 0, do: "— Aujourd'hui: #{stats.daily_volumes} #{stats.daily_gl}g/l", else: "") m.replyfun.(msg) else m.replyfun.("honteux mais #{nick} n'a pas l'air alcoolique du tout. /kick") end end else m.replyfun.("je ne connais pas cet utilisateur") end {:noreply, state} end # Account merge def handle_info({:account_change, old_id, new_id}, state) do spec = [{{:"$1", :_, :_, :_, :_, :_, :_, :_, :_}, [{:==, :"$1", {:const, old_id}}], [:"$_"]}] Util.ets_mutate_select_each(:dets, state.dets, spec, fn(table, obj) -> Logger.debug("alcolog/account_change:: merging #{old_id} -> #{new_id}") rename_object_owner(table, state.ets, obj, old_id, new_id) end) case :dets.lookup(state.meta, {:meta, old_id}) do [{_, meta}] -> :dets.delete(state.meta, {:meta, old_id}) :dets.insert(state.meta, {{:meta, new_id}, meta}) _ -> :ok end {:noreply, state} end def terminate(_, state) do for dets <- [state.dets, state.meta] do :dets.sync(dets) :dets.close(dets) end end defp rename_object_owner(table, ets, object = {old_id, date, volume, current, cl, deg, name, comment, meta}, old_id, new_id) do :dets.delete_object(table, object) :ets.delete(ets, {old_id, date}) :dets.insert(table, {new_id, date, volume, current, cl, deg, name, comment, meta}) :ets.insert(ets, {{new_id, date}, volume, current, cl, deg, name, comment, meta}) end # Account: move from nick to account id def handle_info({:accounts, accounts}, state) do #for x={:account, _, _, _, _} <- accounts, do: handle_info(x, state) #{:noreply, state} mapping = Enum.reduce(accounts, Map.new, fn({:account, _net, _chan, nick, account_id}, acc) -> Map.put(acc, String.downcase(nick), account_id) end) spec = [{{:"$1", :_, :_, :_, :_, :_, :_, :_, :_}, [], [:"$_"]}] Logger.debug("accounts:: mappings #{inspect mapping}") Util.ets_mutate_select_each(:dets, state.dets, spec, fn(table, obj = {nick, _date, _vol, _cur, _cl, _deg, _name, _comment, _meta}) -> #Logger.debug("accounts:: item #{inspect(obj)}") if new_id = Map.get(mapping, nick) do Logger.debug("alcolog/accounts:: merging #{nick} -> #{new_id}") rename_object_owner(table, state.ets, obj, nick, new_id) end end) {:noreply, state} end def handle_info({:account, _net, _chan, nick, account_id}, state) do nick = String.downcase(nick) spec = [{{:"$1", :_, :_, :_, :_, :_, :_, :_, :_}, [{:==, :"$1", {:const, nick}}], [:"$_"]}] Util.ets_mutate_select_each(:dets, state.dets, spec, fn(table, obj) -> Logger.debug("alcoolog/account:: merging #{nick} -> #{account_id}") rename_object_owner(table, state.ets, obj, nick, account_id) end) case :dets.lookup(state.meta, {:meta, nick}) do [{_, meta}] -> :dets.delete(state.meta, {:meta, nick}) :dets.insert(state.meta, {{:meta, account_id}, meta}) _ -> :ok end {:noreply, state} end def handle_info(t, state) do Logger.debug("#{__MODULE__}: unhandled info #{inspect t}") {:noreply, state} end def nick_history(account) do spec = [ {{{:"$1", :_}, :_, :_, :_, :_, :_, :_, :_}, [{:==, :"$1", {:const, account.id}}], [:"$_"]} ] :ets.select(data_state().ets, spec) end defp get_statistics_for_nick(state, account_id) do qvc = :dets.lookup(state.dets, account_id) |> Enum.sort_by(fn({_, ts, _, _, _, _, _, _, _}) -> ts end, & acc + (points||0) end) last = List.last(qvc) || nil {count, last} end def present_type(type, descr) when descr in [nil, ""], do: "#{type}" def present_type(type, description), do: "#{type} (#{description})" def format_points(int) when is_integer(int) and int > 0 do "+#{Integer.to_string(int)}" end def format_points(int) when is_integer(int) and int < 0 do Integer.to_string(int) end def format_points(int) when is_float(int) and int > 0 do "+#{Float.to_string(Float.round(int,4))}" end def format_points(int) when is_float(int) and int < 0 do Float.to_string(Float.round(int,4)) end def format_points(0), do: "0" def format_points(0.0), do: "0" defp format_relative_timestamp(timestamp) do alias Timex.Format.DateTime.Formatters alias Timex.Timezone date = timestamp |> DateTime.from_unix!(:millisecond) |> Timezone.convert("Europe/Paris") {:ok, relative} = Formatters.Relative.relative_to(date, Timex.now("Europe/Paris"), "{relative}", "fr") {:ok, detail} = Formatters.Default.lformat(date, " ({h24}:{m})", "fr") relative <> detail end defp put_user_meta(state, account_id, meta) do :dets.insert(state.meta, {{:meta, account_id}, meta}) :ok end defp get_user_meta(%{meta: meta}, account_id) do case :dets.lookup(meta, {:meta, account_id}) do [{{:meta, _}, meta}] -> Map.merge(@default_user_meta, meta) _ -> @default_user_meta end end # Calcul g/l actuel: # 1. load user meta # 2. foldr ets # for each object # get_current_alcohol # ((object g/l) - 0,15/l/60)* minutes_since_drink # if minutes_since_drink < 10, reduce g/l (?!) # acc + current_alcohol # stop folding when ? # def user_stats(account) do user_stats(data_state(), account.id) end defp user_stats(state = %{ets: ets}, account_id) do meta = get_user_meta(state, account_id) aday = (10 * 60)*60 now = DateTime.utc_now() before = now |> DateTime.add(-aday, :second) |> DateTime.to_unix(:millisecond) #match = :ets.fun2ms(fn(obj = {{^nick, date}, _, _, _, _}) when date > before -> obj end) match = [ {{{:"$1", :"$2"}, :_, :_, :_, :_, :_, :_, :_}, [ {:>, :"$2", {:const, before}}, {:"=:=", {:const, account_id}, :"$1"} ], [:"$_"]} ] # tuple ets: {{nick, date}, volumes, current, nom, commentaire} drinks = :ets.select(ets, match) # {date, single_peak} total_volume = Enum.reduce(drinks, 0.0, fn({{_, date}, volume, _, _, _, _, _, _}, acc) -> acc + volume end) k = if meta.sex, do: 0.7, else: 0.6 weight = meta.weight gl = (10*total_volume)/(k*weight) {Float.round(total_volume + 0.0, 4), Float.round(gl + 0.0, 4)} end defp alcohol_level_rising(state, account_id, minutes \\ 30) do {now, _} = current_alcohol_level(state, account_id) soon_date = DateTime.utc_now |> DateTime.add(minutes*60, :second) {soon, _} = current_alcohol_level(state, account_id, soon_date) soon = cond do soon < 0 -> 0.0 true -> soon end #IO.puts "soon #{soon_date} - #{inspect soon} #{inspect now}" {soon > now, Float.round(soon+0.0, 4)} end defp current_alcohol_level(state = %{ets: ets}, account_id, now \\ nil) do meta = get_user_meta(state, account_id) aday = ((24*7) * 60)*60 now = if now do now else DateTime.utc_now() end before = now |> DateTime.add(-aday, :second) |> DateTime.to_unix(:millisecond) #match = :ets.fun2ms(fn(obj = {{^nick, date}, _, _, _, _}) when date > before -> obj end) match = [ {{{:"$1", :"$2"}, :_, :_, :_, :_, :_, :_, :_}, [ {:>, :"$2", {:const, before}}, {:"=:=", {:const, account_id}, :"$1"} ], [:"$_"]} ] # tuple ets: {{nick, date}, volumes, current, nom, commentaire} drinks = :ets.select(ets, match) |> Enum.sort_by(fn({{_, date}, _, _, _, _, _, _, _}) -> date end, & k = if meta.sex, do: 0.7, else: 0.6 weight = meta.weight peak = (10*volume)/(k*weight) date = case date do ts when is_integer(ts) -> DateTime.from_unix!(ts, :millisecond) date = %NaiveDateTime{} -> DateTime.from_naive!(date, "Etc/UTC") date = %DateTime{} -> date end last_at = last_at || date mins_since = round(DateTime.diff(now, date)/60.0) #IO.puts "Drink: #{inspect({date, volume})} - mins since: #{inspect mins_since} - last drink at #{inspect last_at}" # Apply loss since `last_at` on `all` # all = if last_at do mins_since_last = round(DateTime.diff(date, last_at)/60.0) loss = ((meta.loss_factor/100)/60)*(mins_since_last) #IO.puts "Applying last drink loss: from #{all}, loss of #{inspect loss} (mins since #{inspect mins_since_first})" cond do (all-loss) > 0 -> all - loss true -> 0.0 end else all end #IO.puts "Applying last drink current before drink: #{inspect all}" if mins_since < 30 do per_min = (peak)/30.0 current = (per_min*mins_since) #IO.puts "Applying current drink 30m: from #{peak}, loss of #{inspect per_min}/min (mins since #{inspect mins_since})" {all + current, date, [{date, current} | acc], active_drinks + 1} else {all + peak, date, [{date, peak} | acc], active_drinks} end end) #IO.puts "last drink #{inspect last_drink_at}" mins_since_last = if last_drink_at do round(DateTime.diff(now, last_drink_at)/60.0) else 0 end # Si on a déjà bu y'a déjà moins 15 minutes (big up le binge drinking), on applique plus de perte level = if mins_since_last > 15 do loss = ((meta.loss_factor/100)/60)*(mins_since_last) Float.round(all - loss, 4) else all end #IO.puts "\n LEVEL #{inspect level}\n\n\n\n" cond do level < 0 -> {0.0, 0} true -> {level, active_drinks} end end defp format_duration_from_now(date, with_detail \\ true) do date = if is_integer(date) do date = DateTime.from_unix!(date, :millisecond) |> Timex.Timezone.convert("Europe/Paris") else Util.to_naive_date_time(date) end now = DateTime.utc_now() |> Timex.Timezone.convert("Europe/Paris") {:ok, detail} = Timex.Format.DateTime.Formatters.Default.lformat(date, "({h24}:{m})", "fr") mins_since = round(DateTime.diff(now, date)/60.0) if ago = format_minute_duration(mins_since) do word = if mins_since > 0 do "il y a " else "dans " end word <> ago <> if(with_detail, do: " #{detail}", else: "") else "maintenant #{detail}" end end defp format_minute_duration(minutes) do sober_in_s = if (minutes != 0) do duration = Timex.Duration.from_minutes(minutes) Timex.Format.Duration.Formatter.lformat(duration, "fr", :humanized) else nil end end end diff --git a/lib/plugins/alcoolog_announcer.ex b/lib/plugins/alcoolog_announcer.ex index 2baa999..9f66799 100644 --- a/lib/plugins/alcoolog_announcer.ex +++ b/lib/plugins/alcoolog_announcer.ex @@ -1,269 +1,269 @@ defmodule Nola.Plugins.AlcoologAnnouncer do require Logger @moduledoc """ Annonce changements d'alcoolog """ @channel "#dmz" @seconds 30 @apero [ "C'EST L'HEURE DE L'APÉRRROOOOOOOO !!", "SAAAAANNNNNNNTTTTTTTTAAAAAAAAIIIIIIIIIIIIIIIIIIIIII", "APÉRO ? APÉRO !", {:timed, ["/!\\ à vos tires bouchons…", "… débouchez …", "… BUVEZ! SANTAIII!"]}, "/!\\ ALERTE APÉRO /!\\", "CED !!! VASE DE ROUGE !", "DIDI UN PETIT RICARD™??!", "ALLEZ GUIGUI UNE PETITE BIERE ?", {:timed, ["/!\\ à vos tires bouchons…", "… débouchez …", "… BUVEZ! SANTAIII!"]}, "APPPPAIIIRRREAAUUUUUUUUUUU" ] def irc_doc, do: nil def start_link(), do: GenServer.start_link(__MODULE__, [], name: __MODULE__) def log(account) do dets_filename = (Nola.data_path() <> "/" <> "alcoologlog.dets") |> String.to_charlist {:ok, dets} = :dets.open_file(dets_filename, [{:type,:bag}]) from = ~U[2020-08-23 19:41:40.524154Z] to = ~U[2020-08-24 19:41:40.524154Z] select = [ {{:"$1", :"$2", :_}, [ {:andalso, {:andalso, {:==, :"$1", {:const, account.id}}, {:>, :"$2", {:const, DateTime.to_unix(from)}}}, {:<, :"$2", {:const, DateTime.to_unix(to)}}} ], [:"$_"]} ] res = :dets.select(dets, select) :dets.close(dets) res end def init(_) do {:ok, _} = Registry.register(Nola.PubSub, "account", []) stats = get_stats() Process.send_after(self(), :stats, :timer.seconds(30)) dets_filename = (Nola.data_path() <> "/" <> "alcoologlog.dets") |> String.to_charlist {:ok, dets} = :dets.open_file(dets_filename, [{:type,:bag}]) ets = nil # :ets.new(__MODULE__.ETS, [:ordered_set, :named_table, :protected, {:read_concurrency, true}]) {:ok, {stats, now(), dets, ets}}#, {:continue, :traverse}} end def handle_continue(:traverse, state = {_, _, dets, ets}) do traverse_fun = fn(obj, dets) -> case obj do {nick, %DateTime{} = dt, active} -> :dets.delete_object(dets, obj) :dets.insert(dets, {nick, DateTime.to_unix(dt), active}) IO.puts("ok #{inspect obj}") dets {nick, ts, value} -> :ets.insert(ets, { {nick, ts}, value }) dets end end :dets.foldl(traverse_fun, dets, dets) :dets.sync(dets) IO.puts("alcoolog announcer fixed") {:noreply, state} end def alcohol_reached(old, new, level) do (old.active < level && new.active >= level) && (new.active5m >= level) end def alcohol_below(old, new, level) do (old.active > level && new.active <= level) && (new.active5m <= level) end def handle_info(:stats, {old_stats, old_now, dets, ets}) do stats = get_stats() now = now() if old_now.hour < 18 && now.hour == 18 do apero = Enum.shuffle(@apero) |> Enum.random() case apero do {:timed, list} -> spawn(fn() -> for line <- list do IRC.Connection.broadcast_message("evolu.net", "#dmz", line) :timer.sleep(:timer.seconds(5)) end end) string -> IRC.Connection.broadcast_message("evolu.net", "#dmz", string) end end #IO.puts "newstats #{inspect stats}" events = for {acct, old} <- old_stats do new = Map.get(stats, acct, nil) #IO.puts "#{acct}: #{inspect(old)} -> #{inspect(new)}" now = DateTime.to_unix(DateTime.utc_now()) if new && new[:active] do :dets.insert(dets, {acct, now, new[:active]}) :ets.insert(ets, {{acct, now}, new[:active]}) else :dets.insert(dets, {acct, now, 0.0}) :ets.insert(ets, {{acct, now}, new[:active]}) end event = cond do old == nil -> nil (old.active > 0) && (new == nil) -> :sober new == nil -> nil alcohol_reached(old, new, 0.5) -> :stopconduire alcohol_reached(old, new, 1.0) -> :g1 alcohol_reached(old, new, 2.0) -> :g2 alcohol_reached(old, new, 3.0) -> :g3 alcohol_reached(old, new, 4.0) -> :g4 alcohol_reached(old, new, 5.0) -> :g5 alcohol_reached(old, new, 6.0) -> :g6 alcohol_reached(old, new, 7.0) -> :g7 alcohol_reached(old, new, 10.0) -> :g10 alcohol_reached(old, new, 13.74) -> :record alcohol_below(old, new, 0.5) -> :conduire alcohol_below(old, new, 1.0) -> :fini1g alcohol_below(old, new, 2.0) -> :fini2g alcohol_below(old, new, 3.0) -> :fini3g alcohol_below(old, new, 4.0) -> :fini4g (old.rising) && (!new.rising) -> :lowering true -> nil end {acct, event} end for {acct, event} <- events do message = case event do :g1 -> [ "[vigicuite jaune] LE GRAMME! LE GRAMME O/", "début de vigicuite jaune ! LE GRAMME ! \\O/", "waiiiiiiii le grammmeee", "bourraiiiiiiiiiiide 1 grammeeeeeeeeeee", ] :g2 -> [ "[vigicuite orange] \\o_YAY 2 GRAMMES ! _o/", "PAITAIIIIIIIIII DEUX GRAMMEESSSSSSSSSSSSSSSSS", "bourrrrrraiiiiiiiiiiiiiiiide 2 grammeeeeeeeeeees", ] :g3 -> [ "et un ! et deux ! et TROIS GRAMMEEESSSSSSS", "[vigicuite rouge] _o/ BOURRAIIDDDEEEE 3 GRAMMESSSSSSSSS \\o/ \\o/" ] :g4 -> [ "[vigicuite écarlate] et un, et deux, et trois, ET QUATRES GRAMMEESSSSSSSSSSSSSSSSSSSssssss" ] :g5 -> "[vigicuite écarlate+] PUTAIN 5 GRAMMES !" :g6 -> "[vigicuite écarlate++] 6 grammes ? Vous pouvez joindre Alcool info service au 0 980 980 930" :g7 -> "[vigicuite c'est la merde] 7 grammes. Le SAMU, c'est le 15." :g10 -> "BORDLE 10 GRAMMES" :record -> "RECORD DU MONDE BATTU ! >13.74g/l !!" :fini1g -> [ "fin d'alerte vigicuite jaune, passage en vert (<1g/l)", "/!\\ alerte moins de 1g/l /!\\" ] :fini2g -> [ "t'as moins de 2 g/l, faut se reprendre là [vigicuite jaune]" ] :fini3g -> [ "fin d'alerte vigicuite rouge, passage en orange (<3g/l)" ] :fini4g -> [ "fin d'alerte vigicuite écarlate, passage en rouge (<4g/l)" ] :lowering -> [ "attention ça baisse!", "tu vas quand même pas en rester là ?", "IL FAUT CONTINUER À BOIRE !", "t'abandonnes déjà ?", "!santai ?", "faut pas en rester là", "il faut se resservir", "coucou faut reboire", "encore un petit verre ?", "abwaaaaaaaaaaaaarrrrrrrrrrrrrr", "taux d'alcoolémie en chute ! agissez avant qu'il soit trop tard!", "ÇA BAISSE !!" ] :stopconduire -> [ "0.5g! bientot le gramme?", "tu peux plus prendre la route... mais... tu peux prendre la route du gramme! !santai !", "fini la conduite!", "0.5! continues faut pas en rester là!", "beau début, continues !", "ça monte! 0.5g/l!" ] :conduire -> [ "tu peux conduire, ou recommencer à boire! niveau critique!", "!santai ?", "tu peux reprendre la route, ou reprendre la route du gramme..", "attention, niveau critique!", "il faut boire !!", "trop de sang dans ton alcool, c'est mauvais pour la santé", "faut pas en rester là !", ] :sober -> [ "sobre…", "/!\\ alerte sobriété /!\\", "... sobre?!?!", "sobre :(", "attention, t'es sobre :/", "danger, alcoolémie à 0.0 !", "sobre! c'était bien on recommence quand ?", "sobre ? Faut recommencer...", "T'es sobre. Ne te laisses pas abattre- ton caviste peut aider.", "Vous êtes sobre ? Ceci n'est pas une fatalité - resservez vous vite !" ] _ -> nil end message = case message do m when is_binary(m) -> m m when is_list(m) -> m |> Enum.shuffle() |> Enum.random() nil -> nil end if message do #IO.puts("#{acct}: #{message}") account = Nola.Account.get(acct) - for {net, chan} <- IRC.Membership.notify_channels(account) do + for {net, chan} <- Nola.Membership.notify_channels(account) do user = IRC.UserTrack.find_by_account(net, account) nick = if(user, do: user.nick, else: account.name) IRC.Connection.broadcast_message(net, chan, "#{nick}: #{message}") end end end timer() #IO.puts "tick stats ok" {:noreply, {stats,now,dets,ets}} end def handle_info(_, state) do {:noreply, state} end defp now() do DateTime.utc_now() |> Timex.Timezone.convert("Europe/Paris") end defp get_stats() do Enum.into(Nola.Plugins.Alcoolog.get_all_stats(), %{}) end defp timer() do Process.send_after(self(), :stats, :timer.seconds(@seconds)) end end diff --git a/lib/plugins/last_fm.ex b/lib/plugins/last_fm.ex index 4607cbe..68c55ee 100644 --- a/lib/plugins/last_fm.ex +++ b/lib/plugins/last_fm.ex @@ -1,187 +1,187 @@ defmodule Nola.Plugins.LastFm do require Logger @moduledoc """ # last.fm * **!lastfm|np `[nick|username]`** * **.lastfm|np** * **+lastfm, -lastfm `; ?lastfm`** Configurer un nom d'utilisateur last.fm """ @single_trigger ~w(lastfm np) @pubsub_topics ~w(trigger:lastfm trigger:np) defstruct dets: nil def irc_doc, do: @moduledoc def start_link() do GenServer.start_link(__MODULE__, [], name: __MODULE__) end def init([]) do regopts = [type: __MODULE__] for t <- @pubsub_topics, do: {:ok, _} = Registry.register(Nola.PubSub, t, type: __MODULE__) dets_filename = (Nola.data_path() <> "/" <> "lastfm.dets") |> String.to_charlist {:ok, dets} = :dets.open_file(dets_filename, []) {:ok, %__MODULE__{dets: dets}} end def handle_info({:irc, :trigger, "lastfm", message = %{trigger: %{type: :plus, args: [username]}}}, state) do username = String.strip(username) :ok = :dets.insert(state.dets, {message.account.id, username}) message.replyfun.("#{message.sender.nick}: nom d'utilisateur last.fm configuré: \"#{username}\".") {:noreply, state} end def handle_info({:irc, :trigger, "lastfm", message = %{trigger: %{type: :minus, args: []}}}, state) do text = case :dets.lookup(state.dets, message.account.id) do [{_nick, _username}] -> :dets.delete(state.dets, message.account.id) message.replyfun.("#{message.sender.nick}: nom d'utilisateur last.fm enlevé.") _ -> nil end {:noreply, state} end def handle_info({:irc, :trigger, "lastfm", message = %{trigger: %{type: :query, args: []}}}, state) do text = case :dets.lookup(state.dets, message.account.id) do [{_nick, username}] -> message.replyfun.("#{message.sender.nick}: #{username}.") _ -> nil end {:noreply, state} end def handle_info({:irc, :trigger, _, message = %{trigger: %{type: :bang, args: []}}}, state) do irc_now_playing(message.account.id, message, state) {:noreply, state} end def handle_info({:irc, :trigger, _, message = %{trigger: %{type: :bang, args: [nick_or_user]}}}, state) do irc_now_playing(nick_or_user, message, state) {:noreply, state} end def handle_info({:irc, :trigger, _, message = %{trigger: %{type: :dot}}}, state) do - members = IRC.Membership.members(message.network, message.channel) + members = Nola.Membership.members(message.network, message.channel) foldfun = fn({nick, user}, acc) -> [{nick,user}|acc] end usernames = :dets.foldl(foldfun, [], state.dets) |> Enum.uniq() |> Enum.filter(fn({acct,_}) -> Enum.member?(members, acct) end) |> Enum.map(fn({_, u}) -> u end) for u <- usernames, do: irc_now_playing(u, message, state) {:noreply, state} end def handle_info(info, state) do {:noreply, state} end def terminate(_reason, state) do if state.dets do :dets.sync(state.dets) :dets.close(state.dets) end :ok end defp irc_now_playing(nick_or_user, message, state) do nick_or_user = String.strip(nick_or_user) id_or_user = if account = Nola.Account.get(nick_or_user) || Nola.Account.find_always_by_nick(message.network, message.channel, nick_or_user) do account.id else nick_or_user end username = case :dets.lookup(state.dets, id_or_user) do [{_, username}] -> username _ -> id_or_user end case now_playing(username) do {:error, text} when is_binary(text) -> message.replyfun.(text) {:ok, map} when is_map(map) -> track = fetch_track(username, map) text = format_now_playing(map, track) user = if account = Nola.Account.get(id_or_user) do user = IRC.UserTrack.find_by_account(message.network, account) if(user, do: user.nick, else: account.name) else username end if user && text do message.replyfun.("#{user} #{text}") else message.replyfun.("#{username}: pas de résultat") end other -> message.replyfun.("erreur :(") end end defp now_playing(user) do api = Application.get_env(:nola, :lastfm)[:api_key] url = "http://ws.audioscrobbler.com/2.0/?method=user.getrecenttracks&format=json&limit=1&extended=1" <> "&api_key=" <> api <> "&user="<> user case HTTPoison.get(url) do {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> Jason.decode(body) {:ok, %HTTPoison.Response{status_code: 404}} -> {:error, "last.fm: utilisateur \"#{user}\" inexistant"} {:ok, %HTTPoison.Response{status_code: code}} -> {:error, "last.fm: erreur #{to_string(code)}"} error -> Logger.error "Lastfm http error: #{inspect error}" :error end end defp fetch_track(user, %{"recenttracks" => %{"track" => [ t = %{"name" => name, "artist" => %{"name" => artist}} | _]}}) do api = Application.get_env(:nola, :lastfm)[:api_key] url = "http://ws.audioscrobbler.com/2.0/?method=track.getInfo&format=json" <> "&api_key=" <> api <> "&username="<> user <> "&artist="<>URI.encode(artist)<>"&track="<>URI.encode(name) case HTTPoison.get(url) do {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> case Jason.decode(body) do {:ok, body} -> body["track"] || %{} _ -> %{} end error -> Logger.error "Lastfm http error: #{inspect error}" :error end end defp format_now_playing(%{"recenttracks" => %{"track" => [track = %{"@attr" => %{"nowplaying" => "true"}} | _]}}, et) do format_track(true, track, et) end defp format_now_playing(%{"recenttracks" => %{"track" => [track | _]}}, et) do format_track(false, track, et) end defp format_now_playing(%{"error" => err, "message" => message}, _) do "last.fm error #{err}: #{message}" end defp format_now_playing(miss) do nil end defp format_track(np, track, extended) do artist = track["artist"]["name"] album = if track["album"]["#text"], do: " (" <> track["album"]["#text"] <> ")", else: "" name = track["name"] <> album action = if np, do: "écoute ", else: "a écouté" love = if track["loved"] != "0", do: "❤️" count = if x = extended["userplaycount"], do: "x#{x} #{love}" tags = (get_in(extended, ["toptags", "tag"]) || []) |> Enum.map(fn(tag) -> tag["name"] end) |> Enum.filter(& &1) |> Enum.join(", ") [action, artist, name, count, tags, track["url"]] |> Enum.filter(& &1) |> Enum.map(&String.trim(&1)) |> Enum.join(" - ") end end diff --git a/lib/plugins/say.ex b/lib/plugins/say.ex index e036bd4..9bfe1bd 100644 --- a/lib/plugins/say.ex +++ b/lib/plugins/say.ex @@ -1,73 +1,73 @@ defmodule Nola.Plugins.Say do def irc_doc do """ # say Say something... * **!say `` ``** say something on `channel` * **!asay `` ``** same but anonymously You must be a member of the channel. """ end def start_link() do GenServer.start_link(__MODULE__, [], name: __MODULE__) end def init([]) do regopts = [type: __MODULE__] {:ok, _} = Registry.register(Nola.PubSub, "trigger:say", regopts) {:ok, _} = Registry.register(Nola.PubSub, "trigger:asay", regopts) {:ok, _} = Registry.register(Nola.PubSub, "messages:private", regopts) {:ok, nil} end def handle_info({:irc, :trigger, "say", m = %{trigger: %{type: :bang, args: [target | text]}}}, state) do 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 + for {net, chan} <- Nola.Membership.of_account(account) do chan2 = String.replace(chan, "#", "") if (target == "#{net}/#{chan}" || target == "#{net}/#{chan2}" || target == chan || target == chan2) do if with_nick? do IRC.send_message_as(account, net, chan, text) else IRC.Connection.broadcast_message(net, chan, text) end end end end end diff --git a/lib/telegram/telegram.ex b/lib/telegram/telegram.ex index a93f5bb..dd23146 100644 --- a/lib/telegram/telegram.ex +++ b/lib/telegram/telegram.ex @@ -1,233 +1,233 @@ defmodule Nola.Telegram do require Logger @behaviour Telegram.ChatBot def my_path() do "https://t.me/beauttebot" end def send_message(id, text, md2 \\ false) do md = if md2, do: "MarkdownV2", else: "Markdown" token = Keyword.get(Application.get_env(:nola, :telegram, []), :key) Telegram.Bot.ChatBot.Chat.Session.Supervisor.start_child(Nola.Telegram, id) Telegram.Api.request(token, "sendMessage", chat_id: id, text: text, parse_mode: "Markdown") end @impl Telegram.ChatBot def init(chat_id) when chat_id < 0 do {:ok, state} = Nola.TelegramRoom.init(chat_id) {:ok, %{room_state: state}} end def init(chat_id) do Logger.info("Telegram session starting: #{chat_id}") account = Nola.Account.find_meta_account("telegram-id", chat_id) account_id = if account, do: account.id {:ok, %{account: account_id}} end @impl Telegram.ChatBot def handle_update(update, token, %{room_state: room_state}) do {:ok, room_state} = Nola.TelegramRoom.handle_update(update, token, room_state) {:ok, %{room_state: room_state}} end def handle_update(%{"message" => m = %{"chat" => %{"type" => "private"}, "text" => text = "/start"<>_}}, _token, state) do text = "*Welcome to beautte!*\n\nQuery the bot on IRC and say \"enable-telegram\" to continue." send_message(m["chat"]["id"], text) {:ok, %{account: nil}} end def handle_update(%{"message" => m = %{"chat" => %{"type" => "private"}, "text" => text = "/enable"<>_}}, _token, state) do key = case String.split(text, " ") do ["/enable", key | _] -> key _ -> "nil" end #Handled message "1247435154:AAGnSSCnySn0RuVxy_SUcDEoOX_rbF6vdq0" %{"message" => # %{"chat" => %{"first_name" => "J", "id" => 2075406, "type" => "private", "username" => "ahref"}, # "date" => 1591027272, "entities" => # [%{"length" => 7, "offset" => 0, "type" => "bot_command"}], # "from" => %{"first_name" => "J", "id" => 2075406, "is_bot" => false, "language_code" => "en", "username" => "ahref"}, # "message_id" => 11, "text" => "/enable salope"}, "update_id" => 764148578} account = Nola.Account.find_meta_account("telegram-validation-code", String.downcase(key)) text = if account do net = Nola.Account.get_meta(account, "telegram-validation-target") Nola.Account.put_meta(account, "telegram-id", m["chat"]["id"]) Nola.Account.put_meta(account, "telegram-username", m["chat"]["username"]) Nola.Account.put_meta(account, "telegram-username", m["chat"]["username"]) Nola.Account.delete_meta(account, "telegram-validation-code") Nola.Account.delete_meta(account, "telegram-validation-target") IRC.Connection.broadcast_message(net, account, "Telegram #{m["chat"]["username"]} account added!") "Yay! Linked to account **#{account.name}**." else "Token invalid" end send_message(m["chat"]["id"], text) {:ok, %{account: account.id}} end #[debug] Unhandled update: %{"message" => # %{"chat" => %{"first_name" => "J", "id" => 2075406, "type" => "private", "username" => "ahref"}, # "date" => 1591096015, # "from" => %{"first_name" => "J", "id" => 2075406, "is_bot" => false, "language_code" => "en", "username" => "ahref"}, # "message_id" => 29, # "photo" => [ # %{"file_id" => "AgACAgQAAxkBAAMdXtYyz4RQqLcpOlR6xKK3w3NayHAAAnCzMRuL4bFSgl_cTXMl4m5G_T0kXQADAQADAgADbQADZVMBAAEaBA", # "file_size" => 9544, "file_unique_id" => "AQADRv09JF0AA2VTAQAB", "height" => 95, "width" => 320}, # %{"file_id" => "AgACAgQAAxkBAAMdXtYyz4RQqLcpOlR6xKK3w3NayHAAAnCzMRuL4bFSgl_cTXMl4m5G_T0kXQADAQADAgADeAADZFMBAAEaBA", # "file_size" => 21420, "file_unique_id" => "AQADRv09JF0AA2RTAQAB", "height" => 148, "width" => 501}]}, # "update_id" => 218161546} for type <- ~w(photo voice video document animation) do def handle_update(data = %{"message" => %{unquote(type) => _}}, token, state) do start_upload(unquote(type), data, token, state) end end #[debug] Unhandled update: %{"callback_query" => # %{ # "chat_instance" => "-7948978714441865930", "data" => "evolu.net/#dmz", # "from" => %{"first_name" => "J", "id" => 2075406, "is_bot" => false, "language_code" => "en", "username" => "ahref"}, # "id" => "8913804780149600", # "message" => %{"chat" => %{"first_name" => "J", "id" => 2075406, "type" => "private", "username" => "ahref"}, # "date" => 1591098553, "from" => %{"first_name" => "devbeautte", "id" => 1293058838, "is_bot" => true, "username" => "devbeauttebot"}, # "message_id" => 62, # "reply_markup" => %{"inline_keyboard" => [[%{"callback_data" => "random/#", "text" => "random/#"}, # %{"callback_data" => "evolu.net/#dmz", "text" => "evolu.net/#dmz"}]]}, # "text" => "Where should I send the file?"} # } # , "update_id" => 218161568} #def handle_update(t, %{"callback_query" => cb = %{"data" => "resend", "id" => id, "message" => m = %{"message_id" => m_id, "chat" => %{"id" => chat_id}, "reply_to_message" => op}}}) do #end def handle_update(%{"callback_query" => cb = %{"data" => "start-upload:"<>target, "id" => id, "message" => m = %{"message_id" => m_id, "chat" => %{"id" => chat_id}, "reply_to_message" => op}}}, t, state) do account = Nola.Account.find_meta_account("telegram-id", chat_id) if account do target = case String.split(target, "/") do - ["everywhere"] -> IRC.Membership.of_account(account) + ["everywhere"] -> Nola.Membership.of_account(account) [net, chan] -> [{net, chan}] end Telegram.Api.request(t, "editMessageText", chat_id: chat_id, message_id: m_id, text: "Processing...", reply_markup: %{}) {content, type} = cond do op["photo"] -> {op["photo"], ""} op["voice"] -> {op["voice"], " a voice message"} op["video"] -> {op["video"], ""} op["document"] -> {op["document"], ""} op["animation"] -> {op["animation"], ""} end file = if is_list(content) && Enum.count(content) > 1 do Enum.sort_by(content, fn(p) -> p["file_size"] end, &>=/2) |> List.first() else content end file_id = file["file_id"] file_unique_id = file["file_unique_id"] text = if(op["caption"], do: ": "<> op["caption"] <> "", else: "") resend = %{"inline_keyboard" => [ [%{"text" => "re-share", "callback_data" => "resend"}] ]} spawn(fn() -> with \ {:ok, file} <- Telegram.Api.request(t, "getFile", file_id: file_id), path = "https://api.telegram.org/file/bot#{t}/#{file["file_path"]}", {:ok, %HTTPoison.Response{status_code: 200, body: body}} <- HTTPoison.get(path), <> = body, {:ok, magic} <- GenMagic.Pool.perform(Nola.GenMagic, {:bytes, smol_body}), bucket = Application.get_env(:nola, :s3, []) |> Keyword.get(:bucket), ext = Path.extname(file["file_path"]), s3path = "#{account.id}/#{file_unique_id}#{ext}", Telegram.Api.request(t, "editMessageText", chat_id: chat_id, message_id: m_id, text: "*Uploading...*", reply_markup: %{}, parse_mode: "MarkdownV2"), s3req = ExAws.S3.put_object(bucket, s3path, body, acl: :public_read, content_type: magic.mime_type), {:ok, _} <- ExAws.request(s3req) do path = NolaWeb.Router.Helpers.url(NolaWeb.Endpoint) <> "/files/#{s3path}" sent = for {net, chan} <- target do txt = "sent#{type}#{text} #{path}" IRC.send_message_as(account, net, chan, txt) "#{net}/#{chan}" end if caption = op["caption"], do: as_irc_message(chat_id, caption, account) text = "Sent on " <> Enum.join(sent, ", ") <> " !" Telegram.Api.request(t, "editMessageText", chat_id: chat_id, message_id: m_id, text: "_Sent!_", reply_markup: %{}, parse_mode: "MarkdownV2") else error -> Telegram.Api.request(t, "editMessageText", chat_id: chat_id, message_id: m_id, text: "Something failed.", reply_markup: %{}, parse_mode: "MarkdownV2") Logger.error("Failed upload from Telegram: #{inspect error}") end end) end {:ok, state} end def handle_update(%{"message" => m = %{"chat" => %{"id" => id, "type" => "private"}, "text" => text}}, _, state) do account = Nola.Account.find_meta_account("telegram-id", id) if account do as_irc_message(id, text, account) end {:ok, state} end def handle_update(m, _, state) do Logger.debug("Unhandled update: #{inspect m}") {:ok, state} end @impl Telegram.ChatBot def handle_info(info, %{room_state: room_state}) do {:ok, room_state} = Nola.TelegramRoom.handle_info(info, room_state) {:ok, %{room_state: room_state}} end def handle_info(_info, state) do {:ok, state} end defp as_irc_message(id, text, account) do reply_fun = fn(text) -> send_message(id, text) end trigger_text = cond do String.starts_with?(text, "/") -> "/"<>text = text "!"<>text Enum.any?(IRC.Connection.triggers(), fn({trigger, _}) -> String.starts_with?(text, trigger) end) -> text true -> "!"<>text end message = %IRC.Message{ id: FlakeId.get(), transport: :telegram, network: "telegram", channel: nil, text: text, account: account, sender: %ExIRC.SenderInfo{nick: account.name}, replyfun: reply_fun, trigger: IRC.Connection.extract_trigger(trigger_text), at: nil } IRC.Connection.publish(message, ["messages:private", "messages:telegram", "telegram/#{account.id}:messages"]) message end defp start_upload(_type, %{"message" => m = %{"chat" => %{"id" => id, "type" => "private"}}}, token, state) do account = Nola.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) + targets = Nola.Membership.of_account(account) |> Enum.map(fn({net, chan}) -> "#{net}/#{chan}" end) |> Enum.map(fn(i) -> %{"text" => i, "callback_data" => "start-upload:#{i}"} end) kb = if Enum.count(targets) > 1 do [%{"text" => "everywhere", "callback_data" => "start-upload:everywhere"}] ++ targets else targets end |> Enum.chunk_every(2) keyboard = %{"inline_keyboard" => kb} Telegram.Api.request(token, "sendMessage", chat_id: id, text: "Where should I send this file?", reply_markup: keyboard, reply_to_message_id: m["message_id"], parse_mode: "MarkdownV2") end {:ok, state} end end diff --git a/lib/web/context_plug.ex b/lib/web/context_plug.ex index 7289763..fcdf42f 100644 --- a/lib/web/context_plug.ex +++ b/lib/web/context_plug.ex @@ -1,92 +1,92 @@ defmodule NolaWeb.ContextPlug do import Plug.Conn import Phoenix.Controller def init(opts \\ []) do opts || [] end def get_account(conn) do cond do get_session(conn, :account) -> get_session(conn, :account) get_session(conn, :oidc_id) -> if account = Nola.Account.find_meta_account("identity-id", get_session(conn, :oidc_id)), do: account.id true -> nil end end def call(conn, opts) do account = with \ {:account, account_id} when is_binary(account_id) <- {:account, get_account(conn)}, {:account, account} when not is_nil(account) <- {:account, Nola.Account.get(account_id)} do account else _ -> nil end network = Map.get(conn.params, "network") network = if network == "-", do: nil, else: network oidc_account = Nola.Account.find_meta_account("identity-id", get_session(conn, :oidc_id)) conns = IRC.Connection.get_network(network) chan = if c = Map.get(conn.params, "chan") do NolaWeb.reformat_chan(c) end chan_conn = IRC.Connection.get_network(network, chan) memberships = if account do - IRC.Membership.of_account(account) + Nola.Membership.of_account(account) end auth_required = cond do Keyword.get(opts, :restrict) == :public -> false account == nil -> true network == nil -> false Keyword.get(opts, :restrict) == :logged_in -> false network && chan -> !Enum.member?(memberships, {network, chan}) network -> !Enum.any?(memberships, fn({n, _}) -> n == network end) end bot = cond do network && chan && chan_conn -> chan_conn.nick network && conns -> conns.nick true -> nil end cond do account && auth_required -> conn |> put_status(404) |> text("Page not found") |> halt() auth_required -> conn |> put_status(403) |> render(NolaWeb.AlcoologView, "auth.html", bot: bot, no_header: true, network: network) |> halt() (network && !conns) -> conn |> put_status(404) |> text("Page not found") |> halt() (chan && !chan_conn) -> conn |> put_status(404) |> text("Page not found") |> halt() true -> conn = conn |> assign(:network, network) |> assign(:chan, chan) |> assign(:bot, bot) |> assign(:account, account) |> assign(:oidc_account, oidc_account) |> assign(:memberships, memberships) end end end diff --git a/lib/web/controllers/alcoolog_controller.ex b/lib/web/controllers/alcoolog_controller.ex index dc09517..6337da5 100644 --- a/lib/web/controllers/alcoolog_controller.ex +++ b/lib/web/controllers/alcoolog_controller.ex @@ -1,323 +1,323 @@ defmodule NolaWeb.AlcoologController do use NolaWeb, :controller require Logger plug NolaWeb.ContextPlug when action not in [:token] plug NolaWeb.ContextPlug, [restrict: :public] when action in [:token] def token(conn, %{"token" => token}) do case Nola.Token.lookup(token) do {:ok, {:alcoolog, :index, network, channel}} -> index(conn, nil, network, channel) err -> Logger.debug("AlcoologControler: token #{inspect err} invalid") conn |> put_status(404) |> text("Page not found") end end def nick(conn = %{assigns: %{account: account}}, params = %{"network" => network, "nick" => nick}) do profile_account = Nola.Account.find_always_by_nick(network, nick, nick) days = String.to_integer(Map.get(params, "days", "180")) - friend? = Enum.member?(IRC.Membership.friends(account), profile_account.id) + friend? = Enum.member?(Nola.Membership.friends(account), profile_account.id) if friend? do stats = Nola.Plugins.Alcoolog.get_full_statistics(profile_account.id) history = for {{nick, ts}, points, active, cl, deg, type, descr, meta} <- Nola.Plugins.Alcoolog.nick_history(profile_account) do %{ at: ts |> DateTime.from_unix!(:millisecond), points: points, active: active, cl: cl, deg: deg, type: type, description: descr, meta: meta } end history = Enum.sort(history, &(DateTime.compare(&1.at, &2.at) != :lt)) |> IO.inspect() conn |> assign(:title, "alcoolog #{nick}") |> render("user.html", network: network, profile: profile_account, days: days, nick: nick, history: history, stats: stats) else conn |> put_status(404) |> text("Page not found") end end def nick_stats_json(conn = %{assigns: %{account: account}}, params = %{"network" => network, "nick" => nick}) do profile_account = Nola.Account.find_always_by_nick(network, nick, nick) - friend? = Enum.member?(IRC.Membership.friends(account), profile_account.id) + friend? = Enum.member?(Nola.Membership.friends(account), profile_account.id) if friend? do stats = Nola.Plugins.Alcoolog.get_full_statistics(profile_account.id) conn |> put_resp_content_type("application/json") |> text(Jason.encode!(stats)) else conn |> put_status(404) |> json([]) end end def nick_gls_json(conn = %{assigns: %{account: account}}, params = %{"network" => network, "nick" => nick}) do profile_account = Nola.Account.find_always_by_nick(network, nick, nick) - friend? = Enum.member?(IRC.Membership.friends(account), profile_account.id) + friend? = Enum.member?(Nola.Membership.friends(account), profile_account.id) count = String.to_integer(Map.get(params, "days", "180")) if friend? do data = Nola.Plugins.Alcoolog.user_over_time_gl(profile_account, count) delay = count*((24 * 60)*60) now = DateTime.utc_now() start_date = DateTime.utc_now() |> DateTime.shift_zone!("Europe/Paris", Tzdata.TimeZoneDatabase) |> DateTime.add(-delay, :second, Tzdata.TimeZoneDatabase) |> DateTime.to_date() |> Date.to_erl() filled = (:calendar.date_to_gregorian_days(start_date) .. :calendar.date_to_gregorian_days(DateTime.utc_now |> DateTime.to_date() |> Date.to_erl)) |> Enum.to_list |> Enum.map(&(:calendar.gregorian_days_to_date(&1))) |> Enum.map(&Date.from_erl!(&1)) |> Enum.map(fn(date) -> %{date: date, gls: Map.get(data, date, 0)} end) |> Enum.sort(&(Date.compare(&1.date, &2.date) != :gt)) conn |> put_resp_content_type("application/json") |> text(Jason.encode!(filled)) else conn |> put_status(404) |> json([]) end end def nick_volumes_json(conn = %{assigns: %{account: account}}, params = %{"network" => network, "nick" => nick}) do profile_account = Nola.Account.find_always_by_nick(network, nick, nick) - friend? = Enum.member?(IRC.Membership.friends(account), profile_account.id) + friend? = Enum.member?(Nola.Membership.friends(account), profile_account.id) count = String.to_integer(Map.get(params, "days", "180")) if friend? do data = Nola.Plugins.Alcoolog.user_over_time(profile_account, count) delay = count*((24 * 60)*60) now = DateTime.utc_now() start_date = DateTime.utc_now() |> DateTime.shift_zone!("Europe/Paris", Tzdata.TimeZoneDatabase) |> DateTime.add(-delay, :second, Tzdata.TimeZoneDatabase) |> DateTime.to_date() |> Date.to_erl() filled = (:calendar.date_to_gregorian_days(start_date) .. :calendar.date_to_gregorian_days(DateTime.utc_now |> DateTime.to_date() |> Date.to_erl)) |> Enum.to_list |> Enum.map(&(:calendar.gregorian_days_to_date(&1))) |> Enum.map(&Date.from_erl!(&1)) |> Enum.map(fn(date) -> %{date: date, volumes: Map.get(data, date, 0)} end) |> Enum.sort(&(Date.compare(&1.date, &2.date) != :gt)) conn |> put_resp_content_type("application/json") |> text(Jason.encode!(filled)) else conn |> put_status(404) |> json([]) end end def nick_log_json(conn = %{assigns: %{account: account}}, %{"network" => network, "nick" => nick}) do profile_account = Nola.Account.find_always_by_nick(network, nick, nick) - friend? = Enum.member?(IRC.Membership.friends(account), profile_account.id) + friend? = Enum.member?(Nola.Membership.friends(account), profile_account.id) if friend? do history = for {{nick, ts}, points, active, cl, deg, type, descr, meta} <- Nola.Plugins.Alcoolog.nick_history(profile_account) do %{ at: ts |> DateTime.from_unix!(:millisecond) |> DateTime.to_iso8601(), points: points, active: active, cl: cl, deg: deg, type: type, description: descr, meta: meta } end last = List.last(history) {_, active} = Nola.Plugins.Alcoolog.user_stats(profile_account) last = %{last | active: active, at: DateTime.utc_now() |> DateTime.to_iso8601()} history = history ++ [last] conn |> put_resp_content_type("application/json") |> text(Jason.encode!(history)) else conn |> put_status(404) |> json([]) end end def nick_history_json(conn = %{assigns: %{account: account}}, %{"network" => network, "nick" => nick}) do profile_account = Nola.Account.find_always_by_nick(network, nick, nick) - friend? = Enum.member?(IRC.Membership.friends(account), profile_account.id) + friend? = Enum.member?(Nola.Membership.friends(account), profile_account.id) if friend? do history = for {_, date, value} <- Nola.Plugs.AlcoologAnnouncer.log(profile_account) do %{date: DateTime.to_iso8601(date), value: value} end conn |> put_resp_content_type("application/json") |> text(Jason.encode!(history)) else conn |> put_status(404) |> json([]) end end def index(conn = %{assigns: %{account: account}}, %{"network" => network, "chan" => channel}) do index(conn, account, network, NolaWeb.reformat_chan(channel)) end def index(conn = %{assigns: %{account: account}}, _) do index(conn, account, nil, nil) end #def index(conn, params) do # network = Map.get(params, "network") # chan = if c = Map.get(params, "chan") do # NolaWeb.reformat_chan(c) # end # irc_conn = if network do # IRC.Connection.get_network(network, chan) # end # bot = if(irc_conn, do: irc_conn.nick)# # # conn # |> put_status(403) # |> render("auth.html", network: network, channel: chan, irc_conn: conn, bot: bot) #end def index(conn, account, network, channel) do aday = ((24 * 60)*60) now = DateTime.utc_now() before7 = now |> DateTime.add(-(7*aday), :second) |> DateTime.to_unix(:millisecond) before15 = now |> DateTime.add(-(15*aday), :second) |> DateTime.to_unix(:millisecond) before31 = now |> DateTime.add(-(31*aday), :second) |> DateTime.to_unix(:millisecond) #match = :ets.fun2ms(fn(obj = {{^nick, date}, _, _, _, _}) when date > before -> obj end) match = [ {{{:_, :"$1"}, :_, :_, :_, :_, :_, :_, :_}, [ {:>, :"$1", {:const, before15}}, ], [:"$_"]} ] # tuple ets: {{nick, date}, volumes, current, nom, commentaire} - members = IRC.Membership.expanded_members_or_friends(account, network, channel) + members = Nola.Membership.expanded_members_or_friends(account, network, channel) members_ids = Enum.map(members, fn({account, _, nick}) -> account.id end) member_names = Enum.reduce(members, %{}, fn({account, _, nick}, acc) -> Map.put(acc, account.id, nick) end) drinks = :ets.select(Nola.Plugins.Alcoolog.ETS, match) |> Enum.filter(fn({{account, _}, _vol, _cur, _cl, _deg, _name, _cmt, _meta}) -> Enum.member?(members_ids, account) end) |> Enum.map(fn({{account, _}, _, _, _, _, _, _, _} = object) -> {object, Map.get(member_names, account)} end) |> Enum.sort_by(fn({{{_, ts}, _, _, _, _, _, _, _}, _}) -> ts end, &>/2) stats = Nola.Plugins.Alcoolog.get_channel_statistics(account, network, channel) top = Enum.reduce(drinks, %{}, fn({{{account_id, _}, vol, _, _, _, _, _, _}, _}, acc) -> nick = Map.get(member_names, account_id) all = Map.get(acc, nick, 0) Map.put(acc, nick, all + vol) end) |> Enum.sort_by(fn({_nick, count}) -> count end, &>/2) # {date, single_peak} # conn |> assign(:title, "alcoolog") |> render("index.html", network: network, channel: channel, drinks: drinks, top: top, stats: stats) end def index_gls_json(conn = %{assigns: %{account: account}}, %{"network" => network, "chan" => channel}) do count = 30 channel = NolaWeb.reformat_chan(channel) - members = IRC.Membership.expanded_members_or_friends(account, network, channel) + members = Nola.Membership.expanded_members_or_friends(account, network, channel) members_ids = Enum.map(members, fn({account, _, nick}) -> account.id end) member_names = Enum.reduce(members, %{}, fn({account, _, nick}, acc) -> Map.put(acc, account.id, nick) end) delay = count*((24 * 60)*60) now = DateTime.utc_now() start_date = DateTime.utc_now() |> DateTime.shift_zone!("Europe/Paris", Tzdata.TimeZoneDatabase) |> DateTime.add(-delay, :second, Tzdata.TimeZoneDatabase) |> DateTime.to_date() |> Date.to_erl() filled = (:calendar.date_to_gregorian_days(start_date) .. :calendar.date_to_gregorian_days(DateTime.utc_now |> DateTime.to_date() |> Date.to_erl)) |> Enum.to_list |> Enum.map(&(:calendar.gregorian_days_to_date(&1))) |> Enum.map(&Date.from_erl!(&1)) |> Enum.map(fn(date) -> {date, (for {a, _, _} <- members, into: Map.new, do: {Map.get(member_names, a.id, a.id), 0})} end) |> Enum.into(Map.new) gls = Enum.reduce(members, filled, fn({account, _, _}, gls) -> Enum.reduce(Nola.Plugins.Alcoolog.user_over_time_gl(account, count), gls, fn({date, gl}, gls) -> u = Map.get(gls, date, %{}) |> Map.put(Map.get(member_names, account.id, account.id), gl) Map.put(gls, date, u) end) end) dates = (:calendar.date_to_gregorian_days(start_date) .. :calendar.date_to_gregorian_days(DateTime.utc_now |> DateTime.to_date() |> Date.to_erl)) |> Enum.to_list |> Enum.map(&(:calendar.gregorian_days_to_date(&1))) |> Enum.map(&Date.from_erl!(&1)) filled2 = Enum.map(member_names, fn({_, name}) -> history = (:calendar.date_to_gregorian_days(start_date) .. :calendar.date_to_gregorian_days(DateTime.utc_now |> DateTime.to_date() |> Date.to_erl)) |> Enum.to_list |> Enum.map(&(:calendar.gregorian_days_to_date(&1))) |> Enum.map(&Date.from_erl!(&1)) |> Enum.map(fn(date) -> get_in(gls, [date, name]) #%{date: date, gl: get_in(gls, [date, name])} end) if Enum.all?(history, fn(x) -> x == 0 end) do nil else %{name: name, history: history} end end) |> Enum.filter(fn(x) -> x end) conn |> put_resp_content_type("application/json") |> text(Jason.encode!(%{labels: dates, data: filled2})) end def minisync(conn, %{"user_id" => user_id, "key" => key, "value" => value}) do account = Nola.Account.get(user_id) if account do ds = Nola.Plugins.Alcoolog.data_state() meta = Nola.Plugins.Alcoolog.get_user_meta(ds, account.id) case Float.parse(value) do {val, _} -> new_meta = Map.put(meta, String.to_existing_atom(key), val) Nola.Plugins.Alcoolog.put_user_meta(ds, account.id, new_meta) _ -> conn |> put_status(:unprocessable_entity) |> text("invalid value") end else conn |> put_status(:not_found) |> text("not found") end end end diff --git a/lib/web/controllers/irc_controller.ex b/lib/web/controllers/irc_controller.ex index d6114e6..441cbe7 100644 --- a/lib/web/controllers/irc_controller.ex +++ b/lib/web/controllers/irc_controller.ex @@ -1,101 +1,101 @@ defmodule NolaWeb.IrcController do use NolaWeb, :controller plug NolaWeb.ContextPlug def index(conn, params) do network = Map.get(params, "network") channel = if c = Map.get(params, "chan"), do: NolaWeb.reformat_chan(c) commands = for mod <- Enum.uniq([Nola.Plugins.Account] ++ Nola.Plugins.enabled()) do if is_atom(mod) do identifier = Module.split(mod) |> List.last |> Macro.underscore {identifier, mod.irc_doc()} end end |> Enum.filter(& &1) |> Enum.filter(fn({_, doc}) -> doc end) members = cond do network && channel -> Enum.map(IRC.UserTrack.channel(network, channel), fn(tuple) -> IRC.UserTrack.User.from_tuple(tuple) end) true -> - IRC.Membership.of_account(conn.assigns.account) + Nola.Membership.of_account(conn.assigns.account) end render conn, "index.html", network: network, commands: commands, channel: channel, members: members end def txt(conn, %{"name" => name}) do if String.contains?(name, ".txt") do name = String.replace(name, ".txt", "") data = data() if Map.has_key?(data, name) do lines = Enum.join(data[name], "\n") text(conn, lines) else conn |> put_status(404) |> text("Not found") end else do_txt(conn, name) end end def txt(conn, _), do: do_txt(conn, nil) defp do_txt(conn, nil) do doc = Nola.IRC.Txt.irc_doc() data = data() main = Enum.filter(data, fn({trigger, _}) -> !String.contains?(trigger, ".") end) |> Enum.into(Map.new) system = Enum.filter(data, fn({trigger, _}) -> String.contains?(trigger, ".") end) |> Enum.into(Map.new) lines = Enum.reduce(main, 0, fn({_, lines}, acc) -> acc + Enum.count(lines) end) conn |> assign(:title, "txt") |> render("txts.html", data: main, doc: doc, files: Enum.count(main), lines: lines, system: system) end defp do_txt(conn, txt) do data = data() base_url = cond do conn.assigns[:chan] -> "/#{conn.assigns.network}/#{NolaWeb.format_chan(conn.assigns.chan)}" true -> "/-" end if lines = Map.get(data, txt) do lines = Enum.map(lines, fn(line) -> line |> String.split("\\\\") |> Enum.intersperse(Phoenix.HTML.Tag.tag(:br)) end) conn |> assign(:breadcrumbs, [{"txt", "#{base_url}/txt"}]) |> assign(:title, "#{txt}.txt") |> render("txt.html", name: txt, data: lines, doc: nil) else conn |> put_status(404) |> text("Not found") end end defp data() do dir = Application.get_env(:nola, :data_path) <> "/irc.txt/" Path.wildcard(dir <> "/*.txt") |> Enum.reduce(%{}, fn(path, m) -> path = String.split(path, "/") file = List.last(path) key = String.replace(file, ".txt", "") data = dir <> file |> File.read! |> String.split("\n") |> Enum.reject(fn(line) -> cond do line == "" -> true !line -> true true -> false end end) Map.put(m, key, data) end) |> Enum.sort |> Enum.into(Map.new) end end diff --git a/lib/web/controllers/page_controller.ex b/lib/web/controllers/page_controller.ex index c5d0a57..a6b85b6 100644 --- a/lib/web/controllers/page_controller.ex +++ b/lib/web/controllers/page_controller.ex @@ -1,53 +1,53 @@ defmodule NolaWeb.PageController do use NolaWeb, :controller plug NolaWeb.ContextPlug when action not in [:token] plug NolaWeb.ContextPlug, [restrict: :public] when action in [:token] def token(conn, %{"token" => token}) do with \ {:ok, account, perks} <- Nola.AuthToken.lookup(token) do IO.puts("Authenticated account #{inspect account}") conn = put_session(conn, :account, account) case perks do nil -> redirect(conn, to: "/") {:redirect, path} -> redirect(conn, to: path) {:external_redirect, url} -> redirect(conn, external: url) end else z -> IO.inspect(z) text(conn, "Error: invalid or expired token") end end def index(conn = %{assigns: %{account: account}}, _) do - memberships = IRC.Membership.of_account(account) + memberships = Nola.Membership.of_account(account) users = IRC.UserTrack.find_by_account(account) metas = Nola.Account.get_all_meta(account) predicates = Nola.Account.get_predicates(account) conn |> assign(:title, account.name) |> render("user.html", users: users, memberships: memberships, metas: metas, predicates: predicates) end def irc(conn, _) do bot_helps = for mod <- Nola.IRC.env(:handlers) do mod.irc_doc() end render conn, "irc.html", bot_helps: bot_helps end def authenticate(conn, _) do with \ {:account, account_id} when is_binary(account_id) <- {:account, get_session(conn, :account)}, {:account, account} when not is_nil(account) <- {:account, Nola.Account.get(account_id)} do assign(conn, :account, account) else _ -> conn end end end diff --git a/lib/web/live/chat_live.ex b/lib/web/live/chat_live.ex index 0513cc2..2d5e289 100644 --- a/lib/web/live/chat_live.ex +++ b/lib/web/live/chat_live.ex @@ -1,120 +1,120 @@ defmodule NolaWeb.ChatLive do use Phoenix.LiveView use Phoenix.HTML require Logger def mount(%{"network" => network, "chan" => chan}, %{"account" => account_id}, socket) do chan = NolaWeb.reformat_chan(chan) connection = IRC.Connection.get_network(network, chan) account = Nola.Account.get(account_id) - membership = IRC.Membership.of_account(Nola.Account.get("DRgpD4fLf8PDJMLp8Dtb")) + membership = Nola.Membership.of_account(Nola.Account.get("DRgpD4fLf8PDJMLp8Dtb")) if account && connection && Enum.member?(membership, {connection.network, chan}) do {:ok, _} = Registry.register(Nola.PubSub, "#{connection.network}:events", plugin: __MODULE__) for t <- ["messages", "triggers", "outputs", "events"] do {:ok, _} = Registry.register(Nola.PubSub, "#{connection.network}/#{chan}:#{t}", plugin: __MODULE__) end IRC.PuppetConnection.start(account, connection) users = IRC.UserTrack.channel(connection.network, chan) |> Enum.map(fn(tuple) -> IRC.UserTrack.User.from_tuple(tuple) end) |> Enum.reduce(Map.new, fn(user = %{id: id}, acc) -> Map.put(acc, id, user) end) backlog = case Nola.IRC.Buffer.select_buffer(connection.network, chan) do {backlog, _} -> {backlog, _} = Enum.reduce(backlog, {backlog, nil}, &reduce_contextual_event/2) Enum.reverse(backlog) _ -> [] end socket = socket |> assign(:connection_id, connection.id) |> assign(:network, connection.network) |> assign(:chan, chan) |> assign(:title, "live") |> assign(:channel, chan) |> assign(:account_id, account.id) |> assign(:backlog, backlog) |> assign(:users, users) |> assign(:counter, 0) {:ok, socket} else {:ok, redirect(socket, to: "/")} end end def handle_event("send", %{"message" => %{"text" => text}}, socket) do account = Nola.Account.get(socket.assigns.account_id) IRC.send_message_as(account, socket.assigns.network, socket.assigns.channel, text, true) {:noreply, assign(socket, :counter, socket.assigns.counter + 1)} end def handle_info({:irc, :event, event = %{type: :join, user_id: id}}, socket) do if user = IRC.UserTrack.lookup(id) do socket = socket |> assign(:users, Map.put(socket.assigns.users, id, user)) |> append_to_backlog(event) {:noreply, socket} else {:noreply, socket} end end def handle_info({:irc, :event, event = %{type: :nick, user_id: id, nick: nick}}, socket) do socket = socket |> assign(:users, update_in(socket.assigns.users, [id, :nick], nick)) |> append_to_backlog(event) {:noreply, socket} end def handle_info({:irc, :event, event = %{type: :quit, user_id: id}}, socket) do socket = socket |> assign(:users, Map.delete(socket.assigns.users, id)) |> append_to_backlog(event) {:noreply, socket} end def handle_info({:irc, :event, event = %{type: :part, user_id: id}}, socket) do socket = socket |> assign(:users, Map.delete(socket.assigns.users, id)) |> append_to_backlog(event) {:noreply, socket} end def handle_info({:irc, :trigger, _, message}, socket) do handle_info({:irc, nil, message}, socket) end def handle_info({:irc, :text, message}, socket) do IO.inspect({:live_message, message}) socket = socket |> append_to_backlog(message) {:noreply, socket} end def handle_info(info, socket) do Logger.debug("Unhandled info: #{inspect info}") {:noreply, socket} end defp append_to_backlog(socket, line) do {add, _} = reduce_contextual_event(line, {[], List.last(socket.assigns.backlog)}) assign(socket, :backlog, socket.assigns.backlog ++ add) end defp reduce_contextual_event(line, {acc, nil}) do {[line | acc], line} end defp reduce_contextual_event(line, {acc, last}) do if NaiveDateTime.to_date(last.at) != NaiveDateTime.to_date(line.at) do {[%{type: :day_changed, date: NaiveDateTime.to_date(line.at), at: nil}, line | acc], line} else {[line | acc], line} end end end