diff --git a/lib/exirc/channels.ex b/lib/exirc/channels.ex index b108de9..f274ef1 100644 --- a/lib/exirc/channels.ex +++ b/lib/exirc/channels.ex @@ -1,240 +1,240 @@ defmodule ExIrc.Channels do @moduledoc """ Responsible for managing channel state """ use Irc.Commands import String, only: [downcase: 1] defmodule Channel do defstruct name: '', topic: '', users: [], modes: '', type: '' end @doc """ Initialize a new Channels data store """ def init() do :gb_trees.empty() end ################## # Self JOIN/PART ################## @doc """ Add a channel to the data store when joining a channel """ def join(channel_tree, channel_name) do name = downcase(channel_name) case :gb_trees.lookup(name, channel_tree) do {:value, _} -> channel_tree :none -> :gb_trees.insert(name, %Channel{name: name}, channel_tree) end end @doc """ Remove a channel from the data store when leaving a channel """ def part(channel_tree, channel_name) do name = downcase(channel_name) case :gb_trees.lookup(name, channel_tree) do {:value, _} -> :gb_trees.delete(name, channel_tree) :none -> channel_tree end end ########################### # Channel Modes/Attributes ########################### @doc """ Update the topic for a tracked channel when it changes """ def set_topic(channel_tree, channel_name, topic) do name = downcase(channel_name) case :gb_trees.lookup(name, channel_tree) do {:value, channel} -> :gb_trees.enter(name, %{channel | topic: topic}, channel_tree) :none -> channel_tree end end @doc """ Update the type of a tracked channel when it changes """ def set_type(channel_tree, channel_name, channel_type) when is_binary(channel_type) do - set_type(channel_tree, channel_name, String.to_char_list(channel_type)) + set_type(channel_tree, channel_name, String.to_charlist(channel_type)) end def set_type(channel_tree, channel_name, channel_type) do name = downcase(channel_name) case :gb_trees.lookup(name, channel_tree) do {:value, channel} -> type = case channel_type do '@' -> :secret '*' -> :private '=' -> :public end :gb_trees.enter(name, %{channel | type: type}, channel_tree) :none -> channel_tree end end #################################### # Users JOIN/PART/AKAs(namechange) #################################### @doc """ Add a user to a tracked channel when they join """ def user_join(channel_tree, channel_name, nick) when not is_list(nick) do users_join(channel_tree, channel_name, [nick]) end @doc """ Add multiple users to a tracked channel (used primarily in conjunction with the NAMES command) """ def users_join(channel_tree, channel_name, nicks) do - pnicks = strip_rank(nicks) + pnicks = trim_rank(nicks) manipfn = fn(channel_nicks) -> :lists.usort(channel_nicks ++ pnicks) end users_manip(channel_tree, channel_name, manipfn) end @doc """ Remove a user from a tracked channel when they leave """ def user_part(channel_tree, channel_name, nick) do - pnick = strip_rank([nick]) + pnick = trim_rank([nick]) manipfn = fn(channel_nicks) -> :lists.usort(channel_nicks -- pnick) end users_manip(channel_tree, channel_name, manipfn) end def user_quit(channel_tree, nick) do - pnick = strip_rank([nick]) + pnick = trim_rank([nick]) manipfn = fn(channel_nicks) -> :lists.usort(channel_nicks -- pnick) end foldl = fn(channel_name, new_channel_tree) -> name = downcase(channel_name) users_manip(new_channel_tree, name, manipfn) end :lists.foldl(foldl, channel_tree, channels(channel_tree)) end @doc """ Update the nick of a user in a tracked channel when they change their nick """ def user_rename(channel_tree, nick, new_nick) do manipfn = fn(channel_nicks) -> case Enum.member?(channel_nicks, nick) do true -> [new_nick | channel_nicks -- [nick]] |> Enum.uniq |> Enum.sort false -> channel_nicks end end foldl = fn(channel_name, new_channel_tree) -> name = downcase(channel_name) users_manip(new_channel_tree, name, manipfn) end :lists.foldl(foldl, channel_tree, channels(channel_tree)) end ################ # Introspection ################ @doc """ Get a list of all currently tracked channels """ def channels(channel_tree) do (for {channel_name, _chan} <- :gb_trees.to_list(channel_tree), do: channel_name) |> Enum.reverse end @doc """ Get a list of all users in a tracked channel """ def channel_users(channel_tree, channel_name) do get_attr(channel_tree, channel_name, fn(%Channel{users: users}) -> users end) |> Enum.reverse end @doc """ Get the current topic for a tracked channel """ def channel_topic(channel_tree, channel_name) do case get_attr(channel_tree, channel_name, fn(%Channel{topic: topic}) -> topic end) do [] -> "No topic" topic -> topic end end @doc """ Get the type of a tracked channel """ def channel_type(channel_tree, channel_name) do case get_attr(channel_tree, channel_name, fn(%Channel{type: type}) -> type end) do [] -> :unknown type -> type end end @doc """ Determine if a user is present in a tracked channel """ def channel_has_user?(channel_tree, channel_name, nick) do get_attr(channel_tree, channel_name, fn(%Channel{users: users}) -> :lists.member(nick, users) end) end @doc """ Get all channel data as a tuple of the channel name and a proplist of metadata. Example Result: [{"#testchannel", [users: ["userA", "userB"], topic: "Just a test channel.", type: :public] }] """ def to_proplist(channel_tree) do for {channel_name, chan} <- :gb_trees.to_list(channel_tree) do {channel_name, [users: chan.users, topic: chan.topic, type: chan.type]} end |> Enum.reverse end #################### # Internal API #################### defp users_manip(channel_tree, channel_name, manipfn) do name = downcase(channel_name) case :gb_trees.lookup(name, channel_tree) do {:value, channel} -> channel_list = manipfn.(channel.users) :gb_trees.enter(channel_name, %{channel | users: channel_list}, channel_tree) :none -> channel_tree end end - defp strip_rank(nicks) do + defp trim_rank(nicks) do nicks |> Enum.map(fn(n) -> case n do << "@", nick :: binary >> -> nick << "+", nick :: binary >> -> nick << "%", nick :: binary >> -> nick << "&", nick :: binary >> -> nick << "~", nick :: binary >> -> nick nick -> nick end end) end defp get_attr(channel_tree, channel_name, getfn) do name = downcase(channel_name) case :gb_trees.lookup(name, channel_tree) do {:value, channel} -> getfn.(channel) :none -> {:error, :no_such_channel} end end end diff --git a/lib/exirc/client.ex b/lib/exirc/client.ex index c86f58d..4c49106 100644 --- a/lib/exirc/client.ex +++ b/lib/exirc/client.ex @@ -1,786 +1,862 @@ defmodule ExIrc.Client do @moduledoc """ Maintains the state and behaviour for individual IRC client connections """ use Irc.Commands use GenServer import ExIrc.Logger alias ExIrc.Channels alias ExIrc.Utils alias ExIrc.SenderInfo alias ExIrc.Client.Transport # Client internal state defmodule ClientState do defstruct event_handlers: [], server: "localhost", port: 6667, socket: nil, nick: "", pass: "", user: "", name: "", ssl?: false, connected?: false, logged_on?: false, autoping: true, channel_prefixes: "", network: "", user_prefixes: "", login_time: "", channels: [], debug?: false, retries: 0, inet: :inet, - owner: nil + owner: nil, + whois_buffers: %{} end ################# # External API ################# @doc """ Start a new IRC client process Returns either {:ok, pid} or {:error, reason} """ @spec start!(options :: list() | nil) :: {:ok, pid} | {:error, term} def start!(options \\ []) do start_link(options) end @doc """ Start a new IRC client process. Returns either {:ok, pid} or {:error, reason} """ @spec start_link(options :: list() | nil, process_opts :: list() | nil) :: {:ok, pid} | {:error, term} def start_link(options \\ [], process_opts \\ []) do options = Keyword.put_new(options, :owner, self()) GenServer.start_link(__MODULE__, options, process_opts) end @doc """ Stop the IRC client process """ @spec stop!(client :: pid) :: {:stop, :normal, :ok, ClientState.t} def stop!(client) do GenServer.call(client, :stop) end @doc """ Connect to a server with the provided server and port Example: Client.connect! pid, "localhost", 6667 """ @spec connect!(client :: pid, server :: binary, port :: non_neg_integer, options :: list() | nil) :: :ok def connect!(client, server, port, options \\ []) do GenServer.call(client, {:connect, server, port, options, false}, :infinity) end @doc """ Connect to a server with the provided server and port via SSL Example: Client.connect! pid, "localhost", 6697 """ @spec connect_ssl!(client :: pid, server :: binary, port :: non_neg_integer, options :: list() | nil) :: :ok def connect_ssl!(client, server, port, options \\ []) do GenServer.call(client, {:connect, server, port, options, true}, :infinity) end @doc """ Determine if the provided client process has an open connection to a server """ @spec is_connected?(client :: pid) :: true | false def is_connected?(client) do GenServer.call(client, :is_connected?) end @doc """ Logon to a server Example: Client.logon pid, "password", "mynick", "username", "My Name" """ @spec logon(client :: pid, pass :: binary, nick :: binary, user :: binary, name :: binary) :: :ok | {:error, :not_connected} def logon(client, pass, nick, user, name) do GenServer.call(client, {:logon, pass, nick, user, name}, :infinity) end @doc """ Determine if the provided client is logged on to a server """ @spec is_logged_on?(client :: pid) :: true | false def is_logged_on?(client) do GenServer.call(client, :is_logged_on?) end @doc """ Send a message to a nick or channel Message types are: :privmsg :notice :ctcp """ @spec msg(client :: pid, type :: atom, nick :: binary, msg :: binary) :: :ok | {:error, atom} def msg(client, type, nick, msg) do GenServer.call(client, {:msg, type, nick, msg}, :infinity) end @doc """ Send an action message, i.e. (/me slaps someone with a big trout) """ @spec me(client :: pid, channel :: binary, msg :: binary) :: :ok | {:error, atom} def me(client, channel, msg) do GenServer.call(client, {:me, channel, msg}, :infinity) end @doc """ Change the client's nick """ @spec nick(client :: pid, new_nick :: binary) :: :ok | {:error, atom} def nick(client, new_nick) do GenServer.call(client, {:nick, new_nick}, :infinity) end @doc """ Send a raw IRC command """ @spec cmd(client :: pid, raw_cmd :: binary) :: :ok | {:error, atom} def cmd(client, raw_cmd) do GenServer.call(client, {:cmd, raw_cmd}) end @doc """ Join a channel, with an optional password """ @spec join(client :: pid, channel :: binary, key :: binary | nil) :: :ok | {:error, atom} def join(client, channel, key \\ "") do GenServer.call(client, {:join, channel, key}, :infinity) end @doc """ Leave a channel """ @spec part(client :: pid, channel :: binary) :: :ok | {:error, atom} def part(client, channel) do GenServer.call(client, {:part, channel}, :infinity) end @doc """ Kick a user from a channel """ @spec kick(client :: pid, channel :: binary, nick :: binary, message :: binary | nil) :: :ok | {:error, atom} def kick(client, channel, nick, message \\ "") do GenServer.call(client, {:kick, channel, nick, message}, :infinity) end @spec names(client :: pid, channel :: binary) :: :ok | {:error, atom} def names(client, channel) do GenServer.call(client, {:names, channel}, :infinity) end + + @doc """ + Ask the server for the user's informations. + """ + @spec whois(client :: pid, user :: binary) :: :ok | {:error, atom()} + def whois(client, user) do + GenServer.call(client, {:whois, user}, :infinity) + end + + @doc """ Change mode for a user or channel """ @spec mode(client :: pid, channel_or_nick :: binary, flags :: binary, args :: binary | nil) :: :ok | {:error, atom} def mode(client, channel_or_nick, flags, args \\ "") do GenServer.call(client, {:mode, channel_or_nick, flags, args}, :infinity) end @doc """ Invite a user to a channel """ @spec invite(client :: pid, nick :: binary, channel :: binary) :: :ok | {:error, atom} def invite(client, nick, channel) do GenServer.call(client, {:invite, nick, channel}, :infinity) end @doc """ Quit the server, with an optional part message """ @spec quit(client :: pid, msg :: binary | nil) :: :ok | {:error, atom} def quit(client, msg \\ "Leaving..") do GenServer.call(client, {:quit, msg}, :infinity) end @doc """ Get details about each of the client's currently joined channels """ @spec channels(client :: pid) :: list(binary) | [] | {:error, atom} def channels(client) do GenServer.call(client, :channels) end @doc """ Get a list of users in the provided channel """ @spec channel_users(client :: pid, channel :: binary) :: list(binary) | [] | {:error, atom} def channel_users(client, channel) do GenServer.call(client, {:channel_users, channel}) end @doc """ Get the topic of the provided channel """ @spec channel_topic(client :: pid, channel :: binary) :: binary | {:error, atom} def channel_topic(client, channel) do GenServer.call(client, {:channel_topic, channel}) end @doc """ Get the channel type of the provided channel """ @spec channel_type(client :: pid, channel :: binary) :: atom | {:error, atom} def channel_type(client, channel) do GenServer.call(client, {:channel_type, channel}) end @doc """ Determine if a nick is present in the provided channel """ @spec channel_has_user?(client :: pid, channel :: binary, nick :: binary) :: true | false | {:error, atom} def channel_has_user?(client, channel, nick) do GenServer.call(client, {:channel_has_user?, channel, nick}) end @doc """ Add a new event handler process """ @spec add_handler(client :: pid, pid) :: :ok def add_handler(client, pid) do GenServer.call(client, {:add_handler, pid}) end @doc """ Add a new event handler process, asynchronously """ @spec add_handler_async(client :: pid, pid) :: :ok def add_handler_async(client, pid) do GenServer.cast(client, {:add_handler, pid}) end @doc """ Remove an event handler process """ @spec remove_handler(client :: pid, pid) :: :ok def remove_handler(client, pid) do GenServer.call(client, {:remove_handler, pid}) end @doc """ Remove an event handler process, asynchronously """ @spec remove_handler_async(client :: pid, pid) :: :ok def remove_handler_async(client, pid) do GenServer.cast(client, {:remove_handler, pid}) end @doc """ Get the current state of the provided client """ @spec state(client :: pid) :: [{atom, any}] def state(client) do state = GenServer.call(client, :state) [server: state.server, port: state.port, nick: state.nick, pass: state.pass, user: state.user, name: state.name, autoping: state.autoping, ssl?: state.ssl?, connected?: state.connected?, logged_on?: state.logged_on?, channel_prefixes: state.channel_prefixes, user_prefixes: state.user_prefixes, channels: Channels.to_proplist(state.channels), network: state.network, login_time: state.login_time, debug?: state.debug?, event_handlers: state.event_handlers] end ############### # GenServer API ############### @doc """ Called when GenServer initializes the client """ @spec init(list(any) | []) :: {:ok, ClientState.t} def init(options \\ []) do autoping = Keyword.get(options, :autoping, true) debug = Keyword.get(options, :debug, false) owner = Keyword.fetch!(options, :owner) # Add event handlers handlers = Keyword.get(options, :event_handlers, []) |> List.foldl([], &do_add_handler/2) ref = Process.monitor(owner) # Return initial state {:ok, %ClientState{ event_handlers: handlers, autoping: autoping, logged_on?: false, debug?: debug, channels: ExIrc.Channels.init(), owner: {owner, ref}}} end @doc """ Handle calls from the external API. It is not recommended to call these directly. """ # Handle call to get the current state of the client process def handle_call(:state, _from, state), do: {:reply, state, state} # Handle call to stop the current client process def handle_call(:stop, _from, state) do # Ensure the socket connection is closed if stop is called while still connected to the server if state.connected?, do: Transport.close(state) {:stop, :normal, :ok, %{state | connected?: false, logged_on?: false, socket: nil}} end # Handles call to add a new event handler process def handle_call({:add_handler, pid}, _from, state) do handlers = do_add_handler(pid, state.event_handlers) {:reply, :ok, %{state | event_handlers: handlers}} end # Handles call to remove an event handler process def handle_call({:remove_handler, pid}, _from, state) do handlers = do_remove_handler(pid, state.event_handlers) {:reply, :ok, %{state | event_handlers: handlers}} end # Handle call to connect to an IRC server def handle_call({:connect, server, port, options, ssl}, _from, state) do # If there is an open connection already, close it. if state.socket != nil, do: Transport.close(state) # Set SSL mode state = %{state | ssl?: ssl} # Open a new connection - case Transport.connect(state, String.to_char_list(server), port, [:list, {:packet, :line}, {:keepalive, true}] ++ options) do + case Transport.connect(state, String.to_charlist(server), port, [:list, {:packet, :line}, {:keepalive, true}] ++ options) do {:ok, socket} -> send_event {:connected, server, port}, state {:reply, :ok, %{state | connected?: true, server: server, port: port, socket: socket}} error -> {:reply, error, state} end end # Handle call to determine if the client is connected def handle_call(:is_connected?, _from, state), do: {:reply, state.connected?, state} # Prevents any of the following messages from being handled if the client is not connected to a server. # Instead, it returns {:error, :not_connected}. def handle_call(_, _from, %ClientState{connected?: false} = state), do: {:reply, {:error, :not_connected}, state} # Handle call to login to the connected IRC server def handle_call({:logon, pass, nick, user, name}, _from, %ClientState{logged_on?: false} = state) do Transport.send state, pass!(pass) Transport.send state, nick!(nick) Transport.send state, user!(user, name) {:reply, :ok, %{state | pass: pass, nick: nick, user: user, name: name} } end # Handles call to change the client's nick. def handle_call({:nick, new_nick}, _from, %ClientState{logged_on?: false} = state) do Transport.send state, nick!(new_nick) # Since we've not yet logged on, we won't get a nick change message, so we have to remember the nick here. {:reply, :ok, %{state | nick: new_nick}} end # Handle call to determine if client is logged on to a server def handle_call(:is_logged_on?, _from, state), do: {:reply, state.logged_on?, state} # Prevents any of the following messages from being handled if the client is not logged on to a server. # Instead, it returns {:error, :not_logged_in}. def handle_call(_, _from, %ClientState{logged_on?: false} = state), do: {:reply, {:error, :not_logged_in}, state} # Handles call to send a message def handle_call({:msg, type, nick, msg}, _from, state) do data = case type do :privmsg -> privmsg!(nick, msg) :notice -> notice!(nick, msg) :ctcp -> notice!(nick, ctcp!(msg)) end Transport.send state, data {:reply, :ok, state} end # Handle /me messages def handle_call({:me, channel, msg}, _from, state) do data = me!(channel, msg) Transport.send state, data {:reply, :ok, state} end # Handles call to join a channel def handle_call({:join, channel, key}, _from, state) do Transport.send(state, join!(channel, key)) {:reply, :ok, state} end # Handles a call to leave a channel def handle_call({:part, channel}, _from, state) do Transport.send(state, part!(channel)) {:reply, :ok, state} end # Handles a call to kick a client def handle_call({:kick, channel, nick, message}, _from, state) do Transport.send(state, kick!(channel, nick, message)) {:reply, :ok, state} end # Handles a call to send the NAMES command to the server def handle_call({:names, channel}, _from, state) do Transport.send(state, names!(channel)) {:reply, :ok, state} end + + def handle_call({:whois, user}, _from, state) do + Transport.send(state, whois!(user)) + {:reply, :ok, state} + end + # Handles a call to change mode for a user or channel def handle_call({:mode, channel_or_nick, flags, args}, _from, state) do Transport.send(state, mode!(channel_or_nick, flags, args)) {:reply, :ok, state} end # Handle call to invite a user to a channel def handle_call({:invite, nick, channel}, _from, state) do Transport.send(state, invite!(nick, channel)) {:reply, :ok, state} end # Handle call to quit the server and close the socket connection def handle_call({:quit, msg}, _from, state) do if state.connected? do Transport.send state, quit!(msg) send_event(:disconnected, state) Transport.close state end {:reply, :ok, %{state | connected?: false, logged_on?: false, socket: nil}} end # Handles call to change the client's nick def handle_call({:nick, new_nick}, _from, state) do Transport.send(state, nick!(new_nick)); {:reply, :ok, state} end # Handles call to send a raw command to the IRC server def handle_call({:cmd, raw_cmd}, _from, state) do Transport.send(state, command!(raw_cmd)); {:reply, :ok, state} end # Handles call to return the client's channel data def handle_call(:channels, _from, state), do: {:reply, Channels.channels(state.channels), state} # Handles call to return a list of users for a given channel def handle_call({:channel_users, channel}, _from, state), do: {:reply, Channels.channel_users(state.channels, channel), state} # Handles call to return the given channel's topic def handle_call({:channel_topic, channel}, _from, state), do: {:reply, Channels.channel_topic(state.channels, channel), state} # Handles call to return the type of the given channel def handle_call({:channel_type, channel}, _from, state), do: {:reply, Channels.channel_type(state.channels, channel), state} # Handles call to determine if a nick is present in the given channel def handle_call({:channel_has_user?, channel, nick}, _from, state) do {:reply, Channels.channel_has_user?(state.channels, channel, nick), state} end # Handles message to add a new event handler process asynchronously def handle_cast({:add_handler, pid}, state) do handlers = do_add_handler(pid, state.event_handlers) {:noreply, %{state | event_handlers: handlers}} end @doc """ Handles asynchronous messages from the external API. Not recommended to call these directly. """ # Handles message to remove an event handler process asynchronously def handle_cast({:remove_handler, pid}, state) do handlers = do_remove_handler(pid, state.event_handlers) {:noreply, %{state | event_handlers: handlers}} end @doc """ Handle messages from the TCP socket connection. """ # Handles the client's socket connection 'closed' event def handle_info({:tcp_closed, _socket}, %ClientState{server: server, port: port} = state) do info "Connection to #{server}:#{port} closed!" send_event :disconnected, state new_state = %{state | socket: nil, connected?: false, logged_on?: false, channels: Channels.init() } {:noreply, new_state} end @doc """ Handle messages from the SSL socket connection. """ # Handles the client's socket connection 'closed' event def handle_info({:ssl_closed, socket}, state) do handle_info({:tcp_closed, socket}, state) end # Handles any TCP errors in the client's socket connection def handle_info({:tcp_error, socket, reason}, %ClientState{server: server, port: port} = state) do error "TCP error in connection to #{server}:#{port}:\r\n#{reason}\r\nClient connection closed." new_state = %{state | socket: nil, connected?: false, logged_on?: false, channels: Channels.init() } {:stop, {:tcp_error, socket}, new_state} end # Handles any SSL errors in the client's socket connection def handle_info({:ssl_error, socket, reason}, state) do handle_info({:tcp_error, socket, reason}, state) end # General handler for messages from the IRC server def handle_info({:tcp, _, data}, state) do debug? = state.debug? case Utils.parse(data) do %IrcMessage{ctcp: true} = msg -> handle_data msg, state {:noreply, state} %IrcMessage{ctcp: false} = msg -> handle_data msg, state %IrcMessage{ctcp: :invalid} = msg when debug? -> send_event msg, state {:noreply, state} _ -> {:noreply, state} end end # Wrapper for SSL socket messages def handle_info({:ssl, socket, data}, state) do handle_info({:tcp, socket, data}, state) end # If the owner process dies, we should die as well def handle_info({:DOWN, ref, _, pid, reason}, %{owner: {pid, ref}} = state) do {:stop, reason, state} end # If an event handler process dies, remove it from the list of event handlers def handle_info({:DOWN, _, _, pid, _}, state) do handlers = do_remove_handler(pid, state.event_handlers) {:noreply, %{state | event_handlers: handlers}} end # Catch-all for unrecognized messages (do nothing) def handle_info(_, state) do {:noreply, state} end @doc """ Handle termination """ def terminate(_reason, state) do if state.socket != nil do Transport.close state %{state | socket: nil} end :ok end @doc """ Transform state for hot upgrades/downgrades """ def code_change(_old, state, _extra), do: {:ok, state} ################ # Data handling ################ @doc """ Handle IrcMessages received from the server. """ # Called upon successful login def handle_data(%IrcMessage{cmd: @rpl_welcome}, %ClientState{logged_on?: false} = state) do if state.debug?, do: debug "SUCCESFULLY LOGGED ON" new_state = %{state | logged_on?: true, login_time: :erlang.timestamp()} send_event :logged_in, new_state {:noreply, new_state} end # Called when the server sends it's current capabilities def handle_data(%IrcMessage{cmd: @rpl_isupport} = msg, state) do if state.debug?, do: debug "RECEIVING SERVER CAPABILITIES" {:noreply, Utils.isup(msg.args, state)} end # Called when the client enters a channel def handle_data(%IrcMessage{nick: nick, cmd: "JOIN"} = msg, %ClientState{nick: nick} = state) do - channel = msg.args |> List.first |> String.strip + channel = msg.args |> List.first |> String.trim if state.debug?, do: debug "JOINED A CHANNEL #{channel}" channels = Channels.join(state.channels, channel) new_state = %{state | channels: channels} send_event {:joined, channel}, new_state {:noreply, new_state} end # Called when another user joins a channel the client is in def handle_data(%IrcMessage{nick: user_nick, cmd: "JOIN", host: host, user: user} = msg, state) do sender = %SenderInfo{nick: user_nick, host: host, user: user} - channel = msg.args |> List.first |> String.strip + channel = msg.args |> List.first |> String.trim if state.debug?, do: debug "ANOTHER USER JOINED A CHANNEL: #{channel} - #{user_nick}" channels = Channels.user_join(state.channels, channel, user_nick) new_state = %{state | channels: channels} send_event {:joined, channel, sender}, new_state {:noreply, new_state} end # Called on joining a channel, to tell us the channel topic # Message with three arguments is not RFC compliant but very common # Message with two arguments is RFC compliant # Message with a single argument is not RFC compliant, but is present # to handle poorly written IRC servers which send RPL_TOPIC with an empty # topic (such as Slack's IRC bridge), when they should be sending RPL_NOTOPIC def handle_data(%IrcMessage{cmd: @rpl_topic} = msg, state) do {channel, topic} = case msg.args do [_nick, channel, topic] -> {channel, topic} [channel, topic] -> {channel, topic} [channel] -> {channel, "No topic is set"} end if state.debug? do debug "INITIAL TOPIC MSG" debug "1. TOPIC SET FOR #{channel} TO #{topic}" end channels = Channels.set_topic(state.channels, channel, topic) new_state = %{state | channels: channels} send_event {:topic_changed, channel, topic}, new_state {:noreply, new_state} end + + + ## WHOIS + + + def handle_data(%IrcMessage{cmd: @rpl_whoisuser, args: [_sender, nickname, username, hostname, _, realname]}, state) do + user = %{nickname: nickname, username: username, hostname: hostname, realname: realname} + {:noreply, %ClientState{state|whois_buffers: Map.put(state.whois_buffers, nickname, user)}} + end + + def handle_data(%IrcMessage{cmd: @rpl_whoiscertfp, args: [_sender, nickname, "has client certificate fingerprint "<> fingerprint]}, state) do + {:noreply, %ClientState{state|whois_buffers: put_in(state.whois_buffers, [nickname, :certfp], fingerprint)}} + end + + def handle_data(%IrcMessage{cmd: @rpl_whoisregnick, args: [_sender, nickname, _message]}, state) do + {:noreply, %ClientState{state|whois_buffers: put_in(state.whois_buffers, [nickname, :registered_nick?], true)}} + end + + def handle_data(%IrcMessage{cmd: @rpl_whoishelpop, args: [_sender, nickname, _message]}, state) do + {:noreply, %ClientState{state|whois_buffers: put_in(state.whois_buffers, [nickname, :helpop?], true)}} + end + + def handle_data(%IrcMessage{cmd: @rpl_whoischannels, args: [_sender, nickname, channels]}, state) do + chans = String.split(channels, " ") + {:noreply, %ClientState{state|whois_buffers: put_in(state.whois_buffers, [nickname, :channels], chans)}} + end + + def handle_data(%IrcMessage{cmd: @rpl_whoisserver, args: [_sender, nickname, server_addr, server_name]}, state) do + new_buffer = state.whois_buffers + |> put_in([nickname, :server_name], server_name) + |> put_in([nickname, :server_address], server_addr) + {:noreply, %ClientState{state|whois_buffers: new_buffer}} + end + + def handle_data(%IrcMessage{cmd: @rpl_whoisoperator, args: [_sender, nickname, _message]}, state) do + {:noreply, %ClientState{state|whois_buffers: put_in(state.whois_buffers, [nickname, :ircop?], true)}} + end + + def handle_data(%IrcMessage{cmd: @rpl_whoisaccount, args: [_sender, nickname, account_name, _message]}, state) do + {:noreply, %ClientState{state|whois_buffers: put_in(state.whois_buffers, [nickname, :account_name], account_name)}} + end + + def handle_data(%IrcMessage{cmd: @rpl_whoissecure, args: [_sender, nickname, _message]}, state) do + {:noreply, %ClientState{state|whois_buffers: put_in(state.whois_buffers, [nickname, :tls?], true)}} + end + + def handle_data(%IrcMessage{cmd: @rpl_whoisidle, args: [_sender, nickname, idling_time, signon_time, _message]}, state) do + new_buffer = state.whois_buffers + |> put_in([nickname, :idling_time], idling_time) + |> put_in([nickname, :signon_time], signon_time) + {:noreply, %ClientState{state|whois_buffers: new_buffer}} + end + + def handle_data(%IrcMessage{cmd: @rpl_endofwhois, args: [_sender, nickname, _message]}, state) do + buffer = struct(Irc.Whois, state.whois_buffers[nickname]) + send_event {:whois, buffer}, state + {:noreply, %ClientState{state|whois_buffers: Map.delete(state.whois_buffers, nickname)}} + end + def handle_data(%IrcMessage{cmd: @rpl_notopic, args: [channel]}, state) do if state.debug? do debug "INITIAL TOPIC MSG" debug "1. NO TOPIC SET FOR #{channel}}" end channels = Channels.set_topic(state.channels, channel, "No topic is set") new_state = %{state | channels: channels} {:noreply, new_state} end # Called when the topic changes while we're in the channel def handle_data(%IrcMessage{cmd: "TOPIC", args: [channel, topic]}, state) do if state.debug?, do: debug "TOPIC CHANGED FOR #{channel} TO #{topic}" channels = Channels.set_topic(state.channels, channel, topic) new_state = %{state | channels: channels} send_event {:topic_changed, channel, topic}, new_state {:noreply, new_state} end # Called when joining a channel with the list of current users in that channel, or when the NAMES command is sent def handle_data(%IrcMessage{cmd: @rpl_namereply} = msg, state) do if state.debug?, do: debug "NAMES LIST RECEIVED" {_nick, channel_type, channel, names} = case msg.args do [nick, channel_type, channel, names] -> {nick, channel_type, channel, names} [channel_type, channel, names] -> {nil, channel_type, channel, names} end channels = Channels.set_type( Channels.users_join(state.channels, channel, String.split(names, " ", trim: true)), channel, channel_type) send_event({:names_list, channel, names}, state) {:noreply, %{state | channels: channels}} end # Called when our nick has succesfully changed def handle_data(%IrcMessage{cmd: "NICK", nick: nick, args: [new_nick]}, %ClientState{nick: nick} = state) do if state.debug?, do: debug "NICK CHANGED FROM #{nick} TO #{new_nick}" new_state = %{state | nick: new_nick} send_event {:nick_changed, new_nick}, new_state {:noreply, new_state} end # Called when someone visible to us changes their nick def handle_data(%IrcMessage{cmd: "NICK", nick: nick, args: [new_nick]}, state) do if state.debug?, do: debug "#{nick} CHANGED THEIR NICK TO #{new_nick}" channels = Channels.user_rename(state.channels, nick, new_nick) new_state = %{state | channels: channels} send_event {:nick_changed, nick, new_nick}, new_state {:noreply, new_state} end # Called upon mode change def handle_data(%IrcMessage{cmd: "MODE", args: [channel, op, user]}, state) do if state.debug?, do: debug "MODE #{channel} #{op} #{user}" send_event {:mode, [channel, op, user]}, state {:noreply, state} end # Called when we leave a channel def handle_data(%IrcMessage{cmd: "PART", nick: nick} = msg, %ClientState{nick: nick} = state) do - channel = msg.args |> List.first |> String.strip + channel = msg.args |> List.first |> String.trim if state.debug?, do: debug "WE LEFT A CHANNEL: #{channel}" channels = Channels.part(state.channels, channel) new_state = %{state | channels: channels} send_event {:parted, channel}, new_state {:noreply, new_state} end # Called when someone else in our channel leaves def handle_data(%IrcMessage{cmd: "PART", nick: from, host: host, user: user} = msg, state) do sender = %SenderInfo{nick: from, host: host, user: user} - channel = msg.args |> List.first |> String.strip + channel = msg.args |> List.first |> String.trim if state.debug?, do: debug "#{from} LEFT A CHANNEL: #{channel}" channels = Channels.user_part(state.channels, channel, from) new_state = %{state | channels: channels} send_event {:parted, channel, sender}, new_state {:noreply, new_state} end def handle_data(%IrcMessage{cmd: "QUIT", nick: from, host: host, user: user} = msg, state) do sender = %SenderInfo{nick: from, host: host, user: user} reason = msg.args |> List.first if state.debug?, do: debug "#{from} QUIT" channels = Channels.user_quit(state.channels, from) new_state = %{state | channels: channels} send_event {:quit, reason, sender}, new_state {:noreply, new_state} end # Called when we receive a PING def handle_data(%IrcMessage{cmd: "PING"} = msg, %ClientState{autoping: true} = state) do if state.debug?, do: debug "RECEIVED A PING!" case msg do %IrcMessage{args: [from]} -> if state.debug?, do: debug("SENT PONG2") Transport.send(state, pong2!(from, msg.server)) _ -> if state.debug?, do: debug("SENT PONG1") Transport.send(state, pong1!(state.nick)) end {:noreply, state}; end # Called when we are invited to a channel def handle_data(%IrcMessage{cmd: "INVITE", args: [nick, channel], nick: by, host: host, user: user} = msg, %ClientState{nick: nick} = state) do sender = %SenderInfo{nick: by, host: host, user: user} if state.debug?, do: debug "RECEIVED AN INVITE: #{msg.args |> Enum.join(" ")}" send_event {:invited, sender, channel}, state {:noreply, state} end # Called when we are kicked from a channel def handle_data(%IrcMessage{cmd: "KICK", args: [channel, nick, reason], nick: by, host: host, user: user} = _msg, %ClientState{nick: nick} = state) do sender = %SenderInfo{nick: by, host: host, user: user} if state.debug?, do: debug "WE WERE KICKED FROM #{channel} BY #{by}" send_event {:kicked, sender, channel, reason}, state {:noreply, state} end # Called when someone else was kicked from a channel def handle_data(%IrcMessage{cmd: "KICK", args: [channel, nick, reason], nick: by, host: host, user: user} = _msg, state) do sender = %SenderInfo{nick: by, host: host, user: user} if state.debug?, do: debug "#{nick} WAS KICKED FROM #{channel} BY #{by}" send_event {:kicked, nick, sender, channel, reason}, state {:noreply, state} end # Called when someone sends us a message def handle_data(%IrcMessage{nick: from, cmd: "PRIVMSG", args: [nick, message], host: host, user: user} = _msg, %ClientState{nick: nick} = state) do sender = %SenderInfo{nick: from, host: host, user: user} if state.debug?, do: debug "#{from} SENT US #{message}" send_event {:received, message, sender}, state {:noreply, state} end # Called when someone sends a message to a channel we're in, or a list of users def handle_data(%IrcMessage{nick: from, cmd: "PRIVMSG", args: [to, message], host: host, user: user} = _msg, %ClientState{nick: nick} = state) do sender = %SenderInfo{nick: from, host: host, user: user} if state.debug?, do: debug "#{from} SENT #{message} TO #{to}" send_event {:received, message, sender, to}, state # If we were mentioned, fire that event as well if String.contains?(message, nick), do: send_event({:mentioned, message, sender, to}, state) {:noreply, state} end # Called when someone uses ACTION, i.e. `/me dies` def handle_data(%IrcMessage{nick: from, cmd: "ACTION", args: [channel, message], host: host, user: user} = _msg, state) do sender = %SenderInfo{nick: from, host: host, user: user} if state.debug?, do: debug "* #{from} #{message} in #{channel}" send_event {:me, message, sender, channel}, state {:noreply, state} end # Called when a NOTICE is received by the client. - def handle_data(%IrcMessage{nick: from, cmd: "NOTICE", args: [target, message], host: host, user: user} = _msg, state) do + def handle_data(%IrcMessage{nick: from, cmd: "NOTICE", args: [_target, message], host: host, user: user} = _msg, state) do sender = %SenderInfo{nick: from, host: host, user: user} if String.contains?(message, "identify") do if state.debug?, do: debug("* Told to identify by #{from}: #{message}") send_event({:identify, message, sender}, state) else if state.debug?, do: debug("* #{message} from #{sender}") send_event({:notice, message, sender}, state) end {:noreply, state} end # Called any time we receive an unrecognized message def handle_data(msg, state) do if state.debug? do debug "UNRECOGNIZED MSG: #{msg.cmd}"; IO.inspect(msg) end send_event {:unrecognized, msg.cmd, msg}, state {:noreply, state} end ############### # Internal API ############### defp send_event(msg, %ClientState{event_handlers: handlers}) when is_list(handlers) do Enum.each(handlers, fn({pid, _}) -> Kernel.send(pid, msg) end) end defp do_add_handler(pid, handlers) do case Enum.member?(handlers, pid) do false -> ref = Process.monitor(pid) [{pid, ref} | handlers] true -> handlers end end defp do_remove_handler(pid, handlers) do case List.keyfind(handlers, pid, 0) do {pid, ref} -> Process.demonitor(ref) List.keydelete(handlers, pid, 0) nil -> handlers end end defp debug(msg) do IO.puts(IO.ANSI.green() <> msg <> IO.ANSI.reset()) end end diff --git a/lib/exirc/commands.ex b/lib/exirc/commands.ex index 9f52b75..0d959c0 100644 --- a/lib/exirc/commands.ex +++ b/lib/exirc/commands.ex @@ -1,263 +1,288 @@ defmodule Irc.Commands do @moduledoc """ Defines IRC command constants, and methods for generating valid commands to send to an IRC server. """ defmacro __using__(_) do quote do import Irc.Commands #################### # IRC Numeric Codes #################### @rpl_welcome "001" @rpl_yourhost "002" @rpl_created "003" @rpl_myinfo "004" @rpl_isupport "005" # Defacto standard for server support @rpl_bounce "010" # Defacto replacement of "005" in RFC2812 @rpl_statsdline "250" #@doc """ #":There are users and invisible on servers" #""" @rpl_luserclient "251" #@doc """ # " :operator(s) online" #""" @rpl_luserop "252" #@doc """ #" :unknown connection(s)" #""" @rpl_luserunknown "253" #@doc """ #" :channels formed" #""" @rpl_luserchannels "254" #@doc """ #":I have clients and servers" #""" @rpl_luserme "255" #@doc """ #Local/Global user stats #""" @rpl_localusers "265" @rpl_globalusers "266" #@doc """ #When sending a TOPIC message to determine the channel topic, #one of two replies is sent. If the topic is set, RPL_TOPIC is sent back else #RPL_NOTOPIC. #""" + @rpl_whoiscertfp "276" + @rpl_whoisregnick "307" + @rpl_whoishelpop "310" + @rpl_whoisuser "311" + @rpl_whoisserver "312" + @rpl_whoisoperator "313" + @rpl_whoisidle "317" + @rpl_endofwhois "318" + @rpl_whoischannels "319" + @rpl_whoisaccount "330" @rpl_notopic "331" @rpl_topic "332" #@doc """ #To reply to a NAMES message, a reply pair consisting #of RPL_NAMREPLY and RPL_ENDOFNAMES is sent by the #server back to the client. If there is no channel #found as in the query, then only RPL_ENDOFNAMES is #returned. The exception to this is when a NAMES #message is sent with no parameters and all visible #channels and contents are sent back in a series of #RPL_NAMEREPLY messages with a RPL_ENDOFNAMES to mark #the end. #Format: " :[[@|+] [[@|+] [...]]]" #""" @rpl_namereply "353" @rpl_endofnames "366" #@doc """ #When responding to the MOTD message and the MOTD file #is found, the file is displayed line by line, with #each line no longer than 80 characters, using #RPL_MOTD format replies. These should be surrounded #by a RPL_MOTDSTART (before the RPL_MOTDs) and an #RPL_ENDOFMOTD (after). #""" @rpl_motd "372" @rpl_motdstart "375" @rpl_endofmotd "376" + @rpl_whoishost "378" + @rpl_whoismodes "379" ################ # Error Codes ################ #@doc """ #Used to indicate the nickname parameter supplied to a command is currently unused. #""" @err_no_such_nick "401" #@doc """ #Used to indicate the server name given currently doesn"t exist. #""" @err_no_such_server "402" #@doc """ #Used to indicate the given channel name is invalid. #""" @err_no_such_channel "403" #@doc """ #Sent to a user who is either (a) not on a channel which is mode +n or (b), #not a chanop (or mode +v) on a channel which has mode +m set, and is trying #to send a PRIVMSG message to that channel. #""" @err_cannot_send_to_chan "404" #@doc """ #Sent to a user when they have joined the maximum number of allowed channels #and they try to join another channel. #""" @err_too_many_channels "405" #@doc """ #Returned to a registered client to indicate that the command sent is unknown by the server. #""" @err_unknown_command "421" #@doc """ #Returned when a nickname parameter expected for a command and isn"t found. #""" @err_no_nickname_given "431" #@doc """ #Returned after receiving a NICK message which contains characters which do not fall in the defined set. #""" @err_erroneus_nickname "432" #@doc """ #Returned when a NICK message is processed that results in an attempt to #change to a currently existing nickname. #""" @err_nickname_in_use "433" #@doc """ #Returned by a server to a client when it detects a nickname collision #(registered of a NICK that already exists by another server). #""" @err_nick_collision "436" #@doc """ #""" @err_unavail_resource "437" #@doc """ #Returned by the server to indicate that the client must be registered before #the server will allow it to be parsed in detail. #""" @err_not_registered "451" #""" # Returned by the server by numerous commands to indicate to the client that # it didn"t supply enough parameters. #""" @err_need_more_params "461" #@doc """ #Returned by the server to any link which tries to change part of the registered #details (such as password or user details from second USER message). #""" @err_already_registered "462" #@doc """ #Returned by the server to the client when the issued command is restricted #""" @err_restricted "484" + @rpl_whoissecure "671" + ############### # Code groups ############### @logon_errors [ @err_no_nickname_given, @err_erroneus_nickname, @err_nickname_in_use, @err_nick_collision, @err_unavail_resource, @err_need_more_params, @err_already_registered, @err_restricted ] + + @whois_rpls [ @rpl_whoisuser, @rpl_whoishost, + @rpl_whoishost, @rpl_whoisserver, + @rpl_whoismodes, @rpl_whoisidle, + @rpl_endofwhois + ] end end ############ # Helpers ############ @ctcp_delimiter 0o001 @doc """ Builds a valid IRC command. """ def command!(cmd), do: [cmd, '\r\n'] @doc """ Builds a valid CTCP command. """ def ctcp!(cmd), do: command! [@ctcp_delimiter, cmd, @ctcp_delimiter] def ctcp!(cmd, args) do expanded = args |> Enum.intersperse(' ') command! [@ctcp_delimiter, cmd, expanded, @ctcp_delimiter] end # IRC Commands + @doc """ + Send a WHOIS request about a user + """ + def whois!(user), do: command! ['WHOIS ', user] + @doc """ Send password to server """ def pass!(pwd), do: command! ['PASS ', pwd] @doc """ Send nick to server. (Changes or sets your nick) """ def nick!(nick), do: command! ['NICK ', nick] @doc """ Send username to server. (Changes or sets your username) """ def user!(user, name) do command! ['USER ', user, ' 0 * :', name] end @doc """ Send PONG in response to PING """ def pong1!(nick), do: command! ['PONG ', nick] @doc """ Send a targeted PONG in response to PING """ def pong2!(nick, to), do: command! ['PONG ', nick, ' ', to] @doc """ Send message to channel or user """ def privmsg!(nick, msg), do: command! ['PRIVMSG ', nick, ' :', msg] @doc """ Send a `/me ` CTCP command to t """ def me!(channel, msg), do: command! ['PRIVMSG ', channel, ' :', @ctcp_delimiter, 'ACTION ', msg, @ctcp_delimiter] @doc """ Sends a command to the server to get the list of names back """ def names!(_channel), do: command! ['NAMES'] @doc """ Send notice to channel or user """ def notice!(nick, msg), do: command! ['NOTICE ', nick, ' :', msg] @doc """ Send join command to server (join a channel) """ def join!(channel), do: command! ['JOIN ', channel] def join!(channel, key), do: command! ['JOIN ', channel, ' ', key] @doc """ Send part command to server (leave a channel) """ def part!(channel), do: command! ['PART ', channel] @doc """ Send quit command to server (disconnect from server) """ def quit!(msg \\ "Leaving"), do: command! ['QUIT :', msg] @doc """ Send kick command to server """ def kick!(channel, nick, message \\ "") do case "#{message}" |> String.length do 0 -> command! ['KICK ', channel, ' ', nick] _ -> command! ['KICK ', channel, ' ', nick, ' ', message] end end @doc """ Send mode command to server MODE MODE [] """ def mode!(channel_or_nick, flags, args \\ "") do case "#{args}" |> String.length do 0 -> command! ['MODE ', channel_or_nick, ' ', flags] _ -> command! ['MODE ', channel_or_nick, ' ', flags, ' ', args] end end @doc """ Send an invite command """ def invite!(nick, channel) do command! ['INVITE ', nick, ' ', channel] end end diff --git a/lib/exirc/logger.ex b/lib/exirc/logger.ex index 6055221..65ae980 100644 --- a/lib/exirc/logger.ex +++ b/lib/exirc/logger.ex @@ -1,29 +1,29 @@ defmodule ExIrc.Logger do @moduledoc """ A simple abstraction of :error_logger """ @doc """ Log an informational message report """ @spec info(binary) :: :ok def info(msg) do - :error_logger.info_report String.to_char_list(msg) + :error_logger.info_report String.to_charlist(msg) end @doc """ Log a warning message report """ @spec warning(binary) :: :ok def warning(msg) do - :error_logger.warning_report String.to_char_list("#{IO.ANSI.yellow()}#{msg}#{IO.ANSI.reset()}") + :error_logger.warning_report String.to_charlist("#{IO.ANSI.yellow()}#{msg}#{IO.ANSI.reset()}") end @doc """ Log an error message report """ @spec error(binary) :: :ok def error(msg) do - :error_logger.error_report String.to_char_list("#{IO.ANSI.red()}#{msg}#{IO.ANSI.reset()}") + :error_logger.error_report String.to_charlist("#{IO.ANSI.red()}#{msg}#{IO.ANSI.reset()}") end end \ No newline at end of file diff --git a/lib/exirc/utils.ex b/lib/exirc/utils.ex index 46cba11..d79b326 100644 --- a/lib/exirc/utils.ex +++ b/lib/exirc/utils.ex @@ -1,188 +1,188 @@ defmodule ExIrc.Utils do ###################### # IRC Message Parsing ###################### @doc """ Parse an IRC message Example: data = ':irc.example.org 005 nick NETWORK=Freenode PREFIX=(ov)@+ CHANTYPES=#&' message = ExIrc.Utils.parse data assert "irc.example.org" = message.server """ - @spec parse(raw_data :: char_list) :: IrcMessage.t + @spec parse(raw_data :: charlist) :: IrcMessage.t def parse(raw_data) do data = :string.substr(raw_data, 1, length(raw_data)) case data do [?:|_] -> [[?:|from]|rest] = :string.tokens(data, ' ') get_cmd(rest, parse_from(from, %IrcMessage{ctcp: false})) data -> get_cmd(:string.tokens(data, ' '), %IrcMessage{ctcp: false}) end end @prefix_pattern ~r/^(?[^!\s]+)(?:!(?:(?[^@\s]+)@)?(?:(?[\S]+)))?$/ defp parse_from(from, msg) do from_str = IO.iodata_to_binary(from) parts = Regex.run(@prefix_pattern, from_str, capture: :all_but_first) case parts do [nick, user, host] -> %{msg | nick: nick, user: user, host: host} [nick, host] -> %{msg | nick: nick, host: host} [nick] -> if String.contains?(nick, ".") do %{msg | server: nick} else %{msg | nick: nick} end end end # Parse command from message defp get_cmd([cmd, arg1, [?:, 1 | ctcp_trail] | restargs], msg) when cmd == 'PRIVMSG' or cmd == 'NOTICE' do get_cmd([cmd, arg1, [1 | ctcp_trail] | restargs], msg) end defp get_cmd([cmd, target, [1 | ctcp_cmd] | cmd_args], msg) when cmd == 'PRIVMSG' or cmd == 'NOTICE' do args = cmd_args |> Enum.map(&Enum.take_while(&1, fn c -> c != 0o001 end)) |> Enum.map(&List.to_string/1) case args do args when args != [] -> %{msg | cmd: to_string(ctcp_cmd), args: [to_string(target), args |> Enum.join(" ")], ctcp: true } _ -> %{msg | cmd: to_string(cmd), ctcp: :invalid} end end defp get_cmd([cmd | rest], msg) do get_args(rest, %{msg | cmd: to_string(cmd)}) end # Parse command args from message defp get_args([], msg) do args = msg.args |> Enum.reverse |> Enum.filter(fn arg -> arg != [] end) |> Enum.map(&trim_crlf/1) |> Enum.map(&:binary.list_to_bin/1) |> Enum.map(fn(s) -> case String.valid?(s) do true -> :unicode.characters_to_binary(s) false -> :unicode.characters_to_binary(s, :latin1, :unicode) end end) post_process(%{msg | args: args}) end defp get_args([[?: | first_arg] | rest], msg) do args = (for arg <- [first_arg | rest], do: ' ' ++ trim_crlf(arg)) |> List.flatten case args do [_] -> get_args([], %{msg | args: msg.args}) [_ | full_trail] -> get_args([], %{msg | args: [full_trail | msg.args]}) end end defp get_args([arg | rest], msg) do get_args(rest, %{msg | args: [arg | msg.args]}) end # This function allows us to handle special case messages which are not RFC # compliant, before passing it to the client. defp post_process(%IrcMessage{cmd: "332", args: [nick, channel]} = msg) do # Handle malformed RPL_TOPIC messages which contain no topic %{msg | :cmd => "331", :args => [channel, "No topic is set"], :nick => nick} end defp post_process(msg), do: msg ############################ # Parse RPL_ISUPPORT (005) ############################ @doc """ Parse RPL_ISUPPORT message. If an empty list is provided, do nothing, otherwise parse CHANTYPES, NETWORK, and PREFIX parameters for relevant data. """ @spec isup(parameters :: list(binary), state :: ExIrc.Client.ClientState.t) :: ExIrc.Client.ClientState.t def isup([], state), do: state def isup([param | rest], state) do try do isup(rest, isup_param(param, state)) rescue _ -> isup(rest, state) end end defp isup_param("CHANTYPES=" <> channel_prefixes, state) do prefixes = channel_prefixes |> String.split("", trim: true) %{state | channel_prefixes: prefixes} end defp isup_param("NETWORK=" <> network, state) do %{state | network: network} end defp isup_param("PREFIX=" <> user_prefixes, state) do prefixes = Regex.run(~r/\((.*)\)(.*)/, user_prefixes, capture: :all_but_first) - |> Enum.map(&String.to_char_list/1) + |> Enum.map(&String.to_charlist/1) |> List.zip %{state | user_prefixes: prefixes} end defp isup_param(_, state) do state end ################### # Helper Functions ################### @days_of_week ['Mon','Tue','Wed','Thu','Fri','Sat','Sun'] @months_of_year ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'] @doc """ Get CTCP formatted time from a tuple representing the current calendar time: Example: iex> local_time = {{2013,12,6},{14,5,0}} {{2013,12,6},{14,5,0}} iex> ExIrc.Utils.ctcp_time local_time "Fri Dec 06 14:05:00 2013" """ @spec ctcp_time(datetime :: {{integer, integer, integer}, {integer, integer, integer}}) :: binary def ctcp_time({{y, m, d}, {h, n, s}} = _datetime) do [:lists.nth(:calendar.day_of_the_week(y,m,d), @days_of_week), ' ', :lists.nth(m, @months_of_year), ' ', - :io_lib.format("~2..0s", [Integer.to_char_list(d)]), + :io_lib.format("~2..0s", [Integer.to_charlist(d)]), ' ', - :io_lib.format("~2..0s", [Integer.to_char_list(h)]), + :io_lib.format("~2..0s", [Integer.to_charlist(h)]), ':', - :io_lib.format("~2..0s", [Integer.to_char_list(n)]), + :io_lib.format("~2..0s", [Integer.to_charlist(n)]), ':', - :io_lib.format("~2..0s", [Integer.to_char_list(s)]), + :io_lib.format("~2..0s", [Integer.to_charlist(s)]), ' ', - Integer.to_char_list(y)] |> List.flatten |> List.to_string + Integer.to_charlist(y)] |> List.flatten |> List.to_string end defp trim_crlf(charlist) do case Enum.reverse(charlist) do [?\n, ?\r | text] -> Enum.reverse(text) _ -> charlist end end end diff --git a/lib/exirc/whois.ex b/lib/exirc/whois.ex new file mode 100644 index 0000000..fb14e63 --- /dev/null +++ b/lib/exirc/whois.ex @@ -0,0 +1,18 @@ +defmodule Irc.Whois do + defstruct [account_name: nil, + channels: [], + helpop?: false, + hostname: nil, + idling_time: 0, + ircop?: false, + nickname: nil, + realname: nil, + registered_nick?: false, + server_address: nil, + server_name: nil, + signon_time: 0, + tls?: false, + username: nil, + ] +end +