diff --git a/lib/irc.ex b/lib/irc.ex index 3898068..0fa1b79 100644 --- a/lib/irc.ex +++ b/lib/irc.ex @@ -1,49 +1,49 @@ defmodule Nola.Irc do require Logger def env(), do: Nola.env(:irc) def env(key, default \\ nil), do: Keyword.get(env(), key, default) def send_message_as(account, network, channel, text, force_puppet \\ false) do connection = Nola.Irc.Connection.get_network(network) if connection && (force_puppet || Nola.Irc.PuppetConnection.whereis(account, connection)) do Nola.Irc.PuppetConnection.start_and_send_message(account, connection, channel, text) else user = Nola.UserTrack.find_by_account(network, account) nick = if(user, do: user.nick, else: account.name) Nola.Irc.Connection.broadcast_message(network, channel, "<#{nick}> #{text}") end end def admin?(%Message{sender: sender}), do: admin?(sender) def admin?(%{nick: nick, user: user, host: host}) do - for {n, u, h} <- Nola.IRC.env(:admins, []) do + for {n, u, h} <- Nola.Irc.env(:admins, []) do admin_part_match?(n, nick) && admin_part_match?(u, user) && admin_part_match?(h, host) end |> Enum.any? end defp admin_part_match?(:_, _), do: true defp admin_part_match?(a, a), do: true defp admin_part_match?(_, _), do: false def application_childs do import Supervisor.Spec Nola.Irc.Connection.setup() [ worker(Registry, [[keys: :duplicate, name: Nola.Irc.ConnectionPubSub]], id: :registr_irc_conn), supervisor(Nola.Irc.Connection.Supervisor, [], [name: Nola.Irc.Connection.Supervisor]), supervisor(Nola.Irc.PuppetConnection.Supervisor, [], [name: Nola.Irc.PuppetConnection.Supervisor]), ] end # Start plugins first to let them get on connection events. def after_start() do Logger.info("Starting connections") Nola.Irc.Connection.start_all() end end diff --git a/lib/irc/admin_handler.ex b/lib/irc/admin_handler.ex index a462789..cb953db 100644 --- a/lib/irc/admin_handler.ex +++ b/lib/irc/admin_handler.ex @@ -1,41 +1,41 @@ defmodule Nola.Irc.AdminHandler do @moduledoc """ # admin !op op; requiert admin """ def irc_doc, do: nil def start_link(client) do GenServer.start_link(__MODULE__, [client]) end def init([client]) do ExIRC.Client.add_handler client, self - :ok = IRC.register("op") + {:ok, _} = Registry.register(Nola.PubSub, "op", []) {:ok, client} end def handle_info({:irc, :trigger, "op", m = %Nola.Message{trigger: %Nola.Trigger{type: :bang}, sender: sender}}, client) do - if IRC.admin?(sender) do + if Nola.Irc.admin?(sender) do m.replyfun.({:mode, "+o"}) else m.replyfun.({:kick, "non"}) end {:noreply, client} end def handle_info({:joined, chan, sender}, client) do - if IRC.admin?(sender) do + if Nola.Irc.admin?(sender) do ExIRC.Client.mode(client, chan, "+o", sender.nick) end {:noreply, client} end def handle_info(msg, client) do {:noreply, client} end end diff --git a/lib/matrix/room.ex b/lib/matrix/room.ex index 299d7cc..e2965a5 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 Nola.Memberships ? acc end else acc end end) |> Enum.filter(& &1) for m <- members, do: Nola.UserTrack.joined(state.id, %{network: "matrix", nick: m, user: m, host: "matrix."}, [], true) accounts = Nola.UserTrack.channel(state.network, state.channel) |> Enum.filter(& &1) |> Enum.map(fn(tuple) -> Nola.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 = %Nola.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) Nola.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 Nola.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) + Nola.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 = Nola.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/user_track.ex b/lib/nola/user_track.ex index f6a02ae..c1218b0 100644 --- a/lib/nola/user_track.ex +++ b/lib/nola/user_track.ex @@ -1,329 +1,329 @@ defmodule Nola.UserTrack do @moduledoc """ User Track DB & Utilities """ @ets Nola.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 || Nola.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: Nola.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 Nola.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 = if Nola.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: Nola.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 Nola.Membership.touch(user.account, sender.network, channel) end Storage.op(fn(ets) -> :ets.insert(ets, User.to_tuple(user)) end) Nola.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(%Nola.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: Nola.Membership.touch(account, network, chan) if !m.account do {:ok, %Nola.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 Nola.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)) Nola.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 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)) Nola.Irc.Connection.publish_event({network, channel}, %{type: :part, user_id: user.id, account_id: user.account, reason: nil}) else Nola.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 Nola.Membership.touch(user.account, sender.network, channel) end Nola.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/plugins/account.ex b/lib/plugins/account.ex index 20abab7..242b290 100644 --- a/lib/plugins/account.ex +++ b/lib/plugins/account.ex @@ -1,188 +1,188 @@ defmodule Nola.Plugins.Account do @moduledoc """ # Account * **account** Get current account id and token * **auth `` ``** Authenticate and link the current nickname to an account * **auth** list authentications methods * **whoami** list currently authenticated users * **web** get a one-time login link to web * **enable-telegram** Link a Telegram account * **enable-sms** Link a SMS number * **enable-untappd** Link a Untappd account * **set-name** set account name * **setusermeta puppet-nick ``** Set puppet IRC nickname """ def irc_doc, do: @moduledoc def start_link(), do: GenServer.start_link(__MODULE__, [], name: __MODULE__) def init(_) do {:ok, _} = Registry.register(Nola.PubSub, "messages:private", []) {:ok, nil} end def handle_info({:irc, :text, m = %Nola.Message{account: account, text: "help"}}, state) do text = [ "account: show current account and auth token", "auth: show authentications methods", "whoami: list authenticated users", "set-name : set account name", "web: login to web", "enable-sms | disable-sms: enable/change or disable sms", "enable-telegram: link/change telegram", "enable-untappd: link untappd account", "getmeta: show meta datas", "setusermeta: set user meta", ] m.replyfun.(text) {:noreply, state} end def handle_info({:irc, :text, m = %Nola.Message{account: account, text: "auth"}}, state) do spec = [{{:"$1", :"$2"}, [{:==, :"$2", {:const, account.id}}], [:"$1"]}] predicates = :dets.select(Nola.Account.file("predicates"), spec) text = for {net, {key, value}} <- predicates, do: "#{net}: #{to_string(key)}: #{value}" m.replyfun.(text) {:noreply, state} end def handle_info({:irc, :text, m = %Nola.Message{account: account, text: "whoami"}}, state) do users = for user <- Nola.UserTrack.find_by_account(m.account) do chans = Enum.map(user.privileges, fn({chan, _}) -> chan end) |> Enum.join(" ") "#{user.network} - #{user.nick}!#{user.username}@#{user.host} - #{chans}" end m.replyfun.(users) {:noreply, state} end def handle_info({:irc, :text, m = %Nola.Message{account: account, text: "account"}}, state) do account = Nola.Account.lookup(m.sender) text = ["Account Id: #{account.id}", "Authenticate to this account from another network: \"auth #{account.id} #{account.token}\" to the other bot!"] m.replyfun.(text) {:noreply, state} end def handle_info({:irc, :text, m = %Nola.Message{sender: sender, text: "auth"<>_}}, state) do #account = Nola.Account.lookup(m.sender) case String.split(m.text, " ") do ["auth", id, token] -> join_account(m, id, token) _ -> m.replyfun.("Invalid parameters") end {:noreply, state} end def handle_info({:irc, :text, m = %Nola.Message{account: account, text: "set-name "<>name}}, state) do Nola.Account.update_account_name(account, name) m.replyfun.("Name changed: #{name}") {:noreply, state} end def handle_info({:irc, :text, m = %Nola.Message{text: "disable-sms"}}, state) do if Nola.Account.get_meta(m.account, "sms-number") do Nola.Account.delete_meta(m.account, "sms-number") m.replfyun.("SMS disabled.") else m.replyfun.("SMS already disabled.") end {:noreply, state} end def handle_info({:irc, :text, m = %Nola.Message{text: "web"}}, state) do auth_url = Untappd.auth_url() login_url = Nola.AuthToken.new_url(m.account.id, nil) m.replyfun.("-> " <> login_url) {:noreply, state} end def handle_info({:irc, :text, m = %Nola.Message{text: "enable-sms"}}, state) do code = String.downcase(EntropyString.small_id()) Nola.Account.put_meta(m.account, "sms-validation-code", code) Nola.Account.put_meta(m.account, "sms-validation-target", m.network) - number = Nola.IRC.Sms.my_number() + number = Nola.Plugin.Sms.my_number() text = "To enable or change your number for SMS messaging, please send:" <> " \"enable #{code}\" to #{number}" m.replyfun.(text) {:noreply, state} end def handle_info({:irc, :text, m = %Nola.Message{text: "enable-telegram"}}, state) do code = String.downcase(EntropyString.small_id()) Nola.Account.delete_meta(m.account, "telegram-id") Nola.Account.put_meta(m.account, "telegram-validation-code", code) Nola.Account.put_meta(m.account, "telegram-validation-target", m.network) text = "To enable or change your number for telegram messaging, please open #{Nola.Telegram.my_path()} and send:" <> " \"/enable #{code}\"" m.replyfun.(text) {:noreply, state} end def handle_info({:irc, :text, m = %Nola.Message{text: "enable-untappd"}}, state) do auth_url = Untappd.auth_url() login_url = Nola.AuthToken.new_url(m.account.id, {:external_redirect, auth_url}) m.replyfun.(["To link your Untappd account, open this URL:", login_url]) {:noreply, state} end def handle_info({:irc, :text, m = %Nola.Message{text: "getmeta"<>_}}, state) do result = case String.split(m.text, " ") do ["getmeta"] -> for {k, v} <- Nola.Account.get_all_meta(m.account) do case k do "u:"<>key -> "(user) #{key}: #{v}" key -> "#{key}: #{v}" end end ["getmeta", key] -> value = Nola.Account.get_meta(m.account, key) text = if value do "#{key}: #{value}" else "#{key} is not defined" end _ -> "usage: getmeta [key]" end m.replyfun.(result) {:noreply, state} end def handle_info({:irc, :text, m = %Nola.Message{text: "setusermeta"<>_}}, state) do result = case String.split(m.text, " ") do ["setusermeta", key, value] -> Nola.Account.put_user_meta(m.account, key, value) "ok" _ -> "usage: setusermeta " end m.replyfun.(result) {:noreply, state} end def handle_info(_, state) do {:noreply, state} end defp join_account(m, id, token) do old_account = Nola.Account.lookup(m.sender) new_account = Nola.Account.get(id) if new_account && token == new_account.token do case Nola.Account.merge_account(old_account.id, new_account.id) do :ok -> if old_account.id == new_account.id do m.replyfun.("Already authenticated, but hello") else m.replyfun.("Accounts merged!") end _ -> m.replyfun.("Something failed :(") end else m.replyfun.("Invalid token") end end end diff --git a/lib/plugins/base.ex b/lib/plugins/base.ex index 3188495..0f2c7e5 100644 --- a/lib/plugins/base.ex +++ b/lib/plugins/base.ex @@ -1,132 +1,132 @@ defmodule Nola.Plugins.Base do def irc_doc, do: nil def start_link() do GenServer.start_link(__MODULE__, [], name: __MODULE__) end def init([]) do regopts = [plugin: __MODULE__] {:ok, _} = Registry.register(Nola.PubSub, "trigger:version", regopts) {:ok, _} = Registry.register(Nola.PubSub, "trigger:help", regopts) {:ok, _} = Registry.register(Nola.PubSub, "trigger:liquidrender", regopts) {:ok, _} = Registry.register(Nola.PubSub, "trigger:plugin", regopts) {:ok, _} = Registry.register(Nola.PubSub, "trigger:plugins", regopts) {:ok, nil} end def handle_info({:irc, :trigger, "plugins", msg = %{trigger: %{type: :bang, args: []}}}, _) do enabled_string = Nola.Plugins.enabled() |> Enum.map(fn(mod) -> mod |> Macro.underscore() |> String.split("/", parts: :infinity) |> List.last() |> Enum.sort() end) |> Enum.join(", ") msg.replyfun.("Enabled plugins: #{enabled_string}") {:noreply, nil} end def handle_info({:irc, :trigger, "plugin", %{trigger: %{type: :query, args: [plugin]}} = m}, _) do module = Module.concat([Nola.Plugins, Macro.camelize(plugin)]) with true <- Code.ensure_loaded?(module), pid when is_pid(pid) <- GenServer.whereis(module) do m.replyfun.("loaded, active: #{inspect(pid)}") else false -> m.replyfun.("not loaded") nil -> msg = case Nola.Plugins.get(module) do :disabled -> "disabled" {_, false, _} -> "disabled" _ -> "not active" end m.replyfun.(msg) end {:noreply, nil} end def handle_info({:irc, :trigger, "plugin", %{trigger: %{type: :plus, args: [plugin]}} = m}, _) do module = Module.concat([Nola.Plugins, Macro.camelize(plugin)]) with true <- Code.ensure_loaded?(module), Nola.Plugins.switch(module, true), {:ok, pid} <- Nola.Plugins.start(module) do m.replyfun.("started: #{inspect(pid)}") else false -> m.replyfun.("not loaded") :ignore -> m.replyfun.("disabled or throttled") {:error, _} -> m.replyfun.("start error") end {:noreply, nil} end def handle_info({:irc, :trigger, "plugin", %{trigger: %{type: :tilde, args: [plugin]}} = m}, _) do module = Module.concat([Nola.Plugins, Macro.camelize(plugin)]) with true <- Code.ensure_loaded?(module), pid when is_pid(pid) <- GenServer.whereis(module), :ok <- GenServer.stop(pid), {:ok, pid} <- Nola.Plugins.start(module) do m.replyfun.("restarted: #{inspect(pid)}") else false -> m.replyfun.("not loaded") nil -> m.replyfun.("not active") end {:noreply, nil} end def handle_info({:irc, :trigger, "plugin", %{trigger: %{type: :minus, args: [plugin]}} = m}, _) do module = Module.concat([Nola.Plugins, Macro.camelize(plugin)]) with true <- Code.ensure_loaded?(module), pid when is_pid(pid) <- GenServer.whereis(module), :ok <- GenServer.stop(pid) do - IRC.Plugin.switch(module, false) + Nola.Plugins.switch(module, false) m.replyfun.("stopped: #{inspect(pid)}") else false -> m.replyfun.("not loaded") nil -> m.replyfun.("not active") end {:noreply, nil} end def handle_info({:irc, :trigger, "liquidrender", m = %{trigger: %{args: args}}}, _) do template = Enum.join(args, " ") m.replyfun.(Tmpl.render(template, m)) {:noreply, nil} end def handle_info({:irc, :trigger, "help", m = %{trigger: %{type: :bang}}}, _) do url = NolaWeb.Router.Helpers.irc_url(NolaWeb.Endpoint, :index, m.network, NolaWeb.format_chan(m.channel)) m.replyfun.("-> #{url}") {:noreply, nil} end def handle_info({:irc, :trigger, "version", message = %{trigger: %{type: :bang}}}, _) do {:ok, vsn} = :application.get_key(:nola, :vsn) ver = List.to_string(vsn) url = NolaWeb.Router.Helpers.irc_url(NolaWeb.Endpoint, :index) elixir_ver = Application.started_applications() |> List.keyfind(:elixir, 0) |> elem(2) |> to_string() otp_ver = :erlang.system_info(:system_version) |> to_string() |> String.trim() system = :erlang.system_info(:system_architecture) |> to_string() brand = Nola.brand(:name) owner = "#{Nola.brand(:owner)} <#{Nola.brand(:owner_email)}>" message.replyfun.([ <<"🤖 I am a robot running", 2, "#{brand}, version #{ver}", 2, " — source: #{Nola.source_url()}">>, "🦾 Elixir #{elixir_ver} #{otp_ver} on #{system}", "👷‍♀️ Owner: h#{owner}", "🌍 Web interface: #{url}" ]) {:noreply, nil} end def handle_info(msg, _) do {:noreply, nil} end end diff --git a/lib/plugins/say.ex b/lib/plugins/say.ex index 3df3ac8..114ca64 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} <- 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) + Nola.Irc.send_message_as(account, net, chan, text) else Nola.Irc.Connection.broadcast_message(net, chan, text) end end end end end diff --git a/lib/plugins/txt.ex b/lib/plugins/txt.ex index 4f9a803..3b431e9 100644 --- a/lib/plugins/txt.ex +++ b/lib/plugins/txt.ex @@ -1,556 +1,556 @@ defmodule Nola.Plugins.Txt do alias Nola.UserTrack require Logger @moduledoc """ # [txt]({{context_path}}/txt) * **.txt**: liste des fichiers et statistiques. Les fichiers avec une `*` sont vérrouillés. [Voir sur le web]({{context_path}}/txt). * **!txt**: lis aléatoirement une ligne dans tous les fichiers. * **!txt ``**: recherche une ligne dans tous les fichiers. * **~txt**: essaie de générer une phrase (markov). * **~txt ``**: essaie de générer une phrase commencant par ``. * **!`FICHIER`**: lis aléatoirement une ligne du fichier `FICHIER`. * **!`FICHIER` ``**: lis la ligne `` du fichier `FICHIER`. * **!`FICHIER` ``**: recherche une ligne contenant `` dans `FICHIER`. * **+txt `**: crée le fichier ``. * **+`FICHIER` ``**: ajoute une ligne `` dans le fichier `FICHIER`. * **-`FICHIER` ``**: supprime la ligne `` du fichier `FICHIER`. * **-txtrw, +txtrw**. op seulement. active/désactive le mode lecture seule. * **+txtlock ``, -txtlock ``**. op seulement. active/désactive le verrouillage d'un fichier. Insérez `\\\\` pour faire un saut de ligne. """ def short_irc_doc, do: "!txt https://sys.115ans.net/irc/txt " def irc_doc, do: @moduledoc def start_link() do GenServer.start_link(__MODULE__, [], name: __MODULE__) end defstruct triggers: %{}, rw: true, locks: nil, markov_handler: nil, markov: nil def random(file) do GenServer.call(__MODULE__, {:random, file}) end def reply_random(message, file) do if line = random(file) do line |> format_line(nil, message) |> message.replyfun.() line end end def init([]) do dets_locks_filename = (Nola.data_path() <> "/" <> "txtlocks.dets") |> String.to_charlist {:ok, locks} = :dets.open_file(dets_locks_filename, []) markov_handler = Keyword.get(Application.get_env(:nola, __MODULE__, []), :markov_handler, Nola.Plugins.Txt.Markov.Native) {:ok, markov} = markov_handler.start_link() {:ok, _} = Registry.register(Nola.PubSub, "triggers", [plugin: __MODULE__]) {:ok, %__MODULE__{locks: locks, markov_handler: markov_handler, markov: markov, triggers: load()}} end def handle_info({:received, "!reload", _, chan}, state) do {:noreply, %__MODULE__{state | triggers: load()}} end # # ADMIN: RW/RO # def handle_info({:irc, :trigger, "txtrw", msg = %{channel: channel, trigger: %{type: :plus}}}, state = %{rw: false}) do if channel && UserTrack.operator?(msg.network, channel, msg.sender.nick) do msg.replyfun.("txt: écriture réactivée") {:noreply, %__MODULE__{state | rw: true}} else {:noreply, state} end end def handle_info({:irc, :trigger, "txtrw", msg = %{channel: channel, trigger: %{type: :minus}}}, state = %{rw: true}) do if channel && UserTrack.operator?(msg.network, channel, msg.sender.nick) do msg.replyfun.("txt: écriture désactivée") {:noreply, %__MODULE__{state | rw: false}} else {:noreply, state} end end # # ADMIN: LOCKS # def handle_info({:irc, :trigger, "txtlock", msg = %{trigger: %{type: :plus, args: [trigger]}}}, state) do with \ {trigger, _} <- clean_trigger(trigger), true <- UserTrack.operator?(msg.network, msg.channel, msg.sender.nick) do :dets.insert(state.locks, {trigger}) msg.replyfun.("txt: #{trigger} verrouillé") end {:noreply, state} end def handle_info({:irc, :trigger, "txtlock", msg = %{trigger: %{type: :minus, args: [trigger]}}}, state) do with \ {trigger, _} <- clean_trigger(trigger), true <- UserTrack.operator?(msg.network, msg.channel, msg.sender.nick), true <- :dets.member(state.locks, trigger) do :dets.delete(state.locks, trigger) msg.replyfun.("txt: #{trigger} déverrouillé") end {:noreply, state} end # # FILE LIST # def handle_info({:irc, :trigger, "txt", msg = %{trigger: %{type: :dot}}}, state) do map = Enum.map(state.triggers, fn({key, data}) -> ignore? = String.contains?(key, ".") locked? = case :dets.lookup(state.locks, key) do [{trigger}] -> "*" _ -> "" end unless ignore?, do: "#{key}: #{to_string(Enum.count(data))}#{locked?}" end) |> Enum.filter(& &1) total = Enum.reduce(state.triggers, 0, fn({_, data}, acc) -> acc + Enum.count(data) end) detail = Enum.join(map, ", ") total = ". total: #{Enum.count(state.triggers)} fichiers, #{to_string(total)} lignes. Détail: https://sys.115ans.net/irc/txt" ro = if !state.rw, do: " (lecture seule activée)", else: "" (detail<>total<>ro) |> msg.replyfun.() {:noreply, state} end # # GLOBAL: RANDOM # def handle_info({:irc, :trigger, "txt", msg = %{trigger: %{type: :bang, args: []}}}, state) do result = Enum.reduce(state.triggers, [], fn({trigger, data}, acc) -> Enum.reduce(data, acc, fn({l, _}, acc) -> [{trigger, l} | acc] end) end) |> Enum.shuffle() if !Enum.empty?(result) do {source, line} = Enum.random(result) msg.replyfun.(format_line(line, "#{source}: ", msg)) end {:noreply, state} end def handle_info({:irc, :trigger, "txt", msg = %{trigger: %{type: :bang, args: args}}}, state) do grep = Enum.join(args, " ") |> String.downcase |> :unicode.characters_to_nfd_binary() result = with_stateful_results(msg, {:bang,"txt",msg.network,msg.channel,grep}, fn() -> Enum.reduce(state.triggers, [], fn({trigger, data}, acc) -> if !String.contains?(trigger, ".") do Enum.reduce(data, acc, fn({l, _}, acc) -> [{trigger, l} | acc] end) else acc end end) |> Enum.filter(fn({_, line}) -> line |> String.downcase() |> :unicode.characters_to_nfd_binary() |> String.contains?(grep) end) |> Enum.shuffle() end) if result do {source, line} = result msg.replyfun.(["#{source}: " | line]) end {:noreply, state} end def with_stateful_results(msg, key, initfun) do me = self() scope = {msg.network, msg.channel || msg.sender.nick} key = {__MODULE__, me, scope, key} with_stateful_results(key, initfun) end def with_stateful_results(key, initfun) do pid = case :global.whereis_name(key) do :undefined -> start_stateful_results(key, initfun.()) pid -> pid end if pid, do: wait_stateful_results(key, initfun, pid) end def start_stateful_results(key, []) do nil end def start_stateful_results(key, list) do me = self() {pid, _} = spawn_monitor(fn() -> Process.monitor(me) stateful_results(me, list) end) :yes = :global.register_name(key, pid) pid end def wait_stateful_results(key, initfun, pid) do send(pid, :get) receive do {:stateful_results, line} -> line {:DOWN, _ref, :process, ^pid, reason} -> with_stateful_results(key, initfun) after 5000 -> nil end end defp stateful_results(owner, []) do send(owner, :empty) :ok end @stateful_results_expire :timer.minutes(30) defp stateful_results(owner, [line | rest] = acc) do receive do :get -> send(owner, {:stateful_results, line}) stateful_results(owner, rest) {:DOWN, _ref, :process, ^owner, _} -> :ok after @stateful_results_expire -> :ok end end # # GLOBAL: MARKOV # def handle_info({:irc, :trigger, "txt", msg = %{trigger: %{type: :tilde, args: []}}}, state) do case state.markov_handler.sentence(state.markov) do {:ok, line} -> msg.replyfun.(line) error -> Logger.error "Txt Markov error: "<>inspect error end {:noreply, state} end def handle_info({:irc, :trigger, "txt", msg = %{trigger: %{type: :tilde, args: complete}}}, state) do complete = Enum.join(complete, " ") case state.markov_handler.complete_sentence(complete, state.markov) do {:ok, line} -> msg.replyfun.(line) error -> Logger.error "Txt Markov error: "<>inspect error end {:noreply, state} end # # TXT CREATE # def handle_info({:irc, :trigger, "txt", msg = %{trigger: %{type: :plus, args: [trigger]}}}, state) do with \ {trigger, _} <- clean_trigger(trigger), true <- can_write?(state, msg, trigger), :ok <- create_file(trigger) do msg.replyfun.("#{trigger}.txt créé. Ajouter: `+#{trigger} …` ; Lire: `!#{trigger}`") {:noreply, %__MODULE__{state | triggers: load()}} else _ -> {:noreply, state} end end # # TXT: RANDOM # def handle_info({:irc, :trigger, trigger, m = %{trigger: %{type: :query, args: opts}}}, state) do {trigger, _} = clean_trigger(trigger) if Map.get(state.triggers, trigger) do url = if m.channel do NolaWeb.Router.Helpers.irc_url(NolaWeb.Endpoint, :txt, m.network, NolaWeb.format_chan(m.channel), trigger) else NolaWeb.Router.Helpers.irc_url(NolaWeb.Endpoint, :txt, trigger) end m.replyfun.("-> #{url}") end {:noreply, state} end def handle_info({:irc, :trigger, trigger, msg = %{trigger: %{type: :bang, args: opts}}}, state) do {trigger, _} = clean_trigger(trigger) line = get_random(msg, state.triggers, trigger, String.trim(Enum.join(opts, " "))) if line do msg.replyfun.(format_line(line, nil, msg)) end {:noreply, state} end # # TXT: ADD # def handle_info({:irc, :trigger, trigger, msg = %{trigger: %{type: :plus, args: content}}}, state) do with \ true <- can_write?(state, msg, trigger), {:ok, idx} <- add(state.triggers, msg.text) do msg.replyfun.("#{msg.sender.nick}: ajouté à #{trigger}. (#{idx})") {:noreply, %__MODULE__{state | triggers: load()}} else {:error, {:jaro, string, idx}} -> msg.replyfun.("#{msg.sender.nick}: doublon #{trigger}##{idx}: #{string}") error -> Logger.debug("txt add failed: #{inspect error}") {:noreply, state} end end # # TXT: DELETE # def handle_info({:irc, :trigger, trigger, msg = %{trigger: %{type: :minus, args: [id]}}}, state) do with \ true <- can_write?(state, msg, trigger), data <- Map.get(state.triggers, trigger), {id, ""} <- Integer.parse(id), {text, _id} <- Enum.find(data, fn({_, idx}) -> id-1 == idx end) do data = data |> Enum.into(Map.new) data = Map.delete(data, text) msg.replyfun.("#{msg.sender.nick}: #{trigger}.txt##{id} supprimée: #{text}") dump(trigger, data) {:noreply, %__MODULE__{state | triggers: load()}} else _ -> {:noreply, state} end end def handle_info(:reload_markov, state=%__MODULE__{triggers: triggers, markov: markov}) do state.markov_handler.reload(state.triggers, state.markov) {:noreply, state} end def handle_info(msg, state) do {:noreply, state} end def handle_call({:random, file}, _from, state) do random = get_random(nil, state.triggers, file, []) {:reply, random, state} end def terminate(_reason, state) do if state.locks do :dets.sync(state.locks) :dets.close(state.locks) end :ok end # Load/Reloads text files from disk defp load() do triggers = Path.wildcard(directory() <> "/*.txt") |> Enum.reduce(%{}, fn(path, m) -> file = Path.basename(path) key = String.replace(file, ".txt", "") data = directory() <> file |> File.read! |> String.split("\n") |> Enum.reject(fn(line) -> cond do line == "" -> true !line -> true true -> false end end) |> Enum.with_index Map.put(m, key, data) end) |> Enum.sort |> Enum.into(Map.new) send(self(), :reload_markov) triggers end defp dump(trigger, data) do data = data |> Enum.sort_by(fn({_, idx}) -> idx end) |> Enum.map(fn({text, _}) -> text end) |> Enum.join("\n") File.write!(directory() <> "/" <> trigger <> ".txt", data<>"\n", []) end defp get_random(msg, triggers, trigger, []) do if data = Map.get(triggers, trigger) do {data, _idx} = Enum.random(data) data else nil end end defp get_random(msg, triggers, trigger, opt) do arg = case Integer.parse(opt) do {pos, ""} -> {:index, pos} {_pos, _some_string} -> {:grep, opt} _error -> {:grep, opt} end get_with_param(msg, triggers, trigger, arg) end defp get_with_param(msg, triggers, trigger, {:index, pos}) do data = Map.get(triggers, trigger, %{}) case Enum.find(data, fn({_, index}) -> index+1 == pos end) do {text, _} -> text _ -> nil end end defp get_with_param(msg, triggers, trigger, {:grep, query}) do out = with_stateful_results(msg, {:grep, trigger, query}, fn() -> data = Map.get(triggers, trigger, %{}) regex = Regex.compile!("#{query}", "i") Enum.filter(data, fn({txt, _}) -> Regex.match?(regex, txt) end) |> Enum.map(fn({txt, _}) -> txt end) |> Enum.shuffle() end) if out, do: out end defp create_file(name) do File.touch!(directory() <> "/" <> name <> ".txt") :ok end defp add(triggers, trigger_and_content) do case String.split(trigger_and_content, " ", parts: 2) do [trigger, content] -> {trigger, _} = clean_trigger(trigger) if Map.has_key?(triggers, trigger) do jaro = Enum.find(triggers[trigger], fn({string, idx}) -> String.jaro_distance(content, string) > 0.9 end) if jaro do {string, idx} = jaro {:error, {:jaro, string, idx}} else File.write!(directory() <> "/" <> trigger <> ".txt", content<>"\n", [:append]) idx = Enum.count(triggers[trigger])+1 {:ok, idx} end else {:error, :notxt} end _ -> {:error, :badarg} end end # fixme: this is definitely the ugliest thing i've ever done defp clean_trigger(trigger) do [trigger | opts] = trigger |> String.strip |> String.split(" ", parts: 2) trigger = trigger |> String.downcase |> :unicode.characters_to_nfd_binary() |> String.replace(~r/[^a-z0-9._]/, "") |> String.trim(".") |> String.trim("_") {trigger, opts} end def format_line(line, prefix, msg) do prefix = unless(prefix, do: "", else: prefix) prefix <> line |> String.split("\\\\") |> Enum.map(fn(line) -> String.split(line, "\\\\\\\\") end) |> List.flatten() |> Enum.map(fn(line) -> String.trim(line) |> Tmpl.render(msg) end) end def directory() do Application.get_env(:nola, :data_path) <> "/irc.txt/" end defp can_write?(%{rw: rw?, locks: locks}, msg = %{channel: nil, sender: sender}, trigger) do - admin? = IRC.admin?(sender) + admin? = Nola.Irc.admin?(sender) locked? = case :dets.lookup(locks, trigger) do [{trigger}] -> true _ -> false end unlocked? = if rw? == false, do: false, else: !locked? can? = unlocked? || admin? if !can? do reason = if !rw?, do: "lecture seule", else: "fichier vérrouillé" msg.replyfun.("#{sender.nick}: permission refusée (#{reason})") end can? end defp can_write?(state = %__MODULE__{rw: rw?, locks: locks}, msg = %{channel: channel, sender: sender}, trigger) do - admin? = IRC.admin?(sender) + admin? = Nola.Irc.admin?(sender) operator? = Nola.UserTrack.operator?(msg.network, channel, sender.nick) locked? = case :dets.lookup(locks, trigger) do [{trigger}] -> true _ -> false end unlocked? = if rw? == false, do: false, else: !locked? can? = admin? || operator? || unlocked? if !can? do reason = if !rw?, do: "lecture seule", else: "fichier vérrouillé" msg.replyfun.("#{sender.nick}: permission refusée (#{reason})") end can? end end diff --git a/lib/telegram.ex b/lib/telegram.ex index dcd8b1f..f2c9eca 100644 --- a/lib/telegram.ex +++ b/lib/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") Nola.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"] -> 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) + Nola.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?(Nola.Irc.Connection.triggers(), fn({trigger, _}) -> String.starts_with?(text, trigger) end) -> text true -> "!"<>text end message = %Nola.Message{ id: FlakeId.get(), transport: :telegram, network: "telegram", channel: nil, text: text, account: account, sender: %ExIRC.SenderInfo{nick: account.name}, replyfun: reply_fun, trigger: Nola.Irc.Connection.extract_trigger(trigger_text), at: nil } Nola.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 = 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/telegram/room.ex b/lib/telegram/room.ex index f3c3715..ede939e 100644 --- a/lib/telegram/room.ex +++ b/lib/telegram/room.ex @@ -1,188 +1,188 @@ defmodule Nola.TelegramRoom do require Logger @behaviour Telegram.ChatBot alias Telegram.Api @couch "bot-telegram-rooms" def rooms(), do: rooms(:with_docs) @spec rooms(:with_docs | :ids) :: [Map.t | integer( )] def rooms(:with_docs) do case Couch.get(@couch, :all_docs, include_docs: true) do {:ok, %{"rows" => rows}} -> {:ok, for(%{"doc" => doc} <- rows, do: doc)} error = {:error, _} -> error end end def rooms(:ids) do case Couch.get(@couch, :all_docs) do {:ok, %{"rows" => rows}} -> {:ok, for(%{"id" => id} <- rows, do: id)} error = {:error, _} -> error end end def room(id, opts \\ []) do Couch.get(@couch, id, opts) end # TODO: Create couch def setup() do :ok end def after_start() do for id <- room(:ids), do: Telegram.Bot.ChatBot.Chat.Session.Supervisor.start_child(Nola.Telegram, Integer.parse(id) |> elem(0)) end @impl Telegram.ChatBot def init(id) when is_integer(id) and id < 0 do token = Keyword.get(Application.get_env(:nola, :telegram, []), :key) {:ok, chat} = Api.request(token, "getChat", chat_id: id) Logger.metadata(transport: :telegram, id: id, telegram_room_id: id) tg_room = case room(id) do {:ok, tg_room = %{"network" => _net, "channel" => _chan}} -> tg_room {:error, :not_found} -> [net, chan] = String.split(chat["title"], "/", parts: 2) {net, chan} = case Nola.Irc.Connection.get_network(net, chan) do %Nola.Irc.Connection{} -> {net, chan} _ -> {nil, nil} end {:ok, _id, _rev} = Couch.post(@couch, %{"_id" => id, "network" => net, "channel" => nil}) {:ok, tg_room} = room(id) tg_room end %{"network" => net, "channel" => chan} = tg_room Logger.info("Starting ChatBot for room #{id} \"#{chat["title"]}\" #{inspect tg_room}") irc_plumbed = if net && chan do {:ok, _} = Registry.register(Nola.PubSub, "#{net}/#{chan}:messages", plugin: __MODULE__) {:ok, _} = Registry.register(Nola.PubSub, "#{net}/#{chan}:triggers", plugin: __MODULE__) {:ok, _} = Registry.register(Nola.PubSub, "#{net}/#{chan}:outputs", plugin: __MODULE__) true else Logger.warn("Did not found telegram match for #{id} \"#{chat["title"]}\"") false end {:ok, %{id: id, net: net, chan: chan, irc: irc_plumbed}} end def init(id) do Logger.error("telegram_room: bad id (not room id)", transport: :telegram, id: id, telegram_room_id: id) :ignoree end defp find_or_create_meta_account(from = %{"id" => user_id}, state) do if account = Nola.Account.find_meta_account("telegram-id", user_id) do account else first_name = Map.get(from, "first_name") last_name = Map.get(from, "last_name") name = [first_name, last_name] |> Enum.filter(& &1) |> Enum.join(" ") username = Map.get(from, "username", first_name) account = username |> Nola.Account.new_account() |> Nola.Account.update_account_name(name) |> Nola.Account.put_meta("telegram-id", user_id) Logger.info("telegram_room: created account #{account.id} for telegram user #{user_id}") account end end def handle_update(%{"message" => %{"from" => from = %{"id" => user_id}, "text" => text}}, _token, state) do account = find_or_create_meta_account(from, state) connection = Nola.Irc.Connection.get_network(state.net) - IRC.send_message_as(account, state.net, state.chan, text, true) + Nola.Irc.send_message_as(account, state.net, state.chan, text, true) {:ok, state} end def handle_update(data = %{"message" => %{"from" => from = %{"id" => user_id}, "location" => %{"latitude" => lat, "longitude" => lon}}}, _token, state) do account = find_or_create_meta_account(from, state) connection = Nola.Irc.Connection.get_network(state.net) - IRC.send_message_as(account, state.net, state.chan, "@ #{lat}, #{lon}", true) + Nola.Irc.send_message_as(account, state.net, state.chan, "@ #{lat}, #{lon}", true) {:ok, state} end for type <- ~w(photo voice video document animation) do def handle_update(data = %{"message" => %{unquote(type) => _}}, token, state) do upload(unquote(type), data, token, state) end end def handle_update(update, token, state) do {:ok, state} end def handle_info({:irc, _, _, message}, state) do handle_info({:irc, nil, message}, state) end def handle_info({:irc, _, message = %Nola.Message{sender: %{nick: nick}, text: text}}, state) do if Map.get(message.meta, :from) == self() do else body = if Map.get(message.meta, :self), do: text, else: "<#{nick}> #{text}" Nola.Telegram.send_message(state.id, body) end {:ok, state} end def handle_info(info, state) do Logger.info("UNhandled #{inspect info}") {:ok, state} end defp upload(_type, %{"message" => m = %{"chat" => %{"id" => chat_id}, "from" => from = %{"id" => user_id}}}, token, state) do account = find_or_create_meta_account(from, state) if account do {content, type} = cond do m["photo"] -> {m["photo"], "photo"} m["voice"] -> {m["voice"], "voice message"} m["video"] -> {m["video"], "video"} m["document"] -> {m["document"], "file"} m["animation"] -> {m["animation"], "gif"} end file = if is_list(content) && Enum.count(content) > 1 do Enum.sort_by(content, fn(p) -> p["file_size"] end, &>=/2) |> List.first() else content end file_id = file["file_id"] file_unique_id = file["file_unique_id"] text = if(m["caption"], do: m["caption"] <> " ", else: "") spawn(fn() -> with \ {:ok, file} <- Telegram.Api.request(token, "getFile", file_id: file_id), path = "https://api.telegram.org/file/bot#{token}/#{file["file_path"]}", {:ok, %HTTPoison.Response{status_code: 200, body: body}} <- HTTPoison.get(path), <> = body, {:ok, magic} <- GenMagic.Pool.perform(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}", 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}" txt = "#{type}: #{text}#{path}" connection = Nola.Irc.Connection.get_network(state.net) - IRC.send_message_as(account, state.net, state.chan, txt, true) + Nola.Irc.send_message_as(account, state.net, state.chan, txt, true) else error -> Telegram.Api.request(token, "sendMessage", chat_id: chat_id, text: "File upload failed, sorry.") Logger.error("Failed upload from Telegram: #{inspect error}") end end) {:ok, state} end end end diff --git a/lib/web/controllers/irc_controller.ex b/lib/web/controllers/irc_controller.ex index d2ba04a..e87382b 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(Nola.UserTrack.channel(network, channel), fn(tuple) -> Nola.UserTrack.User.from_tuple(tuple) end) true -> 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() + doc = Nola.Plugins.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 bf79413..cb46b8f 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 = Nola.Membership.of_account(account) users = Nola.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 + 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 f8bab7d..8a9f6f2 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 = Nola.Irc.Connection.get_network(network, chan) account = Nola.Account.get(account_id) 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 Nola.Irc.PuppetConnection.start(account, connection) users = Nola.UserTrack.channel(connection.network, chan) |> Enum.map(fn(tuple) -> Nola.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 = case Nola.Plugins.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) + Nola.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 = Nola.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