diff --git a/lib/irc.ex b/lib/irc.ex index 1592955..2c7a468 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 + def send_message_as(account, network, channel, text, force_puppet \\ false, meta \\ []) 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) + Nola.Irc.PuppetConnection.start_and_send_message(account, connection, channel, text, meta) 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}") + Nola.Irc.Connection.broadcast_message(network, channel, "<#{nick}> #{text}", meta) end end def admin?(%Nola.Message{sender: sender}), do: admin?(sender) def admin?(%{nick: nick, user: user, host: host}) 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/connection.ex b/lib/irc/connection.ex index 62d52d8..87da466 100644 --- a/lib/irc/connection.ex +++ b/lib/irc/connection.ex @@ -1,556 +1,558 @@ defmodule Nola.Irc.Connection do require Logger use Ecto.Schema @moduledoc """ # IRC Connection Provides a nicer abstraction over ExIRC's handlers. ## Start connections ``` Nola.Irc.Connection.start_link(host: "irc.random.sh", port: 6697, nick: "pouetbot", channels: ["#dev"]) ## PubSub topics * `account` -- accounts change * {:account_change, old_account_id, new_account_id} # Sent when account merged * {:accounts, [{:account, network, channel, nick, account_id}] # Sent on bot join * {:account, network, nick, account_id} # Sent on user join * `message` -- aill messages (without triggers) * `message:private` -- all messages without a channel * `message:#CHANNEL` -- all messages within `#CHANNEL` * `triggers` -- all triggers * `trigger:TRIGGER` -- any message with a trigger `TRIGGER` ## Replying to %Nola.Message{} Each `Nola.Message` comes with a dedicated `replyfun`, to which you only have to pass either: """ def irc_doc, do: nil @min_backoff :timer.seconds(5) @max_backoff :timer.seconds(2*60) embedded_schema do field :network, :string field :host, :string field :port, :integer field :nick, :string field :user, :string field :name, :string field :pass, :string field :tls, :boolean, default: false field :channels, {:array, :string}, default: [] end defmodule Supervisor do use DynamicSupervisor def start_link() do DynamicSupervisor.start_link(__MODULE__, [], name: __MODULE__) end def start_child(%Nola.Irc.Connection{} = conn) do spec = %{id: conn.id, start: {Nola.Irc.Connection, :start_link, [conn]}, restart: :transient} DynamicSupervisor.start_child(__MODULE__, spec) end @impl true def init(_init_arg) do DynamicSupervisor.init( strategy: :one_for_one, max_restarts: 10, max_seconds: 1 ) end end def changeset(params) do import Ecto.Changeset %__MODULE__{id: EntropyString.large_id()} |> cast(params, [:network, :host, :port, :nick, :user, :name, :pass, :channels, :tls]) |> validate_required([:host, :port, :nick, :user, :name]) |> apply_action(:insert) end def to_tuple(%__MODULE__{} = conn) do {conn.id, conn.network, conn.host, conn.port, conn.nick, conn.user, conn.name, conn.pass, conn.tls, conn.channels, nil} end def from_tuple({id, network, host, port, nick, user, name, pass, tls, channels, _}) do %__MODULE__{id: id, network: network, host: host, port: port, nick: nick, user: user, name: name, pass: pass, tls: tls, channels: channels} end ## -- MANAGER API def setup() do :dets.open_file(dets(), []) end def dets(), do: to_charlist(Nola.data_path("/connections.dets")) def lookup(id) do case :dets.lookup(dets(), id) do [object | _] -> from_tuple(object) _ -> nil end end def connections() do :dets.foldl(fn(object, acc) -> [from_tuple(object) | acc] end, [], dets()) end def start_all() do for conn <- connections(), do: {conn, Nola.Irc.Connection.Supervisor.start_child(conn)} end def get_network(network, channel \\ nil) do spec = [{{:_, :"$1", :_, :_, :_, :_, :_, :_, :_, :_, :_}, [{:==, :"$1", {:const, network}}], [:"$_"]}] results = Enum.map(:dets.select(dets(), spec), fn(object) -> from_tuple(object) end) if channel do Enum.find(results, fn(conn) -> Enum.member?(conn.channels, channel) end) else List.first(results) end end def get_host_nick(host, port, nick) do spec = [{{:_, :_, :"$1", :"$2", :"$3", :_, :_, :_, :_, :_, :_}, [{:andalso, {:andalso, {:==, :"$1", {:const, host}}, {:==, :"$2", {:const, port}}}, {:==, :"$3", {:const, nick}}}], [:"$_"]} ] case :dets.select(dets(), spec) do [object] -> from_tuple(object) [] -> nil end end def delete_connection(%__MODULE__{id: id} = conn) do :dets.delete(dets(), id) stop_connection(conn) :ok end def start_connection(%__MODULE__{} = conn) do Nola.Irc.Connection.Supervisor.start_child(conn) end def stop_connection(%__MODULE__{id: id}) do case :global.whereis_name(id) do pid when is_pid(pid) -> GenServer.stop(pid, :normal) _ -> :error end end def add_connection(opts) do case changeset(opts) do {:ok, conn} -> if existing = get_host_nick(conn.host, conn.port, conn.nick) do {:error, {:existing, conn}} else :dets.insert(dets(), to_tuple(conn)) Nola.Irc.Connection.Supervisor.start_child(conn) end error -> error end end def update_connection(connection) do :dets.insert(dets(), to_tuple(connection)) end def start_link(conn) do GenServer.start_link(__MODULE__, [conn], name: {:global, conn.id}) end - def broadcast_message(net, chan, message) do - dispatch("conn", {:broadcast, net, chan, message}, Nola.Irc.ConnectionPubSub) - end - def broadcast_message(list, message) when is_list(list) do - for {net, chan} <- list do - broadcast_message(net, chan, message) - end + def broadcast_message(net, chan, message, meta \\ []) do + dispatch("conn", {:broadcast, net, chan, message}, meta, Nola.Irc.ConnectionPubSub) end + #def broadcast_message(list, message, meta \\ []) when is_list(list) do + # for {net, chan} <- list do + # broadcast_message(net, chan, message, meta) + # end + #send def privmsg(channel, line) do GenServer.cast(__MODULE__, {:privmsg, channel, line}) end def init([conn]) do Logger.metadata(conn: conn.id) backoff = :backoff.init(@min_backoff, @max_backoff) |> :backoff.type(:jitter) {:ok, %{client: nil, ignore_channel_for_a_while: conn.network == "snoonet", backoff: backoff, channels: Map.new, conn: conn, connected_server: nil, connected_port: nil, network: conn.network}, {:continue, :connect}} end @triggers %{ "!" => :bang, "+" => :plus, "-" => :minus, "?" => :query, "." => :dot, "~" => :tilde, "@" => :at, "++" => :plus_plus, "--" => :minus_minus, "!!" => :bang_bang, "??" => :query_query, ".." => :dot_dot, "~~" => :tilde_tilde, "@@" => :at_at } def handle_continue(:connect, state) do client_opts = [] |> Keyword.put(:network, state.conn.network) {:ok, _} = Registry.register(Nola.Irc.ConnectionPubSub, "conn", []) client = if state.client && Process.alive?(state.client) do Logger.info("Reconnecting client") state.client else Logger.info("Connecting") {:ok, client} = ExIRC.Client.start_link(debug: false) ExIRC.Client.add_handler(client, self()) client end opts = [{:nodelay, true}] conn_fun = if state.conn.tls, do: :connect_ssl!, else: :connect! apply(ExIRC.Client, conn_fun, [client, to_charlist(state.conn.host), state.conn.port, opts]) {:noreply, %{state | client: client}} end def handle_info(:disconnected, state) do {delay, backoff} = :backoff.fail(state.backoff) Logger.info("#{inspect(self())} Disconnected -- reconnecting in #{inspect delay}ms") Process.send_after(self(), :connect, delay) {:noreply, %{state | channels: Map.new, backoff: backoff}} end def handle_info(:connect, state) do {:noreply, state, {:continue, :connect}} end def handle_cast({:privmsg, channel, line}, state) do irc_reply(state, {channel, nil}, line) {:noreply, state} end <<>> # Connection successful def handle_info({:connected, server, port}, state) do Logger.info("#{inspect(self())} Connected to #{inspect(server)}:#{port} #{inspect state}") {_, backoff} = :backoff.succeed(state.backoff) ExIRC.Client.logon(state.client, state.conn.pass || "", state.conn.nick, state.conn.user, state.conn.name) {:noreply, %{state | channels: Map.new, backoff: backoff, connected_server: server, connected_port: port}} end # Logon successful def handle_info(:logged_in, state) do Logger.info("#{inspect(self())} Logged in") {_, backoff} = :backoff.succeed(state.backoff) Enum.map(state.conn.channels, &ExIRC.Client.join(state.client, &1)) {:noreply, %{state | backoff: backoff}} end # ISUP def handle_info({:isup, network}, state) when is_binary(network) do Nola.UserTrack.clear_network(state.network) if network != state.network do Logger.warn("Possibly misconfigured network: #{network} != #{state.network}") end {:noreply, state} end # Been kicked def handle_info({:kicked, _sender, chan, _reason}, state) do ExIRC.Client.join(state.client, chan) {:noreply, %{state | channels: Map.delete(state.channels, chan)}} end # Received something in a channel def handle_info({:received, text, sender, chan}, state) do channel_state = Map.get(state.channels, chan, Map.new) if state.ignore_channel_for_a_while && DateTime.diff(DateTime.utc_now(), Map.get(channel_state, :joined)) < 30 do :ignore_channel else user = if user = Nola.UserTrack.find_by_nick(state.network, sender.nick) do user else Logger.error("Could not lookup user for message: #{inspect {state.network, chan, sender.nick}}") user = Nola.UserTrack.joined(chan, sender, []) ExIRC.Client.who(state.client, chan) # Rewho everything in case of need ? We shouldn't not know that user.. user end if !user do ExIRC.Client.who(state.client, chan) # Rewho everything in case of need ? We shouldn't not know that user.. Logger.error("Could not lookup user nor create it for message: #{inspect {state.network, chan, sender.nick}}") else if !Map.get(user.options, :puppet) do reply_fun = fn(text) -> irc_reply(state, {chan, sender}, text) end account = Nola.Account.lookup(sender) message = %Nola.Message{id: FlakeId.get(), transport: :irc, at: NaiveDateTime.utc_now(), text: text, network: state.network, account: account, sender: sender, channel: chan, replyfun: reply_fun, trigger: extract_trigger(text)} message = case Nola.UserTrack.messaged(message) do :ok -> message {:ok, message} -> message end publish(message, ["#{message.network}/#{chan}:messages"]) end end end {:noreply, state} end # Received a private message def handle_info({:received, text, sender}, state) do reply_fun = fn(text) -> irc_reply(state, {sender.nick, sender}, text) end account = Nola.Account.lookup(sender) message = %Nola.Message{id: FlakeId.get(), transport: :irc, text: text, network: state.network, at: NaiveDateTime.utc_now(), account: account, sender: sender, replyfun: reply_fun, trigger: extract_trigger(text)} message = case Nola.UserTrack.messaged(message) do :ok -> message {:ok, message} -> message end publish(message, ["messages:private", "#{message.network}/#{account.id}:messages"]) {:noreply, state} end ## -- Broadcast def handle_info({:broadcast, net, account = %Nola.Account{}, message}, state) do if net == state.conn.network do user = Nola.UserTrack.find_by_account(net, account) if user do irc_reply(state, {user.nick, nil}, message) end end {:noreply, state} end def handle_info({:broadcast, net, chan, message}, state) do if net == state.conn.network && Enum.member?(state.conn.channels, chan) do irc_reply(state, {chan, nil}, message) end {:noreply, state} end ## -- UserTrack def handle_info({:joined, channel}, state) do channels = Map.put(state.channels, channel, %{joined: DateTime.utc_now}) ExIRC.Client.who(state.client, channel) {:noreply, %{state | channels: channels}} end def handle_info({:who, channel, whos}, state) do accounts = Enum.map(whos, fn(who = %ExIRC.Who{nick: nick, operator?: operator}) -> priv = if operator, do: [:operator], else: [] # Don't touch -- on WHO the bot joined, not the users. Nola.UserTrack.joined(channel, who, priv, false) account = Nola.Account.lookup(who) if account do {:account, who.network, channel, who.nick, account.id} end end) |> Enum.filter(fn(x) -> x end) dispatch("account", {:accounts, accounts}) {:noreply, state} end def handle_info({:quit, reason, sender}, state) do Nola.UserTrack.quitted(sender, reason) {:noreply, state} end def handle_info({:joined, channel, sender}, state) do Nola.UserTrack.joined(channel, sender, []) account = Nola.Account.lookup(sender) if account do dispatch("account", {:account, sender.network, channel, sender.nick, account.id}) end {:noreply, state} end def handle_info({:kicked, nick, _by, channel, _reason}, state) do Nola.UserTrack.parted(state.network, channel, nick) {:noreply, state} end def handle_info({:parted, channel, %ExIRC.SenderInfo{nick: nick}}, state) do Nola.UserTrack.parted(state.network, channel, nick) {:noreply, state} end def handle_info({:mode, [channel, mode, nick]}, state) do track_mode(state.network, channel, nick, mode) {:noreply, state} end def handle_info({:nick_changed, old_nick, new_nick}, state) do Nola.UserTrack.renamed(state.network, old_nick, new_nick) {:noreply, state} end def handle_info(unhandled, client) do Logger.debug("unhandled: #{inspect unhandled}") {:noreply, client} end def publish(pub), do: publish(pub, []) def publish(m = %Nola.Message{trigger: nil}, keys) do - dispatch(["messages"] ++ keys, {:irc, :text, m}) + dispatch(["messages"] ++ keys, {:irc, :text, m}, Enum.into(m.meta, [])) end def publish(m = %Nola.Message{trigger: t = %Nola.Trigger{trigger: trigger}}, keys) do - dispatch(["triggers", "#{m.network}/#{m.channel}:triggers", "trigger:"<>trigger], {:irc, :trigger, trigger, m}) + dispatch(["triggers", "#{m.network}/#{m.channel}:triggers", "trigger:"<>trigger], {:irc, :trigger, trigger, m}, Enum.into(m.meta, [])) end def publish_event(net, event = %{type: _}) when is_binary(net) do event = event |> Map.put(:at, NaiveDateTime.utc_now()) |> Map.put(:network, net) dispatch("#{net}:events", {:irc, :event, event}) end def publish_event({net, chan}, event = %{type: type}) do event = event |> Map.put(:at, NaiveDateTime.utc_now()) |> Map.put(:network, net) |> Map.put(:channel, chan) dispatch("#{net}/#{chan}:events", {:irc, :event, event}) end - def dispatch(keys, content, sub \\ Nola.PubSub) + def dispatch(keys, content, meta \\ [], sub \\ Nola.PubSub) - def dispatch(key, content, sub) when is_binary(key), do: dispatch([key], content, sub) - def dispatch(keys, content, sub) when is_list(keys) do + def dispatch(key, content, meta, sub) when is_binary(key), do: dispatch([key], content, meta, sub) + def dispatch(keys, content, meta, sub) when is_list(keys) do + Logger.debug("dispatching: #{inspect keys} #{inspect content}") should_dispatch_data = should_dispatch_data(content) + origin = Keyword.get(meta, :origin, :no_origin) for key <- keys do spawn(fn() -> Registry.dispatch(sub, key, fn h -> - for {pid, reg} <- h, do: if(should_dispatch?(key, should_dispatch_data, reg), do: send(pid, content)) + for {pid, reg} <- h, do: if(should_dispatch?(key, origin, should_dispatch_data, reg), do: send(pid, content)) end) end) end end defp should_dispatch_data({:irc, :trigger, _, msg}) do should_dispatch_data(msg) end defp should_dispatch_data({:irc, :text, msg}) do should_dispatch_data(msg) end defp should_dispatch_data(%Nola.Message{network: network, channel: channel}) do Application.get_env(:nola, :channel_pubsub_plugin_ignore_list, %{}) |> Map.get("#{network}/#{channel}", []) end defp should_dispatch_data(_) do [] end - def should_dispatch?(_, [], _), do: true - def should_dispatch?(_, data, reg) do + def should_dispatch?(_, _, [], _), do: true + def should_dispatch?(key, origin, data, reg) do if plugin = Keyword.get(reg, :plugin) do - !Enum.member?(data, plugin) + plugin != origin || !Enum.member?(data, plugin) else true end end # # Triggers # def triggers, do: @triggers for {trigger, name} <- @triggers do def extract_trigger(unquote(trigger)<>text) do text = String.strip(text) [trigger | args] = String.split(text, " ") %Nola.Trigger{type: unquote(name), trigger: String.downcase(trigger), args: args} end end def extract_trigger(_), do: nil # # IRC Replies # # irc_reply(ExIRC.Client pid, {channel or nick, ExIRC.Sender}, binary | replies # replies :: {:kick, reason} | {:kick, nick, reason} | {:mode, mode, nick} defp irc_reply(state = %{client: client, network: network}, {target, _}, text) when is_binary(text) or is_list(text) do lines = Nola.Irc.Message.splitlong(text) |> Enum.map(fn(x) -> if(is_list(x), do: x, else: String.split(x, "\n")) end) |> List.flatten() outputs = for line <- lines do ExIRC.Client.msg(client, :privmsg, target, line) {:irc, :out, %Nola.Message{id: FlakeId.get(), transport: :irc, network: network, channel: target, text: line, sender: %ExIRC.SenderInfo{nick: state.conn.nick}, at: NaiveDateTime.utc_now(), meta: %{self: true}}} end for f <- outputs, do: dispatch(["irc:outputs", "#{network}/#{target}:outputs"], f) end defp irc_reply(%{client: client}, {target, %{nick: nick}}, {:kick, reason}) do ExIRC.Client.kick(client, target, nick, reason) end defp irc_reply(%{client: client}, {target, _}, {:kick, nick, reason}) do ExIRC.Client.kick(client, target, nick, reason) end defp irc_reply(%{client: client}, {target, %{nick: nick}}, {:mode, mode}) do ExIRC.Client.mode(%{client: client}, target, mode, nick) end defp irc_reply(%{client: client}, target, {:mode, mode, nick}) do ExIRC.Client.mode(client, target, mode, nick) end defp irc_reply(%{client: client}, target, {:channel_mode, mode}) do ExIRC.Client.mode(client, target, mode) end defp track_mode(network, channel, nick, "+o") do Nola.UserTrack.change_privileges(network, channel, nick, {[:operator], []}) :ok end defp track_mode(network, channel, nick, "-o") do Nola.UserTrack.change_privileges(network, channel, nick, {[], [:operator]}) :ok end defp track_mode(network, channel, nick, "+v") do Nola.UserTrack.change_privileges(network, channel, nick, {[:voice], []}) :ok end defp track_mode(network, channel, nick, "-v") do Nola.UserTrack.change_privileges(network, channel, nick, {[], [:voice]}) :ok end defp track_mode(network, channel, nick, mode) do Logger.warn("Unhandled track_mode: #{inspect {nick, mode}}") :ok end defp server(%{conn: %{host: host, port: port}}) do host <> ":" <> to_string(port) end end diff --git a/lib/irc/puppet_connection.ex b/lib/irc/puppet_connection.ex index f5e2e5c..51b4c8a 100644 --- a/lib/irc/puppet_connection.ex +++ b/lib/irc/puppet_connection.ex @@ -1,238 +1,238 @@ defmodule Nola.Irc.PuppetConnection do require Logger @min_backoff :timer.seconds(5) @max_backoff :timer.seconds(2*60) @max_idle :timer.hours(12) @env Mix.env defmodule Supervisor do use DynamicSupervisor def start_link() do DynamicSupervisor.start_link(__MODULE__, [], name: __MODULE__) end def start_child(%Nola.Account{id: account_id}, %Nola.Irc.Connection{id: connection_id}) do spec = %{id: {account_id, connection_id}, start: {Nola.Irc.PuppetConnection, :start_link, [account_id, connection_id]}, restart: :transient} DynamicSupervisor.start_child(__MODULE__, spec) end @impl true def init(_init_arg) do DynamicSupervisor.init( strategy: :one_for_one, max_restarts: 10, max_seconds: 1 ) end end def whereis(account = %Nola.Account{id: account_id}, connection = %Nola.Irc.Connection{id: connection_id}) do {:global, name} = name(account_id, connection_id) case :global.whereis_name(name) do :undefined -> nil pid -> pid end end - def send_message(account = %Nola.Account{id: account_id}, connection = %Nola.Irc.Connection{id: connection_id}, channel, text) do - GenServer.cast(name(account_id, connection_id), {:send_message, self(), channel, text}) + def send_message(account = %Nola.Account{id: account_id}, connection = %Nola.Irc.Connection{id: connection_id}, channel, text, meta) do + GenServer.cast(name(account_id, connection_id), {:send_message, self(), channel, text, meta}) end - def start_and_send_message(account = %Nola.Account{id: account_id}, connection = %Nola.Irc.Connection{id: connection_id}, channel, text) do + def start_and_send_message(account = %Nola.Account{id: account_id}, connection = %Nola.Irc.Connection{id: connection_id}, channel, text, meta) do {:global, name} = name(account_id, connection_id) pid = whereis(account, connection) pid = if !pid do case Nola.Irc.PuppetConnection.Supervisor.start_child(account, connection) do {:ok, pid} -> pid {:error, {:already_started, pid}} -> pid end else pid end - GenServer.cast(pid, {:send_message, self(), channel, text}) + GenServer.cast(pid, {:send_message, self(), channel, text, meta}) end def start(account = %Nola.Account{}, connection = %Nola.Irc.Connection{}) do Nola.Irc.PuppetConnection.Supervisor.start_child(account, connection) end def start_link(account_id, connection_id) do GenServer.start_link(__MODULE__, [account_id, connection_id], name: name(account_id, connection_id)) end def name(account_id, connection_id) do {:global, {PuppetConnection, account_id, connection_id}} end def init([account_id, connection_id]) do account = %Nola.Account{} = Nola.Account.get(account_id) connection = %Nola.Irc.Connection{} = Nola.Irc.Connection.lookup(connection_id) Logger.metadata(puppet_conn: account.id <> "@" <> connection.id) backoff = :backoff.init(@min_backoff, @max_backoff) |> :backoff.type(:jitter) idle = :erlang.send_after(@max_idle, self, :idle) {:ok, %{client: nil, backoff: backoff, idle: idle, connected: false, buffer: [], channels: [], connection_id: connection_id, account_id: account_id, connected_server: nil, connected_port: nil, network: connection.network}, {:continue, :connect}} end def handle_continue(:connect, state) do #ipv6 = if @env == :prod do # subnet = Nola.Subnet.assign(state.account_id) # Nola.Account.put_meta(Nola.Account.get(state.account_id), "subnet", subnet) # ip = Pfx.host(subnet, 1) # {:ok, ipv6} = :inet_parse.ipv6_address(to_charlist(ip)) # System.cmd("add-ip6", [ip]) # ipv6 #end conn = Nola.Irc.Connection.lookup(state.connection_id) client_opts = [] |> Keyword.put(:network, conn.network) client = if state.client && Process.alive?(state.client) do Logger.info("Reconnecting client") state.client else Logger.info("Connecting") {:ok, client} = ExIRC.Client.start_link(debug: false) ExIRC.Client.add_handler(client, self()) client end base_opts = [ {:nodelay, true} ] #{ip, opts} = case {ipv6, :inet_res.resolve(to_charlist(conn.host), :in, :aaaa)} do # {ipv6, {:ok, {:dns_rec, _dns_header, _query, rrs = [{:dns_rr, _, _, _, _, _, _, _, _, _} | _], _, _}}} -> # ip = rrs # |> Enum.map(fn({:dns_rr, _, :aaaa, :in, _, _, ipv6, _, _, _}) -> ipv6 end) # |> Enum.shuffle() # |> List.first() # opts = [ # :inet6, # {:ifaddr, ipv6} # ] # {ip, opts} # _ -> {ip, opts} = {to_charlist(conn.host), []} #end conn_fun = if conn.tls, do: :connect_ssl!, else: :connect! apply(ExIRC.Client, conn_fun, [client, ip, conn.port, base_opts ++ opts]) {:noreply, %{state | client: client}} end def handle_continue(:connected, state) do state = Enum.reduce(Enum.reverse(state.buffer), state, fn(b, state) -> {:noreply, state} = handle_cast(b, state) state end) {:noreply, %{state | buffer: []}} end - def handle_cast(cast = {:send_message, _pid, _channel, _text}, state = %{connected: false, buffer: buffer}) do + def handle_cast(cast = {:send_message, _pid, _channel, _text, _meta}, state = %{connected: false, buffer: buffer}) do {:noreply, %{state | buffer: [cast | buffer]}} end - def handle_cast({:send_message, pid, channel, text}, state = %{connected: true}) do + def handle_cast({:send_message, pid, channel, text, pub_meta}, state = %{connected: true}) do channels = if !Enum.member?(state.channels, channel) do ExIRC.Client.join(state.client, channel) [channel | state.channels] else state.channels end ExIRC.Client.msg(state.client, :privmsg, channel, text) - meta = %{puppet: true, from: pid} + meta = %{puppet: true, from: pid, origin: Keyword.get(pub_meta, :origin, __MODULE__)} account = Nola.Account.get(state.account_id) nick = make_nick(state) sender = %ExIRC.SenderInfo{network: state.network, nick: suffix_nick(nick), user: nick, host: "puppet."} reply_fun = fn(text) -> - Nola.Irc.Connection.broadcast_message(state.network, channel, text) + Nola.Irc.Connection.broadcast_message(state.network, channel, text, pub_meta) end message = %Nola.Message{id: FlakeId.get(), at: NaiveDateTime.utc_now(), text: text, network: state.network, account: account, sender: sender, channel: channel, replyfun: reply_fun, trigger: Nola.Irc.Connection.extract_trigger(text), meta: meta} message = case Nola.UserTrack.messaged(message) do :ok -> message {:ok, message} -> message end Nola.Irc.Connection.publish(message, ["#{message.network}/#{channel}:messages"]) idle = if length(state.buffer) == 0 do :erlang.cancel_timer(state.idle) :erlang.send_after(@max_idle, self(), :idle) else state.idle end {:noreply, %{state | idle: idle, channels: channels}} end def handle_info(:idle, state) do ExIRC.Client.quit(state.client, "Puppet was idle for too long") ExIRC.Client.stop!(state.client) {:stop, :normal, state} end def handle_info(:disconnected, state) do {delay, backoff} = :backoff.fail(state.backoff) Logger.info("#{inspect(self())} Disconnected -- reconnecting in #{inspect delay}ms") Process.send_after(self(), :connect, delay) {:noreply, %{state | connected: false, backoff: backoff}} end def handle_info(:connect, state) do {:noreply, state, {:continue, :connect}} end # Connection successful def handle_info({:connected, server, port}, state) do Logger.info("#{inspect(self())} Connected to #{inspect(server)}:#{port} #{inspect state}") {_, backoff} = :backoff.succeed(state.backoff) base_nick = make_nick(state) ExIRC.Client.logon(state.client, "", suffix_nick(base_nick), base_nick, "#{base_nick}'s puppet") {:noreply, %{state | backoff: backoff, connected_server: server, connected_port: port}} end # Logon successful def handle_info(:logged_in, state) do Logger.info("#{inspect(self())} Logged in") {_, backoff} = :backoff.succeed(state.backoff) # Create an UserTrack entry for the client so it's authenticated to the right account_id already. Nola.UserTrack.connected(state.network, suffix_nick(make_nick(state)), make_nick(state), "puppet.", state.account_id, %{puppet: true}) {:noreply, %{state | backoff: backoff}} end # ISUP def handle_info({:isup, network}, state) do {:noreply, %{state | network: network, connected: true}, {:continue, :connected}} end # Been kicked def handle_info({:kicked, _sender, chan, _reason}, state) do {:noreply, %{state | channels: state.channels -- [chan]}} end def handle_info(_info, state) do {:noreply, state} end def make_nick(state) do account = Nola.Account.get(state.account_id) user = Nola.UserTrack.find_by_account(state.network, account) base_nick = if(user, do: user.nick, else: account.name) clean_nick = case String.split(base_nick, ":", parts: 2) do ["@"<>nick, _] -> nick [nick] -> nick end clean_nick end if Mix.env == :dev do def suffix_nick(nick), do: "#{nick}[d]" else def suffix_nick(nick), do: "#{nick}[p]" end end diff --git a/lib/plugins/logger.ex b/lib/plugins/logger.ex index 9b47cd8..46c2a5b 100644 --- a/lib/plugins/logger.ex +++ b/lib/plugins/logger.ex @@ -1,82 +1,83 @@ defmodule Nola.Plugins.Logger do require Logger @couch_db "bot-logs" def irc_doc(), do: nil def start_link() do GenServer.start_link(__MODULE__, [], name: __MODULE__) end def init([]) do regopts = [plugin: __MODULE__] {:ok, _} = Registry.register(Nola.PubSub, "triggers", regopts) {:ok, _} = Registry.register(Nola.PubSub, "messages", regopts) {:ok, _} = Registry.register(Nola.PubSub, "messages:telegram", regopts) {:ok, _} = Registry.register(Nola.PubSub, "irc:outputs", regopts) {:ok, _} = Registry.register(Nola.PubSub, "messages:private", regopts) {:ok, nil} end def handle_info({:irc, :trigger, _, m}, state) do {:noreply, log(m, state)} end def handle_info({:irc, :text, m}, state) do {:noreply, log(m, state)} end def handle_info({:irc, :out, m}, state) do {:noreply, log(m, state)} end def handle_info(info, state) do Logger.debug("logger_plugin: unhandled info: #{inspect info}") {:noreply, state} end def log(entry, state) do case Couch.post(@couch_db, format_to_db(entry)) do {:ok, id, _rev} -> Logger.debug("logger_plugin: saved: #{inspect id}") state error -> Logger.error("logger_plugin: save failed: #{inspect error}") end rescue e -> Logger.error("logger_plugin: rescued processing for #{inspect entry}: #{inspect e}") Logger.error(Exception.format(:error, e, __STACKTRACE__)) state catch e, b -> Logger.error("logger_plugin: catched processing for #{inspect entry}: #{inspect e}") Logger.error(Exception.format(e, b, __STACKTRACE__)) state end def format_to_db(msg = %Nola.Message{id: id}) do channel = cond do msg.channel -> msg.channel msg.account -> msg.account.id msg.sender -> msg.sender.nick true -> nil end id = [msg.network, channel, id] |> Enum.filter(& &1) |> Enum.join(":") %{"_id" => id, "type" => "nola.message:v1", - "object" => msg} + "object" => %Nola.Message{msg | meta: Map.delete(msg.meta, :from)} + } end def format_to_db(anything) do %{"_id" => FlakeId.get(), "type" => "object", "object" => anything} end end diff --git a/lib/telegram/room.ex b/lib/telegram/room.ex index 7a8f427..9db551b 100644 --- a/lib/telegram/room.ex +++ b/lib/telegram/room.ex @@ -1,194 +1,194 @@ 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 {:ok, rooms} = rooms(:ids) for id <- rooms do spawn(fn() -> Telegram.Bot.ChatBot.Chat.Session.Supervisor.start_child(Nola.Telegram, Integer.parse(id) |> elem(0)) end) end 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(to_string(id)) do {:ok, tg_room = %{"network" => _net, "channel" => _chan}} -> tg_room _ -> [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 - _ = Couch.post(@couch, %{"_id" => id, "network" => net, "channel" => chan}) + {:ok, _, _} = Couch.post(@couch, %{"_id" => id, "network" => net, "channel" => chan}) {:ok, tg_room} = room(to_string(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) :ignore 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(account, "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) - Nola.Irc.send_message_as(account, state.net, state.chan, text, true) + Nola.Irc.send_message_as(account, state.net, state.chan, text, true, origin: __MODULE__) {: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) - Nola.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, origin: __MODULE__) {: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 + if Map.get(message.meta, :from) == self() || Map.get(message.meta, :origin) == __MODULE__ 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) - Nola.Irc.send_message_as(account, state.net, state.chan, txt, true) + Nola.Irc.send_message_as(account, state.net, state.chan, txt, true, origin: __MODULE__) 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