summaryrefslogblamecommitdiff
path: root/lib/irc/connection.ex
blob: 1e940036aa72378fc3ca31e5430069a697bd4663 (plain) (tree)


















































































































                                                                                                                                               


















































                                                                               



                                              

















                                                                            
                                  

                                                       
                                                                                                                                                 








                  
               



                         


                         











                                                                  
                                              

            




                                                                                                














                                                                                        
                                          




                                                       
                                                                                              
                                                  
                                                                                                              
                                                                                           









                                                                       
        
                                                                     




                                                                                   

     







                                                              







                                                                                                                 
               

                                                                                                                    



                                                                          


                                                                                                                                   




                                                                 
         
       




                                                      
                                                                             
                                        

                                                                                                                              



                                                     
                                                                                       







                                                                                 
                                                   





                                                                              
                                            

























                                                                                      

                                                    












                                                                                       
                                                      



                                                                              
                                                      



                                                           
                                                  



                                                                
                                                            



                                       
                                                   





                                                      
                                                    


                                                                                     
                                                                                                                   

     













                                                                   



                                                                                        
                                                                 




























                                                                                      
                                                                                                                         


                                                                                        
                                  
                                                      

                                                                                                                                               
       
                                                                                      

     
                                                                                


                                                   
                                                                          


                                                   

                                                                              

     
                                                                   


                                                 
                                                                     












                                                                              














                                                                           



                                                    
   
defmodule IRC.Connection do
  require Logger
  use Ecto.Schema

  @moduledoc """
  # IRC Connection

  Provides a nicer abstraction over ExIRC's handlers.

  ## Start connections

  ```
  IRC.Connection.start_link(host: "irc.random.sh", port: 6697, nick: "pouetbot", channels: ["#dev"])

  ## PubSub topics

  * `account` -- accounts change
    * {:account_change, old_account_id, new_account_id} # Sent when account merged
    * {:accounts, [{:account, network, channel, nick, account_id}] # Sent on bot join
    * {:account, network, nick, account_id} # Sent on user join
  * `message` -- aill messages (without triggers)
  * `message:private` -- all messages without a channel
  * `message:#CHANNEL` -- all messages within `#CHANNEL`
  * `triggers` -- all triggers
  * `trigger:TRIGGER` -- any message with a trigger `TRIGGER`

  ## Replying to %IRC.Message{}

  Each `IRC.Message` comes with a dedicated `replyfun`, to which you only have to pass either:

  """
  def irc_doc, do: nil

  @min_backoff :timer.seconds(5)
  @max_backoff :timer.seconds(2*60)

  embedded_schema do
    field :network, :string
    field :host, :string
    field :port, :integer
    field :nick, :string
    field :user, :string
    field :name, :string
    field :pass, :string
    field :tls, :boolean, default: false
    field :channels, {:array, :string}, default: []
  end

  defmodule Supervisor do
    use DynamicSupervisor

    def start_link() do
      DynamicSupervisor.start_link(__MODULE__, [], name: __MODULE__)
    end

    def start_child(%IRC.Connection{} = conn) do
      spec = %{id: conn.id, start: {IRC.Connection, :start_link, [conn]}, restart: :transient}
      DynamicSupervisor.start_child(__MODULE__, spec)
    end

    @impl true
    def init(_init_arg) do
      DynamicSupervisor.init(
        strategy: :one_for_one,
        max_restarts: 10,
        max_seconds: 1
      )
    end
  end


  def changeset(params) do
    import Ecto.Changeset

    %__MODULE__{id: EntropyString.large_id()}
    |> cast(params, [:network, :host, :port, :nick, :user, :name, :pass, :channels, :tls])
    |> validate_required([:host, :port, :nick, :user, :name])
    |> apply_action(:insert)
  end

  def to_tuple(%__MODULE__{} = conn) do
    {conn.id, conn.network, conn.host, conn.port, conn.nick, conn.user, conn.name, conn.pass, conn.tls, conn.channels, nil}
  end

  def from_tuple({id, network, host, port, nick, user, name, pass, tls, channels, _}) do
    %__MODULE__{id: id, network: network, host: host, port: port, nick: nick, user: user, name: name, pass: pass, tls: tls, channels: channels}
  end

  ## -- MANAGER API

  def setup() do
    :dets.open_file(dets(), [])
  end

  def dets(), do: to_charlist(LSG.data_path("/connections.dets"))

  def lookup(id) do
    case :dets.lookup(dets(), id) do
      [object | _] -> from_tuple(object)
      _ -> nil
    end
  end

  def connections() do
    :dets.foldl(fn(object, acc) -> [from_tuple(object) | acc] end, [], dets())
  end

  def start_all() do
    for conn <- connections(), do: {conn, IRC.Connection.Supervisor.start_child(conn)}
  end

  def get_network(network, channel \\ nil) do
    spec = [{{:_, :"$1", :_, :_, :_, :_, :_, :_, :_, :_, :_},
      [{:==, :"$1", {:const, network}}], [:"$_"]}]
    results = Enum.map(:dets.select(dets(), spec), fn(object) -> from_tuple(object) end)
    if channel do
      Enum.find(results, fn(conn) -> Enum.member?(conn.channels, channel) end)
    else
      List.first(results)
    end
  end

  def get_host_nick(host, port, nick) do
    spec = [{{:_, :_, :"$1", :"$2", :"$3", :_, :_, :_, :_, :_, :_},
      [{:andalso,
        {:andalso, {:==, :"$1", {:const, host}}, {:==, :"$2", {:const, port}}},
        {:==, :"$3", {:const, nick}}}],
      [:"$_"]}
    ]
    case :dets.select(dets(), spec) do
      [object] -> from_tuple(object)
      [] -> nil
    end
  end

  def delete_connection(%__MODULE__{id: id} = conn) do
    :dets.delete(dets(), id)
    stop_connection(conn)
    :ok
  end

  def start_connection(%__MODULE__{} = conn) do
    IRC.Connection.Supervisor.start_child(conn)
  end

  def stop_connection(%__MODULE__{id: id}) do
    case :global.whereis_name(id) do
      pid when is_pid(pid) ->
        GenServer.stop(pid, :normal)
      _ -> :error
    end
  end

  def add_connection(opts) do
    case changeset(opts) do
      {:ok, conn} ->
        if existing = get_host_nick(conn.host, conn.port, conn.nick) do
          {:error, {:existing, conn}}
        else
          :dets.insert(dets(), to_tuple(conn))
          IRC.Connection.Supervisor.start_child(conn)
        end
      error -> error
    end
  end

  def update_connection(connection) do
    :dets.insert(dets(), to_tuple(connection))
  end

  def start_link(conn) do
    GenServer.start_link(__MODULE__, [conn], name: {:global, conn.id})
  end

  def broadcast_message(net, chan, message) do
    dispatch("conn", {:broadcast, net, chan, message}, IRC.ConnectionPubSub)
  end
  def broadcast_message(list, message) when is_list(list) do
    for {net, chan} <- list do
      broadcast_message(net, chan, message)
    end
  end

  def privmsg(channel, line) do
    GenServer.cast(__MODULE__, {:privmsg, channel, line})
  end

  def init([conn]) do
    Logger.metadata(conn: conn.id)
    backoff = :backoff.init(@min_backoff, @max_backoff)
              |> :backoff.type(:jitter)
    {:ok, %{client: nil, backoff: backoff, conn: conn, connected_server: nil, connected_port: nil, network: conn.network}, {:continue, :connect}}
  end

  @triggers %{
    "!" => :bang,
    "+" => :plus,
    "-" => :minus,
    "?" => :query,
    "." => :dot,
    "~" => :tilde,
    "@" => :at,
    "++" => :plus_plus,
    "--" => :minus_minus,
    "!!" => :bang_bang,
    "??" => :query_query,
    ".." => :dot_dot,
    "~~" => :tilde_tilde,
    "@@" => :at_at
  }

  def handle_continue(:connect, state) do
    client_opts = []
                  |> Keyword.put(:network, state.conn.network)
    {:ok, _} = Registry.register(IRC.ConnectionPubSub, "conn", [])
    client = if state.client && Process.alive?(state.client) do
      Logger.info("Reconnecting client")
      state.client
    else
      Logger.info("Connecting")
      {:ok, client} = ExIRC.Client.start_link(debug: false)
      ExIRC.Client.add_handler(client, self())
      client
    end

    opts = [{:nodelay, true}]
    conn_fun = if state.conn.tls, do: :connect_ssl!, else: :connect!
    apply(ExIRC.Client, conn_fun, [client, to_charlist(state.conn.host), state.conn.port, opts])

    {:noreply, %{state | client: client}}
  end

  def handle_info(:disconnected, state) do
    {delay, backoff} = :backoff.fail(state.backoff)
    Logger.info("#{inspect(self())} Disconnected -- reconnecting in #{inspect delay}ms")
    Process.send_after(self(), :connect, delay)
    {:noreply, %{state | backoff: backoff}}
  end

  def handle_info(:connect, state) do
    {:noreply, state, {:continue, :connect}}
  end

  def handle_cast({:privmsg, channel, line}, state) do
    irc_reply(state, {channel, nil}, line)
    {:noreply, state}
  end

  # Connection successful
  def handle_info({:connected, server, port}, state) do
    Logger.info("#{inspect(self())} Connected to #{inspect(server)}:#{port} #{inspect state}")
    {_, backoff} = :backoff.succeed(state.backoff)
    ExIRC.Client.logon(state.client, state.conn.pass || "", state.conn.nick, state.conn.user, state.conn.name)
    {:noreply, %{state | backoff: backoff, connected_server: server, connected_port: port}}
  end

  # Logon successful
  def handle_info(:logged_in, state) do
    Logger.info("#{inspect(self())} Logged in")
    {_, backoff} = :backoff.succeed(state.backoff)
    Enum.map(state.conn.channels, &ExIRC.Client.join(state.client, &1))
    {:noreply, %{state | backoff: backoff}}
  end

  # ISUP
  def handle_info({:isup, network}, state) when is_binary(network) do
    IRC.UserTrack.clear_network(state.network)
    if network != state.network do
      Logger.warn("Possibly misconfigured network: #{network} != #{state.network}")
    end
    {:noreply, state}
  end

  # Been kicked
  def handle_info({:kicked, _sender, chan, _reason}, state) do
    ExIRC.Client.join(state.client, chan)
    {:noreply, state}
  end

  # Received something in a channel
  def handle_info({:received, text, sender, chan}, state) do
    user = if user = IRC.UserTrack.find_by_nick(state.network, sender.nick) do
      user
    else
      Logger.error("Could not lookup user for message: #{inspect {state.network, chan, sender.nick}}")
      user = IRC.UserTrack.joined(chan, sender, [])
      ExIRC.Client.who(state.client, chan) # Rewho everything in case of need ? We shouldn't not know that user..
      user
    end
    if !user do
      ExIRC.Client.who(state.client, chan) # Rewho everything in case of need ? We shouldn't not know that user..
      Logger.error("Could not lookup user nor create it for message: #{inspect {state.network, chan, sender.nick}}")
    else
      if !Map.get(user.options, :puppet) do
        reply_fun = fn(text) -> irc_reply(state, {chan, sender}, text) end
        account = IRC.Account.lookup(sender)
        message = %IRC.Message{id: FlakeId.get(), transport: :irc, at: NaiveDateTime.utc_now(), text: text, network: state.network,
                               account: account, sender: sender, channel: chan, replyfun: reply_fun,
                               trigger: extract_trigger(text)}
        message = case IRC.UserTrack.messaged(message) do
          :ok -> message
          {:ok, message} -> message
        end
        publish(message, ["#{message.network}/#{chan}:messages"])
      end
    end
    {:noreply, state}
  end

  # Received a private message
  def handle_info({:received, text, sender}, state) do
    reply_fun = fn(text) -> irc_reply(state, {sender.nick, sender}, text) end
    account = IRC.Account.lookup(sender)
    message = %IRC.Message{id: FlakeId.get(), transport: irc, text: text, network: state.network, at: NaiveDateTime.utc_now(),
                           account: account, sender: sender, replyfun: reply_fun, trigger: extract_trigger(text)}
    message = case IRC.UserTrack.messaged(message) do
      :ok -> message
      {:ok, message} -> message
    end
    publish(message, ["messages:private", "#{message.network}/#{account.id}:messages"])
    {:noreply, state}
  end

  ## -- Broadcast
  def handle_info({:broadcast, net, account = %IRC.Account{}, message}, state) do
    if net == state.conn.network do
      user = IRC.UserTrack.find_by_account(net, account)
      if user do
        irc_reply(state, {user.nick, nil}, message)
      end
    end
    {:noreply, state}
  end
  def handle_info({:broadcast, net, chan, message}, state) do
    if net == state.conn.network && Enum.member?(state.conn.channels, chan) do
      irc_reply(state, {chan, nil}, message)
    end
    {:noreply, state}
  end

  ## -- UserTrack

  def handle_info({:joined, channel}, state) do
    ExIRC.Client.who(state.client, channel)
    {:noreply, state}
  end

  def handle_info({:who, channel, whos}, state) do
    accounts = Enum.map(whos, fn(who = %ExIRC.Who{nick: nick, operator?: operator}) ->
      priv = if operator, do: [:operator], else: []
      # Don't touch -- on WHO the bot joined, not the users.
      IRC.UserTrack.joined(channel, who, priv, false)
      account = IRC.Account.lookup(who)
      if account do
        {:account, who.network, channel, who.nick, account.id}
      end
    end)
    |> Enum.filter(fn(x) -> x end)
    dispatch("account", {:accounts, accounts})
    {:noreply, state}
  end

  def handle_info({:quit, reason, sender}, state) do
    IRC.UserTrack.quitted(sender, reason)
    {:noreply, state}
  end

  def handle_info({:joined, channel, sender}, state) do
    IRC.UserTrack.joined(channel, sender, [])
    account = IRC.Account.lookup(sender)
    if account do
      dispatch("account", {:account, sender.network, channel, sender.nick, account.id})
    end
    {:noreply, state}
  end

  def handle_info({:kicked, nick, _by, channel, _reason}, state) do
    IRC.UserTrack.parted(state.network, channel, nick)
    {:noreply, state}
  end

  def handle_info({:parted, channel, %ExIRC.SenderInfo{nick: nick}}, state) do
    IRC.UserTrack.parted(state.network, channel, nick)
    {:noreply, state}
  end

  def handle_info({:mode, [channel, mode, nick]}, state) do
    track_mode(state.network, channel, nick, mode)
    {:noreply, state}
  end

  def handle_info({:nick_changed, old_nick, new_nick}, state) do
    IRC.UserTrack.renamed(state.network, old_nick, new_nick)
    {:noreply, state}
  end

  def handle_info(unhandled, client) do
    Logger.debug("unhandled: #{inspect unhandled}")
    {:noreply, client}
  end

  def publish(pub), do: publish(pub, [])

  def publish(m = %IRC.Message{trigger: nil}, keys) do
    dispatch(["messages"] ++ keys, {:irc, :text, m})
  end

  def publish(m = %IRC.Message{trigger: t = %IRC.Trigger{trigger: trigger}}, keys) do
    dispatch(["triggers", "#{m.network}/#{m.channel}:triggers", "trigger:"<>trigger], {:irc, :trigger, trigger, m})
  end

  def publish_event(net, event = %{type: _}) when is_binary(net) do
    event = event
    |> Map.put(:at, NaiveDateTime.utc_now())
    |> Map.put(:network, net)
    dispatch("#{net}:events", {:irc, :event, event})
  end
  def publish_event({net, chan}, event = %{type: type}) do
    event = event
    |> Map.put(:at, NaiveDateTime.utc_now())
    |> Map.put(:network, net)
    |> Map.put(:channel, chan)
    dispatch("#{net}/#{chan}:events", {:irc, :event, event})
  end

  def dispatch(keys, content, sub \\ IRC.PubSub)

  def dispatch(key, content, sub) when is_binary(key), do: dispatch([key], content, sub)
  def dispatch(keys, content, sub) when is_list(keys) do
    Logger.debug("dispatch #{inspect keys} = #{inspect content}")
    for key <- keys do
      spawn(fn() -> Registry.dispatch(sub, key, fn h ->
        for {pid, _} <- h, do: send(pid, content)
      end) end)
    end
  end

  #
  # Triggers
  #

  def triggers, do: @triggers

  for {trigger, name} <- @triggers do
    def extract_trigger(unquote(trigger)<>text) do
      text = String.strip(text)
      [trigger | args] = String.split(text, " ")
      %IRC.Trigger{type: unquote(name), trigger: String.downcase(trigger), args: args}
    end
  end

  def extract_trigger(_), do: nil

  #
  # IRC Replies
  #

  # irc_reply(ExIRC.Client pid, {channel or nick, ExIRC.Sender}, binary | replies
  # replies :: {:kick, reason} | {:kick, nick, reason} | {:mode, mode, nick}
  defp irc_reply(state = %{client: client, network: network}, {target, _}, text) when is_binary(text) or is_list(text) do
    lines = IRC.splitlong(text)
            |> Enum.map(fn(x) -> if(is_list(x), do: x, else: String.split(x, "\n")) end)
            |> List.flatten()
    outputs = for line <- lines do
      ExIRC.Client.msg(client, :privmsg, target, line)
      {:irc, :out, %IRC.Message{id: FlakeId.get(), transport: :irc, network: network,
              channel: target, text: line, sender: %ExIRC.SenderInfo{nick: state.conn.nick}, at: NaiveDateTime.utc_now(), meta: %{self: true}}}
    end
    for f <- outputs, do: dispatch(["irc:outputs", "#{network}/#{target}:outputs"], f)
  end

  defp irc_reply(%{client: client}, {target, %{nick: nick}}, {:kick, reason}) do
    ExIRC.Client.kick(client, target, nick, reason)
  end

  defp irc_reply(%{client: client}, {target, _}, {:kick, nick, reason}) do
    ExIRC.Client.kick(client, target, nick, reason)
  end

  defp irc_reply(%{client: client}, {target, %{nick: nick}}, {:mode, mode}) do
    ExIRC.Client.mode(%{client: client}, target, mode, nick)
  end

  defp irc_reply(%{client: client}, target, {:mode, mode, nick}) do
    ExIRC.Client.mode(client, target, mode, nick)
  end

  defp irc_reply(%{client: client}, target, {:channel_mode, mode}) do
    ExIRC.Client.mode(client, target, mode)
  end

  defp track_mode(network, channel, nick, "+o") do
    IRC.UserTrack.change_privileges(network, channel, nick, {[:operator], []})
    :ok
  end

  defp track_mode(network, channel, nick, "-o") do
    IRC.UserTrack.change_privileges(network, channel, nick, {[], [:operator]})
    :ok
  end

  defp track_mode(network, channel, nick, "+v") do
    IRC.UserTrack.change_privileges(network, channel, nick, {[:voice], []})
    :ok
  end

  defp track_mode(network, channel, nick, "-v") do
    IRC.UserTrack.change_privileges(network, channel, nick, {[], [:voice]})
    :ok
  end

  defp track_mode(network, channel, nick, mode) do
    Logger.warn("Unhandled track_mode: #{inspect {nick, mode}}")
    :ok
  end

  defp server(%{conn: %{host: host, port: port}}) do
    host <> ":" <> to_string(port)
  end

end