├── .github └── workflows │ └── tests.yaml ├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── examples ├── README.md ├── bot │ ├── .gitignore │ ├── config │ │ └── config.exs │ ├── lib │ │ ├── bot.ex │ │ └── example.ex │ ├── mix.exs │ ├── mix.lock │ └── test │ │ ├── exirc_example_test.exs │ │ └── test_helper.exs └── ohai │ ├── connection_handler.ex │ ├── login_handler.ex │ ├── ohai_handler.ex │ └── ohai_irc.ex ├── lib ├── app.ex └── exirc │ ├── channels.ex │ ├── client.ex │ ├── commands.ex │ ├── example_handler.ex │ ├── exirc.ex │ ├── irc_message.ex │ ├── logger.ex │ ├── sender_info.ex │ ├── transport.ex │ ├── utils.ex │ ├── who.ex │ └── whois.ex ├── mix.exs ├── mix.lock └── test ├── channels_test.exs ├── client_test.exs ├── commands_test.exs ├── test_helper.exs └── utils_test.exs /.github/workflows/tests.yaml: -------------------------------------------------------------------------------- 1 | on: push 2 | 3 | jobs: 4 | test: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v2 8 | - uses: actions/setup-elixir@v1 9 | with: 10 | otp-version: '21.3' 11 | elixir-version: '1.6.6' 12 | - run: mix deps.get 13 | - run: mix test 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | doc/ 2 | doc 3 | deps/ 4 | ebin/ 5 | _build/ 6 | .exenv-version 7 | erl_crash.dump 8 | bench/snapshots 9 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: elixir 2 | sudo: false 3 | elixir: 4 | - 1.6 5 | otp_release: 6 | - 20.3 7 | script: 8 | - "MIX_ENV=test mix do deps.get, compile, coveralls.travis" 9 | notifications: 10 | recipients: 11 | - paulschoenfelder@gmail.com 12 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | ## [2.0.0] - 2020-07-19 11 | 12 | * Update Deprecated `:simple_one_to_one` to use DynamicSupervisor [#89](https://github.com/bitwalker/exirc/pull/89) 13 | * Switch to GitHub Actions for CI [#90](https://github.com/bitwalker/exirc/pull/90) 14 | * Start a Changelog for the 2.0.0 release series [#91](https://github.com/bitwalker/exirc/pull/91) 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013 Paul Schoenfelder 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ExIRC 2 | 3 | [![Build Status](https://travis-ci.org/bitwalker/exirc.svg?branch=master)](https://travis-ci.org/bitwalker/exirc) 4 | ![.github/workflows/tests.yaml](https://github.com/bitwalker/exirc/workflows/.github/workflows/tests.yaml/badge.svg) 5 | [![Hex.pm Version](http://img.shields.io/hexpm/v/exirc.svg?style=flat)](https://hex.pm/packages/exirc) 6 | 7 | ExIRC is a IRC client library for Elixir projects. It aims to have a clear, well 8 | documented API, with the minimal amount of code necessary to allow you to connect and 9 | communicate with IRC servers effectively. It aims to implement the full RFC2812 protocol, 10 | and relevant parts of RFC1459. 11 | 12 | ## Getting Started 13 | 14 | Add ExIRC as a dependency to your project in mix.exs, and add it as an application: 15 | 16 | ```elixir 17 | defp deps do 18 | [{:exirc, "~> x.x.x"}] 19 | end 20 | 21 | defp application do 22 | [applications: [:exirc], 23 | ...] 24 | end 25 | ``` 26 | 27 | Then fetch it using `mix deps.get`. 28 | 29 | To use ExIRC, you need to start a new client process, and add event handlers. An example event handler module 30 | is located in `lib/exirc/example_handler.ex`. **The example handler is kept up to date with all events you can 31 | expect to receive from the client**. A simple module is defined below as an example of how you might 32 | use ExIRC in practice. ExampleHandler here is the one that comes bundled with ExIRC. 33 | 34 | There is also a variety of examples in `examples`, the most up to date of which is `examples/bot`. 35 | 36 | ```elixir 37 | defmodule ExampleSupervisor do 38 | defmodule State do 39 | defstruct host: "chat.freenode.net", 40 | port: 6667, 41 | pass: "", 42 | nick: "bitwalker", 43 | user: "bitwalker", 44 | name: "Paul Schoenfelder", 45 | client: nil, 46 | handlers: [] 47 | end 48 | 49 | def start_link(_) do 50 | :gen_server.start_link(__MODULE__, [%State{}]) 51 | end 52 | 53 | def init(state) do 54 | # Start the client and handler processes, the ExIRC supervisor is automatically started when your app runs 55 | {:ok, client} = ExIRC.start_link!() 56 | {:ok, handler} = ExampleHandler.start_link(nil) 57 | 58 | # Register the event handler with ExIRC 59 | ExIRC.Client.add_handler client, handler 60 | 61 | # Connect and logon to a server, join a channel and send a simple message 62 | ExIRC.Client.connect! client, state.host, state.port 63 | ExIRC.Client.logon client, state.pass, state.nick, state.user, state.name 64 | ExIRC.Client.join client, "#elixir-lang" 65 | ExIRC.Client.msg client, :privmsg, "#elixir-lang", "Hello world!" 66 | 67 | {:ok, %{state | :client => client, :handlers => [handler]}} 68 | end 69 | 70 | def terminate(_, state) do 71 | # Quit the channel and close the underlying client connection when the process is terminating 72 | ExIRC.Client.quit state.client, "Goodbye, cruel world." 73 | ExIRC.Client.stop! state.client 74 | :ok 75 | end 76 | end 77 | ``` 78 | 79 | A more robust example usage will wait until connected before it attempts to logon and then wait until logged 80 | on until it attempts to join a channel. Please see the `examples` directory for more in-depth examples cases. 81 | 82 | ```elixir 83 | 84 | defmodule ExampleApplication do 85 | use Application 86 | 87 | # See https://hexdocs.pm/elixir/Application.html 88 | # for more information on OTP Applications 89 | @impl true 90 | def start(_type, _args) do 91 | {:ok, client} = ExIRC.start_link! 92 | 93 | children = [ 94 | # Define workers and child supervisors to be supervised 95 | {ExampleConnectionHandler, client}, 96 | # here's where we specify the channels to join: 97 | {ExampleLoginHandler, [client, ["#ohaibot-testing"]]} 98 | ] 99 | 100 | # See https://hexdocs.pm/elixir/Supervisor.html 101 | # for other strategies and supported options 102 | opts = [strategy: :one_for_one, name: ExampleApplication.Supervisor] 103 | Supervisor.start_link(children, opts) 104 | end 105 | end 106 | 107 | defmodule ExampleConnectionHandler do 108 | defmodule State do 109 | defstruct host: "chat.freenode.net", 110 | port: 6667, 111 | pass: "", 112 | nick: "bitwalker", 113 | user: "bitwalker", 114 | name: "Paul Schoenfelder", 115 | client: nil 116 | end 117 | 118 | def start_link(client, state \\ %State{}) do 119 | GenServer.start_link(__MODULE__, [%{state | client: client}]) 120 | end 121 | 122 | def init([state]) do 123 | ExIRC.Client.add_handler state.client, self 124 | ExIRC.Client.connect! state.client, state.host, state.port 125 | {:ok, state} 126 | end 127 | 128 | def handle_info({:connected, server, port}, state) do 129 | debug "Connected to #{server}:#{port}" 130 | ExIRC.Client.logon state.client, state.pass, state.nick, state.user, state.name 131 | {:noreply, state} 132 | end 133 | 134 | # Catch-all for messages you don't care about 135 | def handle_info(msg, state) do 136 | debug "Received unknown messsage:" 137 | IO.inspect msg 138 | {:noreply, state} 139 | end 140 | 141 | defp debug(msg) do 142 | IO.puts IO.ANSI.yellow() <> msg <> IO.ANSI.reset() 143 | end 144 | end 145 | 146 | defmodule ExampleLoginHandler do 147 | @moduledoc """ 148 | This is an example event handler that listens for login events and then 149 | joins the appropriate channels. We actually need this because we can't 150 | join channels until we've waited for login to complete. We could just 151 | attempt to sleep until login is complete, but that's just hacky. This 152 | as an event handler is a far more elegant solution. 153 | """ 154 | def start_link(client, channels) do 155 | GenServer.start_link(__MODULE__, [client, channels]) 156 | end 157 | 158 | def init([client, channels]) do 159 | ExIRC.Client.add_handler client, self 160 | {:ok, {client, channels}} 161 | end 162 | 163 | def handle_info(:logged_in, state = {client, channels}) do 164 | debug "Logged in to server" 165 | channels |> Enum.map(&ExIRC.Client.join client, &1) 166 | {:noreply, state} 167 | end 168 | 169 | # Catch-all for messages you don't care about 170 | def handle_info(_msg, state) do 171 | {:noreply, state} 172 | end 173 | 174 | defp debug(msg) do 175 | IO.puts IO.ANSI.yellow() <> msg <> IO.ANSI.reset() 176 | end 177 | end 178 | ``` 179 | 180 | ## Projects using ExIRC (in the wild!) 181 | 182 | Below is a list of projects that we know of (if we've missed anything, 183 | send a PR!) that use ExIRC in the wild. 184 | 185 | - [Kuma][kuma] by @ryanwinchester 186 | - [Offension][offension] by @shymega 187 | - [hedwig_irc][hedwig_irc] by @jeffweiss 188 | - [Hekateros][hekateros] by @tchoutri 189 | 190 | [kuma]: https://github.com/ryanwinchester/kuma 191 | [offension]: https://github.com/shymega/offension 192 | [hedwig_irc]: https://github.com/jeffweiss/hedwig_irc 193 | [hekateros]: https://github.com/friendshipismagic/hekateros 194 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | ## Example Bot 2 | 3 | Add bot configuration or change pre-existing one in `config/config.exs` 4 | 5 | Run using `mix run --no-halt` or `iex -S mix`, some basic info will be output to the console. 6 | 7 | Default app connects to a random room on Freenode, should just work out of the box. 8 | -------------------------------------------------------------------------------- /examples/bot/.gitignore: -------------------------------------------------------------------------------- 1 | /_build 2 | /cover 3 | /deps 4 | erl_crash.dump 5 | *.ez 6 | -------------------------------------------------------------------------------- /examples/bot/config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | use Mix.Config 4 | 5 | config :exirc_example, bots: [ 6 | %{:server => "chat.freenode.net", :port => 6667, 7 | :nick => "exirc-example", :user => "exirc-example", :name => "ExIRC Example Bot", 8 | :channel => "##exirc-test"} 9 | ] 10 | 11 | # This configuration is loaded before any dependency and is restricted 12 | # to this project. If another project depends on this project, this 13 | # file won't be loaded nor affect the parent project. For this reason, 14 | # if you want to provide default values for your application for 15 | # 3rd-party users, it should be done in your "mix.exs" file. 16 | 17 | # You can configure for your application as: 18 | # 19 | # config :exirc_example, key: :value 20 | # 21 | # And access this configuration in your application as: 22 | # 23 | # Application.get_env(:exirc_example, :key) 24 | # 25 | # Or configure a 3rd-party app: 26 | # 27 | # config :logger, level: :info 28 | # 29 | 30 | # It is also possible to import configuration files, relative to this 31 | # directory. For example, you can emulate configuration per environment 32 | # by uncommenting the line below and defining dev.exs, test.exs and such. 33 | # Configuration from the imported file will override the ones defined 34 | # here (which is why it is important to import them last). 35 | # 36 | # import_config "#{Mix.env}.exs" 37 | -------------------------------------------------------------------------------- /examples/bot/lib/bot.ex: -------------------------------------------------------------------------------- 1 | defmodule Example.Bot do 2 | use GenServer 3 | require Logger 4 | 5 | defmodule Config do 6 | defstruct server: nil, 7 | port: nil, 8 | pass: nil, 9 | nick: nil, 10 | user: nil, 11 | name: nil, 12 | channel: nil, 13 | client: nil 14 | 15 | def from_params(params) when is_map(params) do 16 | Enum.reduce(params, %Config{}, fn {k, v}, acc -> 17 | case Map.has_key?(acc, k) do 18 | true -> Map.put(acc, k, v) 19 | false -> acc 20 | end 21 | end) 22 | end 23 | end 24 | 25 | alias ExIRC.Client 26 | alias ExIRC.SenderInfo 27 | 28 | def start_link(%{:nick => nick} = params) when is_map(params) do 29 | config = Config.from_params(params) 30 | GenServer.start_link(__MODULE__, [config], name: String.to_atom(nick)) 31 | end 32 | 33 | def init([config]) do 34 | # Start the client and handler processes, the ExIRC supervisor is automatically started when your app runs 35 | {:ok, client} = ExIRC.start_link!() 36 | 37 | # Register the event handler with ExIRC 38 | Client.add_handler client, self() 39 | 40 | # Connect and logon to a server, join a channel and send a simple message 41 | Logger.debug "Connecting to #{config.server}:#{config.port}" 42 | Client.connect! client, config.server, config.port 43 | 44 | {:ok, %Config{config | :client => client}} 45 | end 46 | 47 | def handle_info({:connected, server, port}, config) do 48 | Logger.debug "Connected to #{server}:#{port}" 49 | Logger.debug "Logging to #{server}:#{port} as #{config.nick}.." 50 | Client.logon config.client, config.pass, config.nick, config.user, config.name 51 | {:noreply, config} 52 | end 53 | def handle_info(:logged_in, config) do 54 | Logger.debug "Logged in to #{config.server}:#{config.port}" 55 | Logger.debug "Joining #{config.channel}.." 56 | Client.join config.client, config.channel 57 | {:noreply, config} 58 | end 59 | def handle_info({:login_failed, :nick_in_use}, config) do 60 | nick = Enum.map(1..8, fn x -> Enum.random('abcdefghijklmnopqrstuvwxyz') end) 61 | Client.nick config.client, to_string(nick) 62 | {:noreply, config} 63 | end 64 | def handle_info(:disconnected, config) do 65 | Logger.debug "Disconnected from #{config.server}:#{config.port}" 66 | {:stop, :normal, config} 67 | end 68 | def handle_info({:joined, channel}, config) do 69 | Logger.debug "Joined #{channel}" 70 | Client.msg config.client, :privmsg, config.channel, "Hello world!" 71 | {:noreply, config} 72 | end 73 | def handle_info({:names_list, channel, names_list}, config) do 74 | names = String.split(names_list, " ", trim: true) 75 | |> Enum.map(fn name -> " #{name}\n" end) 76 | Logger.info "Users logged in to #{channel}:\n#{names}" 77 | {:noreply, config} 78 | end 79 | def handle_info({:received, msg, %SenderInfo{:nick => nick}, channel}, config) do 80 | Logger.info "#{nick} from #{channel}: #{msg}" 81 | {:noreply, config} 82 | end 83 | def handle_info({:mentioned, msg, %SenderInfo{:nick => nick}, channel}, config) do 84 | Logger.warn "#{nick} mentioned you in #{channel}" 85 | case String.contains?(msg, "hi") do 86 | true -> 87 | reply = "Hi #{nick}!" 88 | Client.msg config.client, :privmsg, config.channel, reply 89 | Logger.info "Sent #{reply} to #{config.channel}" 90 | false -> 91 | :ok 92 | end 93 | {:noreply, config} 94 | end 95 | def handle_info({:received, msg, %SenderInfo{:nick => nick}}, config) do 96 | Logger.warn "#{nick}: #{msg}" 97 | reply = "Hi!" 98 | Client.msg config.client, :privmsg, nick, reply 99 | Logger.info "Sent #{reply} to #{nick}" 100 | {:noreply, config} 101 | end 102 | # Catch-all for messages you don't care about 103 | def handle_info(_msg, config) do 104 | {:noreply, config} 105 | end 106 | 107 | def terminate(_, state) do 108 | # Quit the channel and close the underlying client connection when the process is terminating 109 | Client.quit state.client, "Goodbye, cruel world." 110 | Client.stop! state.client 111 | :ok 112 | end 113 | end 114 | -------------------------------------------------------------------------------- /examples/bot/lib/example.ex: -------------------------------------------------------------------------------- 1 | defmodule Example do 2 | use Application 3 | 4 | alias Example.Bot 5 | 6 | # See https://hexdocs.pm/elixir/Application.html 7 | # for more information on OTP Applications 8 | @impl true 9 | def start(_type, _args) do 10 | children = 11 | Application.get_env(:exirc_example, :bots) 12 | |> Enum.map(fn bot -> worker(Bot, [bot]) end) 13 | 14 | # See https://hexdocs.pm/elixir/Supervisor.html 15 | # for other strategies and supported options 16 | opts = [strategy: :one_for_one, name: Example.Supervisor] 17 | Supervisor.start_link(children, opts) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /examples/bot/mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Example.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :exirc_example, 7 | version: "0.0.1", 8 | elixir: "~> 1.6", 9 | build_embedded: Mix.env() == :prod, 10 | start_permanent: Mix.env() == :prod, 11 | deps: deps() 12 | ] 13 | end 14 | 15 | # Configuration for the OTP application 16 | # 17 | # Type "mix help compile.app" for more information 18 | def application do 19 | [applications: [:logger, :exirc], mod: {Example, []}] 20 | end 21 | 22 | # Dependencies can be Hex packages: 23 | # 24 | # {:mydep, "~> 0.3.0"} 25 | # 26 | # Or git/path repositories: 27 | # 28 | # {:mydep, git: "https://github.com/elixir-lang/mydep.git", tag: "0.1.0"} 29 | # 30 | # Type "mix help deps" for more examples and options 31 | defp deps do 32 | [{:exirc, ">= 0.0.0"}] 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /examples/bot/mix.lock: -------------------------------------------------------------------------------- 1 | %{"exirc": {:hex, :exirc, "0.10.0"}} 2 | -------------------------------------------------------------------------------- /examples/bot/test/exirc_example_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExircExampleTest do 2 | use ExUnit.Case 3 | doctest ExircExample 4 | 5 | test "the truth" do 6 | assert 1 + 1 == 2 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /examples/bot/test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /examples/ohai/connection_handler.ex: -------------------------------------------------------------------------------- 1 | defmodule ConnectionHandler do 2 | defmodule State do 3 | defstruct host: "chat.freenode.net", 4 | port: 6667, 5 | pass: "ohaipassword", 6 | nick: "ohaibot", 7 | user: "ohaibot", 8 | name: "ohaibot welcomes you", 9 | client: nil 10 | end 11 | 12 | def start_link(client, state \\ %State{}) do 13 | GenServer.start_link(__MODULE__, [%{state | client: client}]) 14 | end 15 | 16 | def init([state]) do 17 | ExIRC.Client.add_handler state.client, self 18 | ExIRC.Client.connect! state.client, state.host, state.port 19 | {:ok, state} 20 | end 21 | 22 | def handle_info({:connected, server, port}, state) do 23 | debug "Connected to #{server}:#{port}" 24 | ExIRC.Client.logon state.client, state.pass, state.nick, state.user, state.name 25 | {:noreply, state} 26 | end 27 | 28 | # Catch-all for messages you don't care about 29 | def handle_info(msg, state) do 30 | debug "Received unknown messsage:" 31 | IO.inspect msg 32 | {:noreply, state} 33 | end 34 | 35 | defp debug(msg) do 36 | IO.puts IO.ANSI.yellow() <> msg <> IO.ANSI.reset() 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /examples/ohai/login_handler.ex: -------------------------------------------------------------------------------- 1 | defmodule LoginHandler do 2 | @moduledoc """ 3 | This is an example event handler that listens for login events and then 4 | joins the appropriate channels. We actually need this because we can't 5 | join channels until we've waited for login to complete. We could just 6 | attempt to sleep until login is complete, but that's just hacky. This 7 | as an event handler is a far more elegant solution. 8 | """ 9 | def start_link(client, channels) do 10 | GenServer.start_link(__MODULE__, [client, channels]) 11 | end 12 | 13 | def init([client, channels]) do 14 | ExIRC.Client.add_handler client, self 15 | {:ok, {client, channels}} 16 | end 17 | 18 | def handle_info(:logged_in, state = {client, channels}) do 19 | debug "Logged in to server" 20 | channels |> Enum.map(&ExIRC.Client.join client, &1) 21 | {:noreply, state} 22 | end 23 | 24 | # Catch-all for messages you don't care about 25 | def handle_info(_msg, state) do 26 | {:noreply, state} 27 | end 28 | 29 | defp debug(msg) do 30 | IO.puts IO.ANSI.yellow() <> msg <> IO.ANSI.reset() 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /examples/ohai/ohai_handler.ex: -------------------------------------------------------------------------------- 1 | defmodule OhaiHandler do 2 | @moduledoc """ 3 | This is an example event handler that greets users when they join a channel 4 | """ 5 | def start_link(client) do 6 | GenServer.start_link(__MODULE__, [client]) 7 | end 8 | 9 | def init([client]) do 10 | ExIRC.Client.add_handler client, self 11 | {:ok, client} 12 | end 13 | 14 | def handle_info({:joined, channel}, client) do 15 | debug "Joined #{channel}" 16 | {:noreply, client} 17 | end 18 | 19 | def handle_info({:joined, channel, user}, client) do 20 | # ExIRC currently has a bug that doesn't remove the \r\n from the end 21 | # of the channel name with it sends along this kind of message 22 | # so we ensure any trailing or leading whitespace is explicitly removed 23 | channel = String.strip(channel) 24 | debug "#{user} joined #{channel}" 25 | ExIRC.Client.msg(client, :privmsg, channel, "ohai #{user}") 26 | {:noreply, client} 27 | end 28 | 29 | # Catch-all for messages you don't care about 30 | def handle_info(_msg, state) do 31 | {:noreply, state} 32 | end 33 | 34 | defp debug(msg) do 35 | IO.puts IO.ANSI.yellow() <> msg <> IO.ANSI.reset() 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /examples/ohai/ohai_irc.ex: -------------------------------------------------------------------------------- 1 | defmodule OhaiIrc do 2 | use Application 3 | 4 | # See http://elixir-lang.org/docs/stable/elixir/Application.html 5 | # for more information on OTP Applications 6 | def start(_type, _args) do 7 | import Supervisor.Spec, warn: false 8 | 9 | {:ok, client} = ExIRC.start_client! 10 | 11 | children = [ 12 | # Define workers and child supervisors to be supervised 13 | worker(ConnectionHandler, [client]), 14 | worker(LoginHandler, [client, ["#ohaibot-testing"]]), 15 | worker(OhaiHandler, [client]) 16 | ] 17 | 18 | # See http://elixir-lang.org/docs/stable/elixir/Supervisor.html 19 | # for other strategies and supported options 20 | opts = [strategy: :one_for_one, name: OhaiIrc.Supervisor] 21 | Supervisor.start_link(children, opts) 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/app.ex: -------------------------------------------------------------------------------- 1 | defmodule ExIRC.App do 2 | @moduledoc """ 3 | Entry point for the ExIRC application. 4 | """ 5 | use Application 6 | 7 | def start(_type, _args) do 8 | ExIRC.start! 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /lib/exirc/channels.ex: -------------------------------------------------------------------------------- 1 | defmodule ExIRC.Channels do 2 | @moduledoc """ 3 | Responsible for managing channel state 4 | """ 5 | use ExIRC.Commands 6 | 7 | import String, only: [downcase: 1] 8 | 9 | defmodule Channel do 10 | defstruct name: '', 11 | topic: '', 12 | users: [], 13 | modes: '', 14 | type: '' 15 | end 16 | 17 | @doc """ 18 | Initialize a new Channels data store 19 | """ 20 | def init() do 21 | :gb_trees.empty() 22 | end 23 | 24 | ################## 25 | # Self JOIN/PART 26 | ################## 27 | 28 | @doc """ 29 | Add a channel to the data store when joining a channel 30 | """ 31 | def join(channel_tree, channel_name) do 32 | name = downcase(channel_name) 33 | case :gb_trees.lookup(name, channel_tree) do 34 | {:value, _} -> 35 | channel_tree 36 | :none -> 37 | :gb_trees.insert(name, %Channel{name: name}, channel_tree) 38 | end 39 | end 40 | 41 | @doc """ 42 | Remove a channel from the data store when leaving a channel 43 | """ 44 | def part(channel_tree, channel_name) do 45 | name = downcase(channel_name) 46 | case :gb_trees.lookup(name, channel_tree) do 47 | {:value, _} -> 48 | :gb_trees.delete(name, channel_tree) 49 | :none -> 50 | channel_tree 51 | end 52 | end 53 | 54 | ########################### 55 | # Channel Modes/Attributes 56 | ########################### 57 | 58 | @doc """ 59 | Update the topic for a tracked channel when it changes 60 | """ 61 | def set_topic(channel_tree, channel_name, topic) do 62 | name = downcase(channel_name) 63 | case :gb_trees.lookup(name, channel_tree) do 64 | {:value, channel} -> 65 | :gb_trees.enter(name, %{channel | topic: topic}, channel_tree) 66 | :none -> 67 | channel_tree 68 | end 69 | end 70 | 71 | @doc """ 72 | Update the type of a tracked channel when it changes 73 | """ 74 | def set_type(channel_tree, channel_name, channel_type) when is_binary(channel_type) do 75 | set_type(channel_tree, channel_name, String.to_charlist(channel_type)) 76 | end 77 | def set_type(channel_tree, channel_name, channel_type) do 78 | name = downcase(channel_name) 79 | case :gb_trees.lookup(name, channel_tree) do 80 | {:value, channel} -> 81 | type = case channel_type do 82 | '@' -> :secret 83 | '*' -> :private 84 | '=' -> :public 85 | end 86 | :gb_trees.enter(name, %{channel | type: type}, channel_tree) 87 | :none -> 88 | channel_tree 89 | end 90 | end 91 | 92 | #################################### 93 | # Users JOIN/PART/AKAs(namechange) 94 | #################################### 95 | 96 | @doc """ 97 | Add a user to a tracked channel when they join 98 | """ 99 | def user_join(channel_tree, channel_name, nick) when not is_list(nick) do 100 | users_join(channel_tree, channel_name, [nick]) 101 | end 102 | 103 | @doc """ 104 | Add multiple users to a tracked channel (used primarily in conjunction with the NAMES command) 105 | """ 106 | def users_join(channel_tree, channel_name, nicks) do 107 | pnicks = trim_rank(nicks) 108 | manipfn = fn(channel_nicks) -> :lists.usort(channel_nicks ++ pnicks) end 109 | users_manip(channel_tree, channel_name, manipfn) 110 | end 111 | 112 | @doc """ 113 | Remove a user from a tracked channel when they leave 114 | """ 115 | def user_part(channel_tree, channel_name, nick) do 116 | pnick = trim_rank([nick]) 117 | manipfn = fn(channel_nicks) -> :lists.usort(channel_nicks -- pnick) end 118 | users_manip(channel_tree, channel_name, manipfn) 119 | end 120 | 121 | def user_quit(channel_tree, nick) do 122 | pnick = trim_rank([nick]) 123 | manipfn = fn(channel_nicks) -> :lists.usort(channel_nicks -- pnick) end 124 | foldl = fn(channel_name, new_channel_tree) -> 125 | name = downcase(channel_name) 126 | users_manip(new_channel_tree, name, manipfn) 127 | end 128 | :lists.foldl(foldl, channel_tree, channels(channel_tree)) 129 | end 130 | 131 | @doc """ 132 | Update the nick of a user in a tracked channel when they change their nick 133 | """ 134 | def user_rename(channel_tree, nick, new_nick) do 135 | manipfn = fn(channel_nicks) -> 136 | case Enum.member?(channel_nicks, nick) do 137 | true -> [new_nick | channel_nicks -- [nick]] |> Enum.uniq |> Enum.sort 138 | false -> channel_nicks 139 | end 140 | end 141 | foldl = fn(channel_name, new_channel_tree) -> 142 | name = downcase(channel_name) 143 | users_manip(new_channel_tree, name, manipfn) 144 | end 145 | :lists.foldl(foldl, channel_tree, channels(channel_tree)) 146 | end 147 | 148 | ################ 149 | # Introspection 150 | ################ 151 | 152 | @doc """ 153 | Get a list of all currently tracked channels 154 | """ 155 | def channels(channel_tree) do 156 | (for {channel_name, _chan} <- :gb_trees.to_list(channel_tree), do: channel_name) |> Enum.reverse 157 | end 158 | 159 | @doc """ 160 | Get a list of all users in a tracked channel 161 | """ 162 | def channel_users(channel_tree, channel_name) do 163 | case get_attr(channel_tree, channel_name, fn(%Channel{users: users}) -> users end) do 164 | {:error, _} = error -> error 165 | users -> Enum.reverse(users) 166 | end 167 | end 168 | 169 | @doc """ 170 | Get the current topic for a tracked channel 171 | """ 172 | def channel_topic(channel_tree, channel_name) do 173 | case get_attr(channel_tree, channel_name, fn(%Channel{topic: topic}) -> topic end) do 174 | [] -> "No topic" 175 | topic -> topic 176 | end 177 | end 178 | 179 | @doc """ 180 | Get the type of a tracked channel 181 | """ 182 | def channel_type(channel_tree, channel_name) do 183 | case get_attr(channel_tree, channel_name, fn(%Channel{type: type}) -> type end) do 184 | [] -> :unknown 185 | type -> type 186 | end 187 | end 188 | 189 | @doc """ 190 | Determine if a user is present in a tracked channel 191 | """ 192 | def channel_has_user?(channel_tree, channel_name, nick) do 193 | get_attr(channel_tree, channel_name, fn(%Channel{users: users}) -> :lists.member(nick, users) end) 194 | end 195 | 196 | @doc """ 197 | Get all channel data as a tuple of the channel name and a proplist of metadata. 198 | 199 | Example Result: 200 | 201 | [{"#testchannel", [users: ["userA", "userB"], topic: "Just a test channel.", type: :public] }] 202 | """ 203 | def to_proplist(channel_tree) do 204 | for {channel_name, chan} <- :gb_trees.to_list(channel_tree) do 205 | {channel_name, [users: chan.users, topic: chan.topic, type: chan.type]} 206 | end |> Enum.reverse 207 | end 208 | 209 | #################### 210 | # Internal API 211 | #################### 212 | defp users_manip(channel_tree, channel_name, manipfn) do 213 | name = downcase(channel_name) 214 | case :gb_trees.lookup(name, channel_tree) do 215 | {:value, channel} -> 216 | channel_list = manipfn.(channel.users) 217 | :gb_trees.enter(channel_name, %{channel | users: channel_list}, channel_tree) 218 | :none -> 219 | channel_tree 220 | end 221 | end 222 | 223 | defp trim_rank(nicks) do 224 | nicks |> Enum.map(fn(n) -> case n do 225 | << "@", nick :: binary >> -> nick 226 | << "+", nick :: binary >> -> nick 227 | << "%", nick :: binary >> -> nick 228 | << "&", nick :: binary >> -> nick 229 | << "~", nick :: binary >> -> nick 230 | nick -> nick 231 | end 232 | end) 233 | end 234 | 235 | defp get_attr(channel_tree, channel_name, getfn) do 236 | name = downcase(channel_name) 237 | case :gb_trees.lookup(name, channel_tree) do 238 | {:value, channel} -> getfn.(channel) 239 | :none -> {:error, :no_such_channel} 240 | end 241 | end 242 | 243 | end 244 | -------------------------------------------------------------------------------- /lib/exirc/client.ex: -------------------------------------------------------------------------------- 1 | defmodule ExIRC.Client do 2 | @moduledoc """ 3 | Maintains the state and behaviour for individual IRC client connections 4 | """ 5 | use ExIRC.Commands 6 | use GenServer 7 | import ExIRC.Logger 8 | 9 | alias ExIRC.Channels 10 | alias ExIRC.Utils 11 | alias ExIRC.SenderInfo 12 | alias ExIRC.Client.Transport 13 | 14 | # Client internal state 15 | defmodule ClientState do 16 | defstruct event_handlers: [], 17 | server: "localhost", 18 | port: 6667, 19 | socket: nil, 20 | nick: "", 21 | pass: "", 22 | user: "", 23 | name: "", 24 | ssl?: false, 25 | connected?: false, 26 | logged_on?: false, 27 | autoping: true, 28 | channel_prefixes: "", 29 | network: "", 30 | user_prefixes: "", 31 | login_time: "", 32 | channels: [], 33 | debug?: false, 34 | retries: 0, 35 | inet: :inet, 36 | owner: nil, 37 | whois_buffers: %{}, 38 | who_buffers: %{} 39 | end 40 | 41 | ################# 42 | # External API 43 | ################# 44 | 45 | @doc """ 46 | Start a new IRC client process 47 | 48 | Returns either {:ok, pid} or {:error, reason} 49 | """ 50 | @spec start!(options :: list() | nil) :: {:ok, pid} | {:error, term} 51 | def start!(options \\ []) do 52 | start_link(options) 53 | end 54 | @doc """ 55 | Start a new IRC client process. 56 | 57 | Returns either {:ok, pid} or {:error, reason} 58 | """ 59 | @spec start_link(options :: list() | nil, process_opts :: list() | nil) :: {:ok, pid} | {:error, term} 60 | def start_link(options \\ [], process_opts \\ []) do 61 | options = Keyword.put_new(options, :owner, self()) 62 | GenServer.start_link(__MODULE__, options, process_opts) 63 | end 64 | @doc """ 65 | Stop the IRC client process 66 | """ 67 | @spec stop!(client :: pid) :: :ok 68 | def stop!(client) do 69 | GenServer.call(client, :stop) 70 | end 71 | @doc """ 72 | Connect to a server with the provided server and port 73 | 74 | Example: 75 | Client.connect! pid, "localhost", 6667 76 | """ 77 | @spec connect!(client :: pid, server :: binary, port :: non_neg_integer, options :: list() | nil) :: :ok 78 | def connect!(client, server, port, options \\ []) do 79 | GenServer.call(client, {:connect, server, port, options, false}, :infinity) 80 | end 81 | @doc """ 82 | Connect to a server with the provided server and port via SSL 83 | 84 | Example: 85 | Client.connect! pid, "localhost", 6697 86 | """ 87 | @spec connect_ssl!(client :: pid, server :: binary, port :: non_neg_integer, options :: list() | nil) :: :ok 88 | def connect_ssl!(client, server, port, options \\ []) do 89 | GenServer.call(client, {:connect, server, port, options, true}, :infinity) 90 | end 91 | @doc """ 92 | Determine if the provided client process has an open connection to a server 93 | """ 94 | @spec is_connected?(client :: pid) :: true | false 95 | def is_connected?(client) do 96 | GenServer.call(client, :is_connected?) 97 | end 98 | @doc """ 99 | Logon to a server 100 | 101 | Example: 102 | Client.logon pid, "password", "mynick", "user", "My Name" 103 | """ 104 | @spec logon(client :: pid, pass :: binary, nick :: binary, user :: binary, name :: binary) :: :ok | {:error, :not_connected} 105 | def logon(client, pass, nick, user, name) do 106 | GenServer.call(client, {:logon, pass, nick, user, name}, :infinity) 107 | end 108 | @doc """ 109 | Determine if the provided client is logged on to a server 110 | """ 111 | @spec is_logged_on?(client :: pid) :: true | false 112 | def is_logged_on?(client) do 113 | GenServer.call(client, :is_logged_on?) 114 | end 115 | @doc """ 116 | Send a message to a nick or channel 117 | Message types are: 118 | :privmsg 119 | :notice 120 | :ctcp 121 | """ 122 | @spec msg(client :: pid, type :: atom, nick :: binary, msg :: binary) :: :ok 123 | def msg(client, type, nick, msg) do 124 | GenServer.call(client, {:msg, type, nick, msg}, :infinity) 125 | end 126 | @doc """ 127 | Send an action message, i.e. (/me slaps someone with a big trout) 128 | """ 129 | @spec me(client :: pid, channel :: binary, msg :: binary) :: :ok 130 | def me(client, channel, msg) do 131 | GenServer.call(client, {:me, channel, msg}, :infinity) 132 | end 133 | @doc """ 134 | Change the client's nick 135 | """ 136 | @spec nick(client :: pid, new_nick :: binary) :: :ok 137 | def nick(client, new_nick) do 138 | GenServer.call(client, {:nick, new_nick}, :infinity) 139 | end 140 | @doc """ 141 | Send a raw IRC command 142 | """ 143 | @spec cmd(client :: pid, raw_cmd :: binary) :: :ok 144 | def cmd(client, raw_cmd) do 145 | GenServer.call(client, {:cmd, raw_cmd}) 146 | end 147 | @doc """ 148 | Join a channel, with an optional password 149 | """ 150 | @spec join(client :: pid, channel :: binary, key :: binary | nil) :: :ok 151 | def join(client, channel, key \\ "") do 152 | GenServer.call(client, {:join, channel, key}, :infinity) 153 | end 154 | @doc """ 155 | Leave a channel 156 | """ 157 | @spec part(client :: pid, channel :: binary) :: :ok 158 | def part(client, channel) do 159 | GenServer.call(client, {:part, channel}, :infinity) 160 | end 161 | @doc """ 162 | Kick a user from a channel 163 | """ 164 | @spec kick(client :: pid, channel :: binary, nick :: binary, message :: binary | nil) :: :ok 165 | def kick(client, channel, nick, message \\ "") do 166 | GenServer.call(client, {:kick, channel, nick, message}, :infinity) 167 | end 168 | @spec names(client :: pid, channel :: binary) :: :ok 169 | def names(client, channel) do 170 | GenServer.call(client, {:names, channel}, :infinity) 171 | end 172 | 173 | @doc """ 174 | Ask the server for the user's informations. 175 | """ 176 | @spec whois(client :: pid, user :: binary) :: :ok 177 | def whois(client, user) do 178 | GenServer.call(client, {:whois, user}, :infinity) 179 | end 180 | 181 | @doc """ 182 | Ask the server for the channel's users 183 | """ 184 | @spec who(client :: pid, channel :: binary) :: :ok 185 | def who(client, channel) do 186 | GenServer.call(client, {:who, channel}, :infinity) 187 | end 188 | 189 | @doc """ 190 | Change mode for a user or channel 191 | """ 192 | @spec mode(client :: pid, channel_or_nick :: binary, flags :: binary, args :: binary | nil) :: :ok 193 | def mode(client, channel_or_nick, flags, args \\ "") do 194 | GenServer.call(client, {:mode, channel_or_nick, flags, args}, :infinity) 195 | end 196 | @doc """ 197 | Invite a user to a channel 198 | """ 199 | @spec invite(client :: pid, nick :: binary, channel :: binary) :: :ok 200 | def invite(client, nick, channel) do 201 | GenServer.call(client, {:invite, nick, channel}, :infinity) 202 | end 203 | @doc """ 204 | Quit the server, with an optional part message 205 | """ 206 | @spec quit(client :: pid, msg :: binary | nil) :: :ok 207 | def quit(client, msg \\ "Leaving..") do 208 | GenServer.call(client, {:quit, msg}, :infinity) 209 | end 210 | @doc """ 211 | Get details about each of the client's currently joined channels 212 | """ 213 | @spec channels(client :: pid) :: [binary] 214 | def channels(client) do 215 | GenServer.call(client, :channels) 216 | end 217 | @doc """ 218 | Get a list of users in the provided channel 219 | """ 220 | @spec channel_users(client :: pid, channel :: binary) :: [binary] | {:error, atom} 221 | def channel_users(client, channel) do 222 | GenServer.call(client, {:channel_users, channel}) 223 | end 224 | @doc """ 225 | Get the topic of the provided channel 226 | """ 227 | @spec channel_topic(client :: pid, channel :: binary) :: binary | {:error, atom} 228 | def channel_topic(client, channel) do 229 | GenServer.call(client, {:channel_topic, channel}) 230 | end 231 | @doc """ 232 | Get the channel type of the provided channel 233 | """ 234 | @spec channel_type(client :: pid, channel :: binary) :: atom | {:error, atom} 235 | def channel_type(client, channel) do 236 | GenServer.call(client, {:channel_type, channel}) 237 | end 238 | @doc """ 239 | Determine if a nick is present in the provided channel 240 | """ 241 | @spec channel_has_user?(client :: pid, channel :: binary, nick :: binary) :: boolean | {:error, atom} 242 | def channel_has_user?(client, channel, nick) do 243 | GenServer.call(client, {:channel_has_user?, channel, nick}) 244 | end 245 | @doc """ 246 | Add a new event handler process 247 | """ 248 | @spec add_handler(client :: pid, pid) :: :ok 249 | def add_handler(client, pid) do 250 | GenServer.call(client, {:add_handler, pid}) 251 | end 252 | @doc """ 253 | Add a new event handler process, asynchronously 254 | """ 255 | @spec add_handler_async(client :: pid, pid) :: :ok 256 | def add_handler_async(client, pid) do 257 | GenServer.cast(client, {:add_handler, pid}) 258 | end 259 | @doc """ 260 | Remove an event handler process 261 | """ 262 | @spec remove_handler(client :: pid, pid) :: :ok 263 | def remove_handler(client, pid) do 264 | GenServer.call(client, {:remove_handler, pid}) 265 | end 266 | @doc """ 267 | Remove an event handler process, asynchronously 268 | """ 269 | @spec remove_handler_async(client :: pid, pid) :: :ok 270 | def remove_handler_async(client, pid) do 271 | GenServer.cast(client, {:remove_handler, pid}) 272 | end 273 | @doc """ 274 | Get the current state of the provided client 275 | """ 276 | @spec state(client :: pid) :: [{atom, any}] 277 | def state(client) do 278 | state = GenServer.call(client, :state) 279 | [server: state.server, 280 | port: state.port, 281 | nick: state.nick, 282 | pass: state.pass, 283 | user: state.user, 284 | name: state.name, 285 | autoping: state.autoping, 286 | ssl?: state.ssl?, 287 | connected?: state.connected?, 288 | logged_on?: state.logged_on?, 289 | channel_prefixes: state.channel_prefixes, 290 | user_prefixes: state.user_prefixes, 291 | channels: Channels.to_proplist(state.channels), 292 | network: state.network, 293 | login_time: state.login_time, 294 | debug?: state.debug?, 295 | event_handlers: state.event_handlers] 296 | end 297 | 298 | ############### 299 | # GenServer API 300 | ############### 301 | 302 | @doc """ 303 | Called when GenServer initializes the client 304 | """ 305 | @spec init(list(any) | []) :: {:ok, ClientState.t} 306 | def init(options \\ []) do 307 | autoping = Keyword.get(options, :autoping, true) 308 | debug = Keyword.get(options, :debug, false) 309 | owner = Keyword.fetch!(options, :owner) 310 | # Add event handlers 311 | handlers = 312 | Keyword.get(options, :event_handlers, []) 313 | |> List.foldl([], &do_add_handler/2) 314 | ref = Process.monitor(owner) 315 | # Return initial state 316 | {:ok, %ClientState{ 317 | event_handlers: handlers, 318 | autoping: autoping, 319 | logged_on?: false, 320 | debug?: debug, 321 | channels: ExIRC.Channels.init(), 322 | owner: {owner, ref}}} 323 | end 324 | @doc """ 325 | Handle calls from the external API. It is not recommended to call these directly. 326 | """ 327 | # Handle call to get the current state of the client process 328 | def handle_call(:state, _from, state), do: {:reply, state, state} 329 | # Handle call to stop the current client process 330 | def handle_call(:stop, _from, state) do 331 | # Ensure the socket connection is closed if stop is called while still connected to the server 332 | if state.connected?, do: Transport.close(state) 333 | {:stop, :normal, :ok, %{state | connected?: false, logged_on?: false, socket: nil}} 334 | end 335 | # Handles call to add a new event handler process 336 | def handle_call({:add_handler, pid}, _from, state) do 337 | handlers = do_add_handler(pid, state.event_handlers) 338 | {:reply, :ok, %{state | event_handlers: handlers}} 339 | end 340 | # Handles call to remove an event handler process 341 | def handle_call({:remove_handler, pid}, _from, state) do 342 | handlers = do_remove_handler(pid, state.event_handlers) 343 | {:reply, :ok, %{state | event_handlers: handlers}} 344 | end 345 | # Handle call to connect to an IRC server 346 | def handle_call({:connect, server, port, options, ssl}, _from, state) do 347 | # If there is an open connection already, close it. 348 | if state.socket != nil, do: Transport.close(state) 349 | # Set SSL mode 350 | state = %{state | ssl?: ssl} 351 | # Open a new connection 352 | case Transport.connect(state, String.to_charlist(server), port, [:list, {:packet, :line}, {:keepalive, true}] ++ options) do 353 | {:ok, socket} -> 354 | send_event {:connected, server, port}, state 355 | {:reply, :ok, %{state | connected?: true, server: server, port: port, socket: socket}} 356 | error -> 357 | {:reply, error, state} 358 | end 359 | end 360 | # Handle call to determine if the client is connected 361 | def handle_call(:is_connected?, _from, state), do: {:reply, state.connected?, state} 362 | # Prevents any of the following messages from being handled if the client is not connected to a server. 363 | # Instead, it returns {:error, :not_connected}. 364 | def handle_call(_, _from, %ClientState{connected?: false} = state), do: {:reply, {:error, :not_connected}, state} 365 | # Handle call to login to the connected IRC server 366 | def handle_call({:logon, pass, nick, user, name}, _from, %ClientState{logged_on?: false} = state) do 367 | Transport.send state, pass!(pass) 368 | Transport.send state, nick!(nick) 369 | Transport.send state, user!(user, name) 370 | {:reply, :ok, %{state | pass: pass, nick: nick, user: user, name: name} } 371 | end 372 | # Handles call to change the client's nick. 373 | def handle_call({:nick, new_nick}, _from, %ClientState{logged_on?: false} = state) do 374 | Transport.send state, nick!(new_nick) 375 | # Since we've not yet logged on, we won't get a nick change message, so we have to remember the nick here. 376 | {:reply, :ok, %{state | nick: new_nick}} 377 | end 378 | # Handle call to determine if client is logged on to a server 379 | def handle_call(:is_logged_on?, _from, state), do: {:reply, state.logged_on?, state} 380 | # Prevents any of the following messages from being handled if the client is not logged on to a server. 381 | # Instead, it returns {:error, :not_logged_in}. 382 | def handle_call(_, _from, %ClientState{logged_on?: false} = state), do: {:reply, {:error, :not_logged_in}, state} 383 | # Handles call to send a message 384 | def handle_call({:msg, type, nick, msg}, _from, state) do 385 | data = case type do 386 | :privmsg -> privmsg!(nick, msg) 387 | :notice -> notice!(nick, msg) 388 | :ctcp -> notice!(nick, ctcp!(msg)) 389 | end 390 | Transport.send state, data 391 | {:reply, :ok, state} 392 | end 393 | # Handle /me messages 394 | def handle_call({:me, channel, msg}, _from, state) do 395 | data = me!(channel, msg) 396 | Transport.send state, data 397 | {:reply, :ok, state} 398 | end 399 | # Handles call to join a channel 400 | def handle_call({:join, channel, key}, _from, state) do 401 | Transport.send(state, join!(channel, key)) 402 | {:reply, :ok, state} 403 | end 404 | # Handles a call to leave a channel 405 | def handle_call({:part, channel}, _from, state) do 406 | Transport.send(state, part!(channel)) 407 | {:reply, :ok, state} 408 | end 409 | # Handles a call to kick a client 410 | def handle_call({:kick, channel, nick, message}, _from, state) do 411 | Transport.send(state, kick!(channel, nick, message)) 412 | {:reply, :ok, state} 413 | end 414 | # Handles a call to send the NAMES command to the server 415 | def handle_call({:names, channel}, _from, state) do 416 | Transport.send(state, names!(channel)) 417 | {:reply, :ok, state} 418 | end 419 | 420 | def handle_call({:whois, user}, _from, state) do 421 | Transport.send(state, whois!(user)) 422 | {:reply, :ok, state} 423 | end 424 | 425 | def handle_call({:who, channel}, _from, state) do 426 | Transport.send(state, who!(channel)) 427 | {:reply, :ok, state} 428 | end 429 | 430 | # Handles a call to change mode for a user or channel 431 | def handle_call({:mode, channel_or_nick, flags, args}, _from, state) do 432 | Transport.send(state, mode!(channel_or_nick, flags, args)) 433 | {:reply, :ok, state} 434 | end 435 | # Handle call to invite a user to a channel 436 | def handle_call({:invite, nick, channel}, _from, state) do 437 | Transport.send(state, invite!(nick, channel)) 438 | {:reply, :ok, state} 439 | end 440 | # Handle call to quit the server and close the socket connection 441 | def handle_call({:quit, msg}, _from, state) do 442 | if state.connected? do 443 | Transport.send state, quit!(msg) 444 | send_event(:disconnected, state) 445 | Transport.close state 446 | end 447 | {:reply, :ok, %{state | connected?: false, logged_on?: false, socket: nil}} 448 | end 449 | # Handles call to change the client's nick 450 | def handle_call({:nick, new_nick}, _from, state) do Transport.send(state, nick!(new_nick)); {:reply, :ok, state} end 451 | # Handles call to send a raw command to the IRC server 452 | def handle_call({:cmd, raw_cmd}, _from, state) do Transport.send(state, command!(raw_cmd)); {:reply, :ok, state} end 453 | # Handles call to return the client's channel data 454 | def handle_call(:channels, _from, state), do: {:reply, Channels.channels(state.channels), state} 455 | # Handles call to return a list of users for a given channel 456 | def handle_call({:channel_users, channel}, _from, state), do: {:reply, Channels.channel_users(state.channels, channel), state} 457 | # Handles call to return the given channel's topic 458 | def handle_call({:channel_topic, channel}, _from, state), do: {:reply, Channels.channel_topic(state.channels, channel), state} 459 | # Handles call to return the type of the given channel 460 | def handle_call({:channel_type, channel}, _from, state), do: {:reply, Channels.channel_type(state.channels, channel), state} 461 | # Handles call to determine if a nick is present in the given channel 462 | def handle_call({:channel_has_user?, channel, nick}, _from, state) do 463 | {:reply, Channels.channel_has_user?(state.channels, channel, nick), state} 464 | end 465 | # Handles message to add a new event handler process asynchronously 466 | def handle_cast({:add_handler, pid}, state) do 467 | handlers = do_add_handler(pid, state.event_handlers) 468 | {:noreply, %{state | event_handlers: handlers}} 469 | end 470 | @doc """ 471 | Handles asynchronous messages from the external API. Not recommended to call these directly. 472 | """ 473 | # Handles message to remove an event handler process asynchronously 474 | def handle_cast({:remove_handler, pid}, state) do 475 | handlers = do_remove_handler(pid, state.event_handlers) 476 | {:noreply, %{state | event_handlers: handlers}} 477 | end 478 | @doc """ 479 | Handle messages from the TCP socket connection. 480 | """ 481 | # Handles the client's socket connection 'closed' event 482 | def handle_info({:tcp_closed, _socket}, %ClientState{server: server, port: port} = state) do 483 | info "Connection to #{server}:#{port} closed!" 484 | send_event :disconnected, state 485 | new_state = %{state | 486 | socket: nil, 487 | connected?: false, 488 | logged_on?: false, 489 | channels: Channels.init() 490 | } 491 | {:noreply, new_state} 492 | end 493 | @doc """ 494 | Handle messages from the SSL socket connection. 495 | """ 496 | # Handles the client's socket connection 'closed' event 497 | def handle_info({:ssl_closed, socket}, state) do 498 | handle_info({:tcp_closed, socket}, state) 499 | end 500 | # Handles any TCP errors in the client's socket connection 501 | def handle_info({:tcp_error, socket, reason}, %ClientState{server: server, port: port} = state) do 502 | error "TCP error in connection to #{server}:#{port}:\r\n#{reason}\r\nClient connection closed." 503 | new_state = %{state | 504 | socket: nil, 505 | connected?: false, 506 | logged_on?: false, 507 | channels: Channels.init() 508 | } 509 | {:stop, {:tcp_error, socket}, new_state} 510 | end 511 | # Handles any SSL errors in the client's socket connection 512 | def handle_info({:ssl_error, socket, reason}, state) do 513 | handle_info({:tcp_error, socket, reason}, state) 514 | end 515 | # General handler for messages from the IRC server 516 | def handle_info({:tcp, _, data}, state) do 517 | debug? = state.debug? 518 | case Utils.parse(data) do 519 | %ExIRC.Message{ctcp: true} = msg -> 520 | handle_data msg, state 521 | {:noreply, state} 522 | %ExIRC.Message{ctcp: false} = msg -> 523 | handle_data msg, state 524 | %ExIRC.Message{ctcp: :invalid} = msg when debug? -> 525 | send_event msg, state 526 | {:noreply, state} 527 | _ -> 528 | {:noreply, state} 529 | end 530 | end 531 | # Wrapper for SSL socket messages 532 | def handle_info({:ssl, socket, data}, state) do 533 | handle_info({:tcp, socket, data}, state) 534 | end 535 | # If the owner process dies, we should die as well 536 | def handle_info({:DOWN, ref, _, pid, reason}, %{owner: {pid, ref}} = state) do 537 | {:stop, reason, state} 538 | end 539 | # If an event handler process dies, remove it from the list of event handlers 540 | def handle_info({:DOWN, _, _, pid, _}, state) do 541 | handlers = do_remove_handler(pid, state.event_handlers) 542 | {:noreply, %{state | event_handlers: handlers}} 543 | end 544 | # Catch-all for unrecognized messages (do nothing) 545 | def handle_info(_, state) do 546 | {:noreply, state} 547 | end 548 | @doc """ 549 | Handle termination 550 | """ 551 | def terminate(_reason, state) do 552 | if state.socket != nil do 553 | Transport.close state 554 | %{state | socket: nil} 555 | end 556 | :ok 557 | end 558 | @doc """ 559 | Transform state for hot upgrades/downgrades 560 | """ 561 | def code_change(_old, state, _extra), do: {:ok, state} 562 | 563 | ################ 564 | # Data handling 565 | ################ 566 | 567 | @doc """ 568 | Handle ExIRC.Messages received from the server. 569 | """ 570 | # Called upon successful login 571 | def handle_data(%ExIRC.Message{cmd: @rpl_welcome}, %ClientState{logged_on?: false} = state) do 572 | if state.debug?, do: debug "SUCCESFULLY LOGGED ON" 573 | new_state = %{state | logged_on?: true, login_time: :erlang.timestamp()} 574 | send_event :logged_in, new_state 575 | {:noreply, new_state} 576 | end 577 | # Called when trying to log in with a nickname that is in use 578 | def handle_data(%ExIRC.Message{cmd: @err_nick_in_use}, %ClientState{logged_on?: false} = state) do 579 | if state.debug?, do: debug "ERROR: NICK IN USE" 580 | send_event {:login_failed, :nick_in_use}, state 581 | {:noreply, state} 582 | end 583 | # Called when the server sends it's current capabilities 584 | def handle_data(%ExIRC.Message{cmd: @rpl_isupport} = msg, state) do 585 | if state.debug?, do: debug "RECEIVING SERVER CAPABILITIES" 586 | {:noreply, Utils.isup(msg.args, state)} 587 | end 588 | # Called when the client enters a channel 589 | 590 | def handle_data(%ExIRC.Message{nick: nick, cmd: "JOIN"} = msg, %ClientState{nick: nick} = state) do 591 | channel = msg.args |> List.first |> String.trim 592 | if state.debug?, do: debug "JOINED A CHANNEL #{channel}" 593 | channels = Channels.join(state.channels, channel) 594 | new_state = %{state | channels: channels} 595 | send_event {:joined, channel}, new_state 596 | {:noreply, new_state} 597 | end 598 | # Called when another user joins a channel the client is in 599 | def handle_data(%ExIRC.Message{nick: user_nick, cmd: "JOIN", host: host, user: user} = msg, state) do 600 | sender = %SenderInfo{nick: user_nick, host: host, user: user} 601 | channel = msg.args |> List.first |> String.trim 602 | if state.debug?, do: debug "ANOTHER USER JOINED A CHANNEL: #{channel} - #{user_nick}" 603 | channels = Channels.user_join(state.channels, channel, user_nick) 604 | new_state = %{state | channels: channels} 605 | send_event {:joined, channel, sender}, new_state 606 | {:noreply, new_state} 607 | end 608 | # Called on joining a channel, to tell us the channel topic 609 | # Message with three arguments is not RFC compliant but very common 610 | # Message with two arguments is RFC compliant 611 | # Message with a single argument is not RFC compliant, but is present 612 | # to handle poorly written IRC servers which send RPL_TOPIC with an empty 613 | # topic (such as Slack's IRC bridge), when they should be sending RPL_NOTOPIC 614 | def handle_data(%ExIRC.Message{cmd: @rpl_topic} = msg, state) do 615 | {channel, topic} = case msg.args do 616 | [_nick, channel, topic] -> {channel, topic} 617 | [channel, topic] -> {channel, topic} 618 | [channel] -> {channel, "No topic is set"} 619 | end 620 | if state.debug? do 621 | debug "INITIAL TOPIC MSG" 622 | debug "1. TOPIC SET FOR #{channel} TO #{topic}" 623 | end 624 | channels = Channels.set_topic(state.channels, channel, topic) 625 | new_state = %{state | channels: channels} 626 | send_event {:topic_changed, channel, topic}, new_state 627 | {:noreply, new_state} 628 | end 629 | 630 | 631 | ## WHOIS 632 | 633 | def handle_data(%ExIRC.Message{cmd: @rpl_whoisuser, args: [_sender, nick, user, hostname, _, name]}, state) do 634 | user = %{nick: nick, user: user, hostname: hostname, name: name} 635 | {:noreply, %ClientState{state|whois_buffers: Map.put(state.whois_buffers, nick, user)}} 636 | end 637 | 638 | def handle_data(%ExIRC.Message{cmd: @rpl_whoiscertfp, args: [_sender, nick, "has client certificate fingerprint "<> fingerprint]}, state) do 639 | {:noreply, %ClientState{state|whois_buffers: put_in(state.whois_buffers, [nick, :certfp], fingerprint)}} 640 | end 641 | 642 | def handle_data(%ExIRC.Message{cmd: @rpl_whoisregnick, args: [_sender, nick, _message]}, state) do 643 | {:noreply, %ClientState{state|whois_buffers: put_in(state.whois_buffers, [nick, :registered_nick?], true)}} 644 | end 645 | 646 | def handle_data(%ExIRC.Message{cmd: @rpl_whoishelpop, args: [_sender, nick, _message]}, state) do 647 | {:noreply, %ClientState{state|whois_buffers: put_in(state.whois_buffers, [nick, :helpop?], true)}} 648 | end 649 | 650 | def handle_data(%ExIRC.Message{cmd: @rpl_whoischannels, args: [_sender, nick, channels]}, state) do 651 | chans = String.split(channels, " ") 652 | {:noreply, %ClientState{state|whois_buffers: put_in(state.whois_buffers, [nick, :channels], chans)}} 653 | end 654 | 655 | 656 | def handle_data(%ExIRC.Message{cmd: @rpl_whoisserver, args: [_sender, nick, server_addr, server_name]}, state) do 657 | new_buffer = state.whois_buffers 658 | |> put_in([nick, :server_name], server_name) 659 | |> put_in([nick, :server_address], server_addr) 660 | {:noreply, %ClientState{state|whois_buffers: new_buffer}} 661 | end 662 | 663 | def handle_data(%ExIRC.Message{cmd: @rpl_whoisoperator, args: [_sender, nick, _message]}, state) do 664 | {:noreply, %ClientState{state|whois_buffers: put_in(state.whois_buffers, [nick, :ircop?], true)}} 665 | end 666 | 667 | def handle_data(%ExIRC.Message{cmd: @rpl_whoisaccount, args: [_sender, nick, account_name, _message]}, state) do 668 | {:noreply, %ClientState{state|whois_buffers: put_in(state.whois_buffers, [nick, :account_name], account_name)}} 669 | end 670 | 671 | def handle_data(%ExIRC.Message{cmd: @rpl_whoissecure, args: [_sender, nick, _message]}, state) do 672 | {:noreply, %ClientState{state|whois_buffers: put_in(state.whois_buffers, [nick, :ssl?], true)}} 673 | end 674 | 675 | def handle_data(%ExIRC.Message{cmd: @rpl_whoisidle, args: [_sender, nick, idling_time, signon_time, _message]}, state) do 676 | new_buffer = state.whois_buffers 677 | |> put_in([nick, :idling_time], idling_time) 678 | |> put_in([nick, :signon_time], signon_time) 679 | {:noreply, %ClientState{state|whois_buffers: new_buffer}} 680 | end 681 | 682 | def handle_data(%ExIRC.Message{cmd: @rpl_endofwhois, args: [_sender, nick, _message]}, state) do 683 | buffer = struct(ExIRC.Whois, state.whois_buffers[nick]) 684 | send_event {:whois, buffer}, state 685 | {:noreply, %ClientState{state|whois_buffers: Map.delete(state.whois_buffers, nick)}} 686 | end 687 | 688 | ## WHO 689 | 690 | def handle_data(%ExIRC.Message{:cmd => "352", :args => [_, channel, user, host, server, nick, mode, hop_and_realn]}, state) do 691 | [hop, name] = String.split(hop_and_realn, " ", parts: 2) 692 | 693 | :binary.compile_pattern(["@", "&", "+"]) 694 | admin? = String.contains?(mode, "&") 695 | away? = String.contains?(mode, "G") 696 | founder? = String.contains?(mode, "~") 697 | half_operator? = String.contains?(mode, "%") 698 | operator? = founder? || admin? || String.contains?(mode, "@") 699 | server_operator? = String.contains?(mode, "*") 700 | voiced? = String.contains?(mode, "+") 701 | 702 | nick = %{nick: nick, user: user, name: name, server: server, hops: hop, admin?: admin?, 703 | away?: away?, founder?: founder?, half_operator?: half_operator?, host: host, 704 | operator?: operator?, server_operator?: server_operator?, voiced?: voiced? 705 | } 706 | 707 | buffer = Map.get(state.who_buffers, channel, []) 708 | {:noreply, %ClientState{state | who_buffers: Map.put(state.who_buffers, channel, [nick|buffer])}} 709 | end 710 | 711 | def handle_data(%ExIRC.Message{:cmd => "315", :args => [_, channel, _]}, state) do 712 | buffer = state 713 | |> Map.get(:who_buffers) 714 | |> Map.get(channel) 715 | |> Enum.map(fn user -> struct(ExIRC.Who, user) end) 716 | 717 | send_event {:who, channel, buffer}, state 718 | {:noreply, %ClientState{state | who_buffers: Map.delete(state.who_buffers, channel)}} 719 | end 720 | 721 | def handle_data(%ExIRC.Message{cmd: @rpl_notopic, args: [channel]}, state) do 722 | if state.debug? do 723 | debug "INITIAL TOPIC MSG" 724 | debug "1. NO TOPIC SET FOR #{channel}}" 725 | end 726 | channels = Channels.set_topic(state.channels, channel, "No topic is set") 727 | new_state = %{state | channels: channels} 728 | {:noreply, new_state} 729 | end 730 | # Called when the topic changes while we're in the channel 731 | def handle_data(%ExIRC.Message{cmd: "TOPIC", args: [channel, topic]}, state) do 732 | if state.debug?, do: debug "TOPIC CHANGED FOR #{channel} TO #{topic}" 733 | channels = Channels.set_topic(state.channels, channel, topic) 734 | new_state = %{state | channels: channels} 735 | send_event {:topic_changed, channel, topic}, new_state 736 | {:noreply, new_state} 737 | end 738 | # Called when joining a channel with the list of current users in that channel, or when the NAMES command is sent 739 | def handle_data(%ExIRC.Message{cmd: @rpl_namereply} = msg, state) do 740 | if state.debug?, do: debug "NAMES LIST RECEIVED" 741 | {_nick, channel_type, channel, names} = case msg.args do 742 | [nick, channel_type, channel, names] -> {nick, channel_type, channel, names} 743 | [channel_type, channel, names] -> {nil, channel_type, channel, names} 744 | end 745 | channels = Channels.set_type( 746 | Channels.users_join(state.channels, channel, String.split(names, " ", trim: true)), 747 | channel, 748 | channel_type) 749 | 750 | send_event({:names_list, channel, names}, state) 751 | 752 | {:noreply, %{state | channels: channels}} 753 | end 754 | # Called when our nick has succesfully changed 755 | def handle_data(%ExIRC.Message{cmd: "NICK", nick: nick, args: [new_nick]}, %ClientState{nick: nick} = state) do 756 | if state.debug?, do: debug "NICK CHANGED FROM #{nick} TO #{new_nick}" 757 | new_state = %{state | nick: new_nick} 758 | send_event {:nick_changed, new_nick}, new_state 759 | {:noreply, new_state} 760 | end 761 | # Called when someone visible to us changes their nick 762 | def handle_data(%ExIRC.Message{cmd: "NICK", nick: nick, args: [new_nick]}, state) do 763 | if state.debug?, do: debug "#{nick} CHANGED THEIR NICK TO #{new_nick}" 764 | channels = Channels.user_rename(state.channels, nick, new_nick) 765 | new_state = %{state | channels: channels} 766 | send_event {:nick_changed, nick, new_nick}, new_state 767 | {:noreply, new_state} 768 | end 769 | # Called upon mode change 770 | def handle_data(%ExIRC.Message{cmd: "MODE", args: [channel, op, user]}, state) do 771 | if state.debug?, do: debug "MODE #{channel} #{op} #{user}" 772 | send_event {:mode, [channel, op, user]}, state 773 | {:noreply, state} 774 | end 775 | # Called when we leave a channel 776 | 777 | def handle_data(%ExIRC.Message{cmd: "PART", nick: nick} = msg, %ClientState{nick: nick} = state) do 778 | 779 | channel = msg.args |> List.first |> String.trim 780 | if state.debug?, do: debug "WE LEFT A CHANNEL: #{channel}" 781 | channels = Channels.part(state.channels, channel) 782 | new_state = %{state | channels: channels} 783 | send_event {:parted, channel}, new_state 784 | {:noreply, new_state} 785 | end 786 | # Called when someone else in our channel leaves 787 | def handle_data(%ExIRC.Message{cmd: "PART", nick: from, host: host, user: user} = msg, state) do 788 | sender = %SenderInfo{nick: from, host: host, user: user} 789 | channel = msg.args |> List.first |> String.trim 790 | if state.debug?, do: debug "#{from} LEFT A CHANNEL: #{channel}" 791 | channels = Channels.user_part(state.channels, channel, from) 792 | new_state = %{state | channels: channels} 793 | send_event {:parted, channel, sender}, new_state 794 | {:noreply, new_state} 795 | end 796 | def handle_data(%ExIRC.Message{cmd: "QUIT", nick: from, host: host, user: user} = msg, state) do 797 | sender = %SenderInfo{nick: from, host: host, user: user} 798 | reason = msg.args |> List.first 799 | if state.debug?, do: debug "#{from} QUIT" 800 | channels = Channels.user_quit(state.channels, from) 801 | new_state = %{state | channels: channels} 802 | send_event {:quit, reason, sender}, new_state 803 | {:noreply, new_state} 804 | end 805 | # Called when we receive a PING 806 | def handle_data(%ExIRC.Message{cmd: "PING"} = msg, %ClientState{autoping: true} = state) do 807 | if state.debug?, do: debug "RECEIVED A PING!" 808 | case msg do 809 | %ExIRC.Message{args: [from]} -> 810 | if state.debug?, do: debug("SENT PONG2") 811 | Transport.send(state, pong2!(from, msg.server)) 812 | _ -> 813 | if state.debug?, do: debug("SENT PONG1") 814 | Transport.send(state, pong1!(state.nick)) 815 | end 816 | {:noreply, state}; 817 | end 818 | # Called when we are invited to a channel 819 | def handle_data(%ExIRC.Message{cmd: "INVITE", args: [nick, channel], nick: by, host: host, user: user} = msg, %ClientState{nick: nick} = state) do 820 | sender = %SenderInfo{nick: by, host: host, user: user} 821 | if state.debug?, do: debug "RECEIVED AN INVITE: #{msg.args |> Enum.join(" ")}" 822 | send_event {:invited, sender, channel}, state 823 | {:noreply, state} 824 | end 825 | # Called when we are kicked from a channel 826 | 827 | def handle_data(%ExIRC.Message{cmd: "KICK", args: [channel, nick, reason], nick: by, host: host, user: user} = _msg, %ClientState{nick: nick} = state) do 828 | 829 | sender = %SenderInfo{nick: by, host: host, user: user} 830 | if state.debug?, do: debug "WE WERE KICKED FROM #{channel} BY #{by}" 831 | send_event {:kicked, sender, channel, reason}, state 832 | {:noreply, state} 833 | end 834 | # Called when someone else was kicked from a channel 835 | 836 | def handle_data(%ExIRC.Message{cmd: "KICK", args: [channel, nick, reason], nick: by, host: host, user: user} = _msg, state) do 837 | 838 | sender = %SenderInfo{nick: by, host: host, user: user} 839 | if state.debug?, do: debug "#{nick} WAS KICKED FROM #{channel} BY #{by}" 840 | send_event {:kicked, nick, sender, channel, reason}, state 841 | {:noreply, state} 842 | end 843 | # Called when someone sends us a message 844 | def handle_data(%ExIRC.Message{nick: from, cmd: "PRIVMSG", args: [nick, message], host: host, user: user} = _msg, %ClientState{nick: nick} = state) do 845 | sender = %SenderInfo{nick: from, host: host, user: user} 846 | if state.debug?, do: debug "#{from} SENT US #{message}" 847 | send_event {:received, message, sender}, state 848 | {:noreply, state} 849 | end 850 | # Called when someone sends a message to a channel we're in, or a list of users 851 | def handle_data(%ExIRC.Message{nick: from, cmd: "PRIVMSG", args: [to, message], host: host, user: user} = _msg, %ClientState{nick: nick} = state) do 852 | sender = %SenderInfo{nick: from, host: host, user: user} 853 | if state.debug?, do: debug "#{from} SENT #{message} TO #{to}" 854 | send_event {:received, message, sender, to}, state 855 | # If we were mentioned, fire that event as well 856 | if String.contains?(String.downcase(message), String.downcase(nick)), do: send_event({:mentioned, message, sender, to}, state) 857 | {:noreply, state} 858 | end 859 | # Called when someone uses ACTION, i.e. `/me dies` 860 | def handle_data(%ExIRC.Message{nick: from, cmd: "ACTION", args: [channel, message], host: host, user: user} = _msg, state) do 861 | sender = %SenderInfo{nick: from, host: host, user: user} 862 | if state.debug?, do: debug "* #{from} #{message} in #{channel}" 863 | send_event {:me, message, sender, channel}, state 864 | {:noreply, state} 865 | end 866 | 867 | # Called when a NOTICE is received by the client. 868 | def handle_data(%ExIRC.Message{nick: from, cmd: "NOTICE", args: [_target, message], host: host, user: user} = _msg, state) do 869 | 870 | sender = %SenderInfo{nick: from, 871 | host: host, 872 | user: user} 873 | 874 | if String.contains?(message, "identify") do 875 | if state.debug?, do: debug("* Told to identify by #{from}: #{message}") 876 | send_event({:identify, message, sender}, state) 877 | else 878 | if state.debug?, do: debug("* #{message} from #{sender}") 879 | send_event({:notice, message, sender}, state) 880 | end 881 | 882 | {:noreply, state} 883 | end 884 | 885 | # Called any time we receive an unrecognized message 886 | def handle_data(msg, state) do 887 | if state.debug? do debug "UNRECOGNIZED MSG: #{msg.cmd}"; IO.inspect(msg) end 888 | send_event {:unrecognized, msg.cmd, msg}, state 889 | {:noreply, state} 890 | end 891 | 892 | ############### 893 | # Internal API 894 | ############### 895 | defp send_event(msg, %ClientState{event_handlers: handlers}) when is_list(handlers) do 896 | Enum.each(handlers, fn({pid, _}) -> Kernel.send(pid, msg) end) 897 | end 898 | 899 | defp do_add_handler(pid, handlers) do 900 | case Enum.member?(handlers, pid) do 901 | false -> 902 | ref = Process.monitor(pid) 903 | [{pid, ref} | handlers] 904 | true -> 905 | handlers 906 | end 907 | end 908 | 909 | defp do_remove_handler(pid, handlers) do 910 | case List.keyfind(handlers, pid, 0) do 911 | {pid, ref} -> 912 | Process.demonitor(ref) 913 | List.keydelete(handlers, pid, 0) 914 | nil -> 915 | handlers 916 | end 917 | end 918 | 919 | defp debug(msg) do 920 | IO.puts(IO.ANSI.green() <> msg <> IO.ANSI.reset()) 921 | end 922 | 923 | end 924 | -------------------------------------------------------------------------------- /lib/exirc/commands.ex: -------------------------------------------------------------------------------- 1 | defmodule ExIRC.Commands do 2 | @moduledoc """ 3 | Defines IRC command constants, and methods for generating valid commands to send to an IRC server. 4 | """ 5 | 6 | defmacro __using__(_) do 7 | 8 | quote do 9 | import ExIRC.Commands 10 | 11 | #################### 12 | # IRC Numeric Codes 13 | #################### 14 | 15 | @rpl_welcome "001" 16 | @rpl_yourhost "002" 17 | @rpl_created "003" 18 | @rpl_myinfo "004" 19 | @rpl_isupport "005" # Defacto standard for server support 20 | @rpl_bounce "010" # Defacto replacement of "005" in RFC2812 21 | @rpl_statsdline "250" 22 | #@doc """ 23 | #":There are users and invisible on servers" 24 | #""" 25 | @rpl_luserclient "251" 26 | #@doc """ 27 | # " :operator(s) online" 28 | #""" 29 | @rpl_luserop "252" 30 | #@doc """ 31 | #" :unknown connection(s)" 32 | #""" 33 | @rpl_luserunknown "253" 34 | #@doc """ 35 | #" :channels formed" 36 | #""" 37 | @rpl_luserchannels "254" 38 | #@doc """ 39 | #":I have clients and servers" 40 | #""" 41 | @rpl_luserme "255" 42 | #@doc """ 43 | #Local/Global user stats 44 | #""" 45 | @rpl_localusers "265" 46 | @rpl_globalusers "266" 47 | #@doc """ 48 | #When sending a TOPIC message to determine the channel topic, 49 | #one of two replies is sent. If the topic is set, RPL_TOPIC is sent back else 50 | #RPL_NOTOPIC. 51 | #""" 52 | @rpl_whoiscertfp "276" 53 | @rpl_whoisregnick "307" 54 | @rpl_whoishelpop "310" 55 | @rpl_whoisuser "311" 56 | @rpl_whoisserver "312" 57 | @rpl_whoisoperator "313" 58 | @rpl_whoisidle "317" 59 | @rpl_endofwhois "318" 60 | @rpl_whoischannels "319" 61 | @rpl_whoisaccount "330" 62 | @rpl_notopic "331" 63 | @rpl_topic "332" 64 | #@doc """ 65 | #To reply to a NAMES message, a reply pair consisting 66 | #of RPL_NAMREPLY and RPL_ENDOFNAMES is sent by the 67 | #server back to the client. If there is no channel 68 | #found as in the query, then only RPL_ENDOFNAMES is 69 | #returned. The exception to this is when a NAMES 70 | #message is sent with no parameters and all visible 71 | #channels and contents are sent back in a series of 72 | #RPL_NAMEREPLY messages with a RPL_ENDOFNAMES to mark 73 | #the end. 74 | 75 | #Format: " :[[@|+] [[@|+] [...]]]" 76 | #""" 77 | @rpl_namereply "353" 78 | @rpl_endofnames "366" 79 | #@doc """ 80 | #When responding to the MOTD message and the MOTD file 81 | #is found, the file is displayed line by line, with 82 | #each line no longer than 80 characters, using 83 | #RPL_MOTD format replies. These should be surrounded 84 | #by a RPL_MOTDSTART (before the RPL_MOTDs) and an 85 | #RPL_ENDOFMOTD (after). 86 | #""" 87 | @rpl_motd "372" 88 | @rpl_motdstart "375" 89 | @rpl_endofmotd "376" 90 | @rpl_whoishost "378" 91 | @rpl_whoismodes "379" 92 | 93 | ################ 94 | # Error Codes 95 | ################ 96 | 97 | #@doc """ 98 | #Used to indicate the nick parameter supplied to a command is currently unused. 99 | #""" 100 | @err_no_such_nick "401" 101 | #@doc """ 102 | #Used to indicate the server name given currently doesn"t exist. 103 | #""" 104 | @err_no_such_server "402" 105 | #@doc """ 106 | #Used to indicate the given channel name is invalid. 107 | #""" 108 | @err_no_such_channel "403" 109 | #@doc """ 110 | #Sent to a user who is either (a) not on a channel which is mode +n or (b), 111 | #not a chanop (or mode +v) on a channel which has mode +m set, and is trying 112 | #to send a PRIVMSG message to that channel. 113 | #""" 114 | @err_cannot_send_to_chan "404" 115 | #@doc """ 116 | #Sent to a user when they have joined the maximum number of allowed channels 117 | #and they try to join another channel. 118 | #""" 119 | @err_too_many_channels "405" 120 | #@doc """ 121 | #Returned to a registered client to indicate that the command sent is unknown by the server. 122 | #""" 123 | @err_unknown_command "421" 124 | #@doc """ 125 | #Returned when a nick parameter expected for a command and isn"t found. 126 | #""" 127 | @err_no_nick_given "431" 128 | #@doc """ 129 | #Returned after receiving a NICK message which contains characters which do not fall in the defined set. 130 | #""" 131 | @err_erroneus_nick "432" 132 | #@doc """ 133 | #Returned when a NICK message is processed that results in an attempt to 134 | #change to a currently existing nick. 135 | #""" 136 | @err_nick_in_use "433" 137 | #@doc """ 138 | #Returned by a server to a client when it detects a nick collision 139 | #(registered of a NICK that already exists by another server). 140 | #""" 141 | @err_nick_collision "436" 142 | #@doc """ 143 | #""" 144 | @err_unavail_resource "437" 145 | #@doc """ 146 | #Returned by the server to indicate that the client must be registered before 147 | #the server will allow it to be parsed in detail. 148 | #""" 149 | @err_not_registered "451" 150 | #""" 151 | # Returned by the server by numerous commands to indicate to the client that 152 | # it didn"t supply enough parameters. 153 | #""" 154 | @err_need_more_params "461" 155 | #@doc """ 156 | #Returned by the server to any link which tries to change part of the registered 157 | #details (such as password or user details from second USER message). 158 | #""" 159 | @err_already_registered "462" 160 | #@doc """ 161 | #Returned by the server to the client when the issued command is restricted 162 | #""" 163 | @err_restricted "484" 164 | 165 | @rpl_whoissecure "671" 166 | 167 | ############### 168 | # Code groups 169 | ############### 170 | 171 | @logon_errors [ @err_no_nick_given, @err_erroneus_nick, 172 | @err_nick_in_use, @err_nick_collision, 173 | @err_unavail_resource, @err_need_more_params, 174 | @err_already_registered, @err_restricted ] 175 | 176 | @whois_rpls [ @rpl_whoisuser, @rpl_whoishost, 177 | @rpl_whoishost, @rpl_whoisserver, 178 | @rpl_whoismodes, @rpl_whoisidle, 179 | @rpl_endofwhois 180 | ] 181 | end 182 | 183 | end 184 | 185 | ############ 186 | # Helpers 187 | ############ 188 | @ctcp_delimiter 0o001 189 | 190 | @doc """ 191 | Builds a valid IRC command. 192 | """ 193 | def command!(cmd), do: [cmd, '\r\n'] 194 | @doc """ 195 | Builds a valid CTCP command. 196 | """ 197 | def ctcp!(cmd), do: command! [@ctcp_delimiter, cmd, @ctcp_delimiter] 198 | def ctcp!(cmd, args) do 199 | expanded = args |> Enum.intersperse(' ') 200 | command! [@ctcp_delimiter, cmd, expanded, @ctcp_delimiter] 201 | end 202 | 203 | # IRC Commands 204 | 205 | @doc """ 206 | Send a WHOIS request about a user 207 | """ 208 | def whois!(user), do: command! ['WHOIS ', user] 209 | 210 | @doc """ 211 | Send a WHO request about a channel 212 | """ 213 | def who!(channel), do: command! ['WHO ', channel] 214 | 215 | @doc """ 216 | Send password to server 217 | """ 218 | def pass!(pwd), do: command! ['PASS ', pwd] 219 | @doc """ 220 | Send nick to server. (Changes or sets your nick) 221 | """ 222 | def nick!(nick), do: command! ['NICK ', nick] 223 | @doc """ 224 | Send username to server. (Changes or sets your username) 225 | """ 226 | def user!(user, name) do 227 | command! ['USER ', user, ' 0 * :', name] 228 | end 229 | @doc """ 230 | Send PONG in response to PING 231 | """ 232 | def pong1!(nick), do: command! ['PONG ', nick] 233 | @doc """ 234 | Send a targeted PONG in response to PING 235 | """ 236 | def pong2!(nick, to), do: command! ['PONG ', nick, ' ', to] 237 | @doc """ 238 | Send message to channel or user 239 | """ 240 | def privmsg!(nick, msg), do: command! ['PRIVMSG ', nick, ' :', msg] 241 | @doc """ 242 | Send a `/me ` CTCP command to t 243 | """ 244 | def me!(channel, msg), do: command! ['PRIVMSG ', channel, ' :', @ctcp_delimiter, 'ACTION ', msg, @ctcp_delimiter] 245 | @doc """ 246 | Sends a command to the server to get the list of names back 247 | """ 248 | def names!(_channel), do: command! ['NAMES'] 249 | @doc """ 250 | Send notice to channel or user 251 | """ 252 | def notice!(nick, msg), do: command! ['NOTICE ', nick, ' :', msg] 253 | @doc """ 254 | Send join command to server (join a channel) 255 | """ 256 | def join!(channel), do: command! ['JOIN ', channel] 257 | def join!(channel, key), do: command! ['JOIN ', channel, ' ', key] 258 | @doc """ 259 | Send part command to server (leave a channel) 260 | """ 261 | def part!(channel), do: command! ['PART ', channel] 262 | @doc """ 263 | Send quit command to server (disconnect from server) 264 | """ 265 | def quit!(msg \\ "Leaving"), do: command! ['QUIT :', msg] 266 | @doc """ 267 | Send kick command to server 268 | """ 269 | def kick!(channel, nick, message \\ "") do 270 | case "#{message}" |> String.length do 271 | 0 -> command! ['KICK ', channel, ' ', nick] 272 | _ -> command! ['KICK ', channel, ' ', nick, ' ', message] 273 | end 274 | end 275 | @doc """ 276 | Send mode command to server 277 | MODE 278 | MODE [] 279 | """ 280 | def mode!(channel_or_nick, flags, args \\ "") do 281 | case "#{args}" |> String.length do 282 | 0 -> command! ['MODE ', channel_or_nick, ' ', flags] 283 | _ -> command! ['MODE ', channel_or_nick, ' ', flags, ' ', args] 284 | end 285 | end 286 | @doc """ 287 | Send an invite command 288 | """ 289 | def invite!(nick, channel) do 290 | command! ['INVITE ', nick, ' ', channel] 291 | end 292 | 293 | end 294 | -------------------------------------------------------------------------------- /lib/exirc/example_handler.ex: -------------------------------------------------------------------------------- 1 | defmodule ExampleHandler do 2 | @moduledoc """ 3 | This is an example event handler that you can attach to the client using 4 | `add_handler` or `add_handler_async`. To remove, call `remove_handler` or 5 | `remove_handler_async` with the pid of the handler process. 6 | """ 7 | def start! do 8 | start_link([]) 9 | end 10 | 11 | def start_link(_) do 12 | GenServer.start_link(__MODULE__, nil, []) 13 | end 14 | 15 | def init(_) do 16 | {:ok, nil} 17 | end 18 | 19 | @doc """ 20 | Handle messages from the client 21 | 22 | Examples: 23 | 24 | def handle_info({:connected, server, port}, _state) do 25 | IO.puts "Connected to \#{server}:\#{port}" 26 | end 27 | def handle_info(:logged_in, _state) do 28 | IO.puts "Logged in!" 29 | end 30 | def handle_info(%ExIRC.Message{nick: from, cmd: "PRIVMSG", args: ["mynick", msg]}, _state) do 31 | IO.puts "Received a private message from \#{from}: \#{msg}" 32 | end 33 | def handle_info(%ExIRC.Message{nick: from, cmd: "PRIVMSG", args: [to, msg]}, _state) do 34 | IO.puts "Received a message in \#{to} from \#{from}: \#{msg}" 35 | end 36 | """ 37 | def handle_info({:connected, server, port}, _state) do 38 | debug "Connected to #{server}:#{port}" 39 | {:noreply, nil} 40 | end 41 | def handle_info(:logged_in, _state) do 42 | debug "Logged in to server" 43 | {:noreply, nil} 44 | end 45 | def handle_info({:login_failed, :nick_in_use}, _state) do 46 | debug "Login failed, nickname in use" 47 | {:noreply, nil} 48 | end 49 | def handle_info(:disconnected, _state) do 50 | debug "Disconnected from server" 51 | {:noreply, nil} 52 | end 53 | def handle_info({:joined, channel}, _state) do 54 | debug "Joined #{channel}" 55 | {:noreply, nil} 56 | end 57 | def handle_info({:joined, channel, user}, _state) do 58 | debug "#{user} joined #{channel}" 59 | {:noreply, nil} 60 | end 61 | def handle_info({:topic_changed, channel, topic}, _state) do 62 | debug "#{channel} topic changed to #{topic}" 63 | {:noreply, nil} 64 | end 65 | def handle_info({:nick_changed, nick}, _state) do 66 | debug "We changed our nick to #{nick}" 67 | {:noreply, nil} 68 | end 69 | def handle_info({:nick_changed, old_nick, new_nick}, _state) do 70 | debug "#{old_nick} changed their nick to #{new_nick}" 71 | {:noreply, nil} 72 | end 73 | def handle_info({:parted, channel}, _state) do 74 | debug "We left #{channel}" 75 | {:noreply, nil} 76 | end 77 | def handle_info({:parted, channel, sender}, _state) do 78 | nick = sender.nick 79 | debug "#{nick} left #{channel}" 80 | {:noreply, nil} 81 | end 82 | def handle_info({:invited, sender, channel}, _state) do 83 | by = sender.nick 84 | debug "#{by} invited us to #{channel}" 85 | {:noreply, nil} 86 | end 87 | def handle_info({:kicked, sender, channel}, _state) do 88 | by = sender.nick 89 | debug "We were kicked from #{channel} by #{by}" 90 | {:noreply, nil} 91 | end 92 | def handle_info({:kicked, nick, sender, channel}, _state) do 93 | by = sender.nick 94 | debug "#{nick} was kicked from #{channel} by #{by}" 95 | {:noreply, nil} 96 | end 97 | def handle_info({:received, message, sender}, _state) do 98 | from = sender.nick 99 | debug "#{from} sent us a private message: #{message}" 100 | {:noreply, nil} 101 | end 102 | def handle_info({:received, message, sender, channel}, _state) do 103 | from = sender.nick 104 | debug "#{from} sent a message to #{channel}: #{message}" 105 | {:noreply, nil} 106 | end 107 | def handle_info({:mentioned, message, sender, channel}, _state) do 108 | from = sender.nick 109 | debug "#{from} mentioned us in #{channel}: #{message}" 110 | {:noreply, nil} 111 | end 112 | def handle_info({:me, message, sender, channel}, _state) do 113 | from = sender.nick 114 | debug "* #{from} #{message} in #{channel}" 115 | {:noreply, nil} 116 | end 117 | # This is an example of how you can manually catch commands if ExIRC.Client doesn't send a specific message for it 118 | def handle_info(%ExIRC.Message{nick: from, cmd: "PRIVMSG", args: ["testnick", msg]}, _state) do 119 | debug "Received a private message from #{from}: #{msg}" 120 | {:noreply, nil} 121 | end 122 | def handle_info(%ExIRC.Message{nick: from, cmd: "PRIVMSG", args: [to, msg]}, _state) do 123 | debug "Received a message in #{to} from #{from}: #{msg}" 124 | {:noreply, nil} 125 | end 126 | # Catch-all for messages you don't care about 127 | def handle_info(msg, _state) do 128 | debug "Received ExIRC.Message:" 129 | IO.inspect msg 130 | {:noreply, nil} 131 | end 132 | 133 | defp debug(msg) do 134 | IO.puts IO.ANSI.yellow() <> msg <> IO.ANSI.reset() 135 | end 136 | end 137 | -------------------------------------------------------------------------------- /lib/exirc/exirc.ex: -------------------------------------------------------------------------------- 1 | defmodule ExIRC do 2 | @moduledoc """ 3 | Supervises IRC client processes 4 | 5 | Usage: 6 | 7 | # Start the supervisor (started automatically when ExIRC is run as an application) 8 | ExIRC.start_link 9 | 10 | # Start a new IRC client 11 | {:ok, client} = ExIRC.start_client! 12 | 13 | # Connect to an IRC server 14 | ExIRC.Client.connect! client, "localhost", 6667 15 | 16 | # Logon 17 | ExIRC.Client.logon client, "password", "nick", "user", "name" 18 | 19 | # Join a channel (password is optional) 20 | ExIRC.Client.join client, "#channel", "password" 21 | 22 | # Send a message 23 | ExIRC.Client.msg client, :privmsg, "#channel", "Hello world!" 24 | 25 | # Quit (message is optional) 26 | ExIRC.Client.quit client, "message" 27 | 28 | # Stop and close the client connection 29 | ExIRC.Client.stop! client 30 | 31 | """ 32 | use DynamicSupervisor 33 | 34 | defmodule TemporaryClient do 35 | @moduledoc """ 36 | Temporary ExIRC.Client. 37 | """ 38 | 39 | @doc """ 40 | Defines how this module will run as a child process. 41 | """ 42 | def child_spec(arg) do 43 | %{ 44 | id: __MODULE__, 45 | start: {ExIRC.Client, :start_link, [arg]}, 46 | restart: :temporary 47 | } 48 | end 49 | end 50 | 51 | ############## 52 | # Public API 53 | ############## 54 | 55 | @doc """ 56 | Start the ExIRC supervisor. 57 | """ 58 | @spec start!() :: Supervisor.on_start() 59 | def start! do 60 | DynamicSupervisor.start_link(__MODULE__, [], name: :exirc) 61 | end 62 | 63 | @doc """ 64 | Start a new ExIRC client under the ExIRC supervisor 65 | """ 66 | @spec start_client!() :: DynamicSupervisor.on_start_child() 67 | def start_client!() do 68 | DynamicSupervisor.start_child(:exirc, {TemporaryClient, owner: self()}) 69 | end 70 | 71 | @doc """ 72 | Start a new ExIRC client 73 | """ 74 | @spec start_link!() :: GenServer.on_start() 75 | def start_link! do 76 | ExIRC.Client.start!(owner: self()) 77 | end 78 | 79 | ############## 80 | # Supervisor API 81 | ############## 82 | 83 | @impl true 84 | def init(_) do 85 | DynamicSupervisor.init(strategy: :one_for_one) 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /lib/exirc/irc_message.ex: -------------------------------------------------------------------------------- 1 | defmodule ExIRC.Message do 2 | defstruct server: '', 3 | nick: '', 4 | user: '', 5 | host: '', 6 | ctcp: nil, 7 | cmd: '', 8 | args: [] 9 | end 10 | -------------------------------------------------------------------------------- /lib/exirc/logger.ex: -------------------------------------------------------------------------------- 1 | defmodule ExIRC.Logger do 2 | @moduledoc """ 3 | A simple abstraction of :error_logger 4 | """ 5 | 6 | @doc """ 7 | Log an informational message report 8 | """ 9 | @spec info(binary) :: :ok 10 | def info(msg) do 11 | :error_logger.info_report String.to_charlist(msg) 12 | end 13 | 14 | @doc """ 15 | Log a warning message report 16 | """ 17 | @spec warning(binary) :: :ok 18 | def warning(msg) do 19 | :error_logger.warning_report String.to_charlist("#{IO.ANSI.yellow()}#{msg}#{IO.ANSI.reset()}") 20 | end 21 | 22 | @doc """ 23 | Log an error message report 24 | """ 25 | @spec error(binary) :: :ok 26 | def error(msg) do 27 | :error_logger.error_report String.to_charlist("#{IO.ANSI.red()}#{msg}#{IO.ANSI.reset()}") 28 | end 29 | end -------------------------------------------------------------------------------- /lib/exirc/sender_info.ex: -------------------------------------------------------------------------------- 1 | defmodule ExIRC.SenderInfo do 2 | @moduledoc """ 3 | This struct represents information available about the sender of a message. 4 | """ 5 | defstruct nick: nil, 6 | host: nil, 7 | user: nil 8 | end 9 | -------------------------------------------------------------------------------- /lib/exirc/transport.ex: -------------------------------------------------------------------------------- 1 | defmodule ExIRC.Client.Transport do 2 | def connect(%{ssl?: false}, host, port, options) do 3 | :gen_tcp.connect(host, port, options) 4 | end 5 | def connect(%{ssl?: true}, host, port, options) do 6 | :ssl.connect(host, port, options) 7 | end 8 | 9 | def send(%{ssl?: false, socket: socket}, data) do 10 | :gen_tcp.send(socket, data) 11 | end 12 | def send(%{ssl?: true, socket: socket}, data) do 13 | :ssl.send(socket, data) 14 | end 15 | 16 | def close(%{ssl?: false, socket: socket}) do 17 | :gen_tcp.close(socket) 18 | end 19 | def close(%{ssl?: true, socket: socket}) do 20 | :ssl.close(socket) 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/exirc/utils.ex: -------------------------------------------------------------------------------- 1 | defmodule ExIRC.Utils do 2 | 3 | ###################### 4 | # IRC Message Parsing 5 | ###################### 6 | 7 | @doc """ 8 | Parse an IRC message 9 | 10 | Example: 11 | 12 | data = ':irc.example.org 005 nick NETWORK=Freenode PREFIX=(ov)@+ CHANTYPES=#&' 13 | message = ExIRC.Utils.parse data 14 | assert "irc.example.org" = message.server 15 | """ 16 | 17 | @spec parse(raw_data :: charlist) :: ExIRC.Message.t 18 | 19 | def parse(raw_data) do 20 | data = :string.substr(raw_data, 1, length(raw_data)) 21 | case data do 22 | [?:|_] -> 23 | [[?:|from]|rest] = :string.tokens(data, ' ') 24 | get_cmd(rest, parse_from(from, %ExIRC.Message{ctcp: false})) 25 | data -> 26 | get_cmd(:string.tokens(data, ' '), %ExIRC.Message{ctcp: false}) 27 | end 28 | end 29 | 30 | @prefix_pattern ~r/^(?[^!\s]+)(?:!(?:(?[^@\s]+)@)?(?:(?[\S]+)))?$/ 31 | defp parse_from(from, msg) do 32 | from_str = IO.iodata_to_binary(from) 33 | parts = Regex.run(@prefix_pattern, from_str, capture: :all_but_first) 34 | case parts do 35 | [nick, user, host] -> 36 | %{msg | nick: nick, user: user, host: host} 37 | [nick, host] -> 38 | %{msg | nick: nick, host: host} 39 | [nick] -> 40 | if String.contains?(nick, ".") do 41 | %{msg | server: nick} 42 | else 43 | %{msg | nick: nick} 44 | end 45 | end 46 | end 47 | 48 | # Parse command from message 49 | defp get_cmd([cmd, arg1, [?:, 1 | ctcp_trail] | restargs], msg) when cmd == 'PRIVMSG' or cmd == 'NOTICE' do 50 | get_cmd([cmd, arg1, [1 | ctcp_trail] | restargs], msg) 51 | end 52 | 53 | defp get_cmd([cmd, target, [1 | ctcp_cmd] | cmd_args], msg) when cmd == 'PRIVMSG' or cmd == 'NOTICE' do 54 | args = cmd_args 55 | |> Enum.map(&Enum.take_while(&1, fn c -> c != 0o001 end)) 56 | |> Enum.map(&List.to_string/1) 57 | case args do 58 | args when args != [] -> 59 | %{msg | 60 | cmd: to_string(ctcp_cmd), 61 | args: [to_string(target), args |> Enum.join(" ")], 62 | ctcp: true 63 | } 64 | _ -> 65 | %{msg | cmd: to_string(cmd), ctcp: :invalid} 66 | end 67 | end 68 | 69 | defp get_cmd([cmd | rest], msg) do 70 | get_args(rest, %{msg | cmd: to_string(cmd)}) 71 | end 72 | 73 | 74 | # Parse command args from message 75 | defp get_args([], msg) do 76 | args = msg.args 77 | |> Enum.reverse 78 | |> Enum.filter(fn arg -> arg != [] end) 79 | |> Enum.map(&trim_crlf/1) 80 | |> Enum.map(&:binary.list_to_bin/1) 81 | |> Enum.map(fn(s) -> 82 | case String.valid?(s) do 83 | true -> :unicode.characters_to_binary(s) 84 | false -> :unicode.characters_to_binary(s, :latin1, :unicode) 85 | end 86 | end) 87 | 88 | post_process(%{msg | args: args}) 89 | end 90 | 91 | defp get_args([[?: | first_arg] | rest], msg) do 92 | args = (for arg <- [first_arg | rest], do: ' ' ++ trim_crlf(arg)) |> List.flatten 93 | case args do 94 | [_] -> 95 | get_args([], %{msg | args: msg.args}) 96 | [_ | full_trail] -> 97 | get_args([], %{msg | args: [full_trail | msg.args]}) 98 | end 99 | end 100 | 101 | defp get_args([arg | rest], msg) do 102 | get_args(rest, %{msg | args: [arg | msg.args]}) 103 | end 104 | 105 | # This function allows us to handle special case messages which are not RFC 106 | # compliant, before passing it to the client. 107 | defp post_process(%ExIRC.Message{cmd: "332", args: [nick, channel]} = msg) do 108 | # Handle malformed RPL_TOPIC messages which contain no topic 109 | %{msg | :cmd => "331", :args => [channel, "No topic is set"], :nick => nick} 110 | end 111 | defp post_process(msg), do: msg 112 | 113 | ############################ 114 | # Parse RPL_ISUPPORT (005) 115 | ############################ 116 | 117 | @doc """ 118 | Parse RPL_ISUPPORT message. 119 | 120 | If an empty list is provided, do nothing, otherwise parse CHANTYPES, 121 | NETWORK, and PREFIX parameters for relevant data. 122 | """ 123 | @spec isup(parameters :: list(binary), state :: ExIRC.Client.ClientState.t) :: ExIRC.Client.ClientState.t 124 | def isup([], state), do: state 125 | def isup([param | rest], state) do 126 | try do 127 | isup(rest, isup_param(param, state)) 128 | rescue 129 | _ -> isup(rest, state) 130 | end 131 | end 132 | 133 | defp isup_param("CHANTYPES=" <> channel_prefixes, state) do 134 | prefixes = channel_prefixes |> String.split("", trim: true) 135 | %{state | channel_prefixes: prefixes} 136 | end 137 | defp isup_param("NETWORK=" <> network, state) do 138 | %{state | network: network} 139 | end 140 | defp isup_param("PREFIX=" <> user_prefixes, state) do 141 | prefixes = Regex.run(~r/\((.*)\)(.*)/, user_prefixes, capture: :all_but_first) 142 | |> Enum.map(&String.to_charlist/1) 143 | |> List.zip 144 | %{state | user_prefixes: prefixes} 145 | end 146 | defp isup_param(_, state) do 147 | state 148 | end 149 | 150 | ################### 151 | # Helper Functions 152 | ################### 153 | 154 | @days_of_week ['Mon','Tue','Wed','Thu','Fri','Sat','Sun'] 155 | @months_of_year ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'] 156 | @doc """ 157 | Get CTCP formatted time from a tuple representing the current calendar time: 158 | 159 | Example: 160 | 161 | iex> local_time = {{2013,12,6},{14,5,0}} 162 | {{2013,12,6},{14,5,0}} 163 | iex> ExIRC.Utils.ctcp_time local_time 164 | "Fri Dec 06 14:05:00 2013" 165 | """ 166 | @spec ctcp_time(datetime :: {{integer, integer, integer}, {integer, integer, integer}}) :: binary 167 | def ctcp_time({{y, m, d}, {h, n, s}} = _datetime) do 168 | [:lists.nth(:calendar.day_of_the_week(y,m,d), @days_of_week), 169 | ' ', 170 | :lists.nth(m, @months_of_year), 171 | ' ', 172 | :io_lib.format("~2..0s", [Integer.to_charlist(d)]), 173 | ' ', 174 | :io_lib.format("~2..0s", [Integer.to_charlist(h)]), 175 | ':', 176 | :io_lib.format("~2..0s", [Integer.to_charlist(n)]), 177 | ':', 178 | :io_lib.format("~2..0s", [Integer.to_charlist(s)]), 179 | ' ', 180 | Integer.to_charlist(y)] |> List.flatten |> List.to_string 181 | end 182 | 183 | defp trim_crlf(charlist) do 184 | case Enum.reverse(charlist) do 185 | [?\n, ?\r | text] -> Enum.reverse(text) 186 | _ -> charlist 187 | end 188 | end 189 | 190 | end 191 | -------------------------------------------------------------------------------- /lib/exirc/who.ex: -------------------------------------------------------------------------------- 1 | defmodule ExIRC.Who do 2 | 3 | defstruct [ 4 | admin?: nil, 5 | away?: nil, 6 | founder?: nil, 7 | half_operator?: nil, 8 | hops: nil, 9 | host: nil, 10 | name: nil, 11 | nick: nil, 12 | operator?: nil, 13 | server: nil, 14 | server_operator?: nil, 15 | user: nil, 16 | voiced?: nil 17 | ] 18 | end 19 | -------------------------------------------------------------------------------- /lib/exirc/whois.ex: -------------------------------------------------------------------------------- 1 | defmodule ExIRC.Whois do 2 | 3 | defstruct [account_name: nil, 4 | channels: [], 5 | helpop?: false, 6 | hostname: nil, 7 | idling_time: 0, 8 | ircop?: false, 9 | nick: nil, 10 | name: nil, 11 | registered_nick?: false, 12 | server_address: nil, 13 | server_name: nil, 14 | signon_time: 0, 15 | ssl?: false, 16 | user: nil, 17 | ] 18 | end 19 | 20 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule ExIRC.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :exirc, 7 | version: "2.0.0", 8 | elixir: "~> 1.6", 9 | description: "An IRC client library for Elixir.", 10 | package: package(), 11 | test_coverage: [tool: ExCoveralls], 12 | preferred_cli_env: [ 13 | coveralls: :test, 14 | "coveralls.detail": :test, 15 | "coveralls.html": :test, 16 | "coveralls.post": :test 17 | ], 18 | deps: deps() 19 | ] 20 | end 21 | 22 | # Configuration for the OTP application 23 | def application do 24 | [mod: {ExIRC.App, []}, applications: [:ssl, :crypto, :inets]] 25 | end 26 | 27 | defp package do 28 | [ 29 | files: ["lib", "mix.exs", "README.md", "LICENSE", "CHANGELOG.md"], 30 | maintainers: ["Paul Schoenfelder", "Théophile Choutri"], 31 | licenses: ["MIT"], 32 | links: %{ 33 | "GitHub" => "https://github.com/bitwalker/exirc", 34 | "Home Page" => "http://bitwalker.org/exirc" 35 | } 36 | ] 37 | end 38 | 39 | defp deps do 40 | [ 41 | {:ex_doc, "~> 0.22", only: :dev}, 42 | {:excoveralls, "~> 0.13", only: :test} 43 | ] 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "certifi": {:hex, :certifi, "2.5.2", "b7cfeae9d2ed395695dd8201c57a2d019c0c43ecaf8b8bcb9320b40d6662f340", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm", "3b3b5f36493004ac3455966991eaf6e768ce9884693d9968055aeeeb1e575040"}, 3 | "earmark": {:hex, :earmark, "1.3.1", "73812f447f7a42358d3ba79283cfa3075a7580a3a2ed457616d6517ac3738cb9", [:mix], [], "hexpm", "000aaeff08919e95e7aea13e4af7b2b9734577b3e6a7c50ee31ee88cab6ec4fb"}, 4 | "earmark_parser": {:hex, :earmark_parser, "1.4.10", "6603d7a603b9c18d3d20db69921527f82ef09990885ed7525003c7fe7dc86c56", [:mix], [], "hexpm", "8e2d5370b732385db2c9b22215c3f59c84ac7dda7ed7e544d7c459496ae519c0"}, 5 | "ex_doc": {:hex, :ex_doc, "0.22.2", "03a2a58bdd2ba0d83d004507c4ee113b9c521956938298eba16e55cc4aba4a6c", [:mix], [{:earmark_parser, "~> 1.4.0", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm", "cf60e1b3e2efe317095b6bb79651f83a2c1b3edcb4d319c421d7fcda8b3aff26"}, 6 | "excoveralls": {:hex, :excoveralls, "0.13.1", "b9f1697f7c9e0cfe15d1a1d737fb169c398803ffcbc57e672aa007e9fd42864c", [:mix], [{:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "b4bb550e045def1b4d531a37fb766cbbe1307f7628bf8f0414168b3f52021cce"}, 7 | "exjsx": {:hex, :exjsx, "3.2.1", "1bc5bf1e4fd249104178f0885030bcd75a4526f4d2a1e976f4b428d347614f0f", [:mix], [{:jsx, "~> 2.8.0", [hex: :jsx, optional: false]}]}, 8 | "hackney": {:hex, :hackney, "1.16.0", "5096ac8e823e3a441477b2d187e30dd3fff1a82991a806b2003845ce72ce2d84", [:rebar3], [{:certifi, "2.5.2", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.1", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.0", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.6", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "3bf0bebbd5d3092a3543b783bf065165fa5d3ad4b899b836810e513064134e18"}, 9 | "idna": {:hex, :idna, "6.0.1", "1d038fb2e7668ce41fbf681d2c45902e52b3cb9e9c77b55334353b222c2ee50c", [:rebar3], [{:unicode_util_compat, "0.5.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "a02c8a1c4fd601215bb0b0324c8a6986749f807ce35f25449ec9e69758708122"}, 10 | "jason": {:hex, :jason, "1.2.1", "12b22825e22f468c02eb3e4b9985f3d0cb8dc40b9bd704730efa11abd2708c44", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b659b8571deedf60f79c5a608e15414085fa141344e2716fbd6988a084b5f993"}, 11 | "jsx": {:hex, :jsx, "2.8.2", "7acc7d785b5abe8a6e9adbde926a24e481f29956dd8b4df49e3e4e7bcc92a018", [:mix, :rebar3], []}, 12 | "makeup": {:hex, :makeup, "1.0.3", "e339e2f766d12e7260e6672dd4047405963c5ec99661abdc432e6ec67d29ef95", [:mix], [{:nimble_parsec, "~> 0.5", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "2e9b4996d11832947731f7608fed7ad2f9443011b3b479ae288011265cdd3dad"}, 13 | "makeup_elixir": {:hex, :makeup_elixir, "0.14.1", "4f0e96847c63c17841d42c08107405a005a2680eb9c7ccadfd757bd31dabccfb", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "f2438b1a80eaec9ede832b5c41cd4f373b38fd7aa33e3b22d9db79e640cbde11"}, 14 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, 15 | "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, 16 | "nimble_parsec": {:hex, :nimble_parsec, "0.6.0", "32111b3bf39137144abd7ba1cce0914533b2d16ef35e8abc5ec8be6122944263", [:mix], [], "hexpm", "27eac315a94909d4dc68bc07a4a83e06c8379237c5ea528a9acff4ca1c873c52"}, 17 | "parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm", "17ef63abde837ad30680ea7f857dd9e7ced9476cdd7b0394432af4bfc241b960"}, 18 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, 19 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.5.0", "8516502659002cec19e244ebd90d312183064be95025a319a6c7e89f4bccd65b", [:rebar3], [], "hexpm", "d48d002e15f5cc105a696cf2f1bbb3fc72b4b770a184d8420c8db20da2674b38"}, 20 | } 21 | -------------------------------------------------------------------------------- /test/channels_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExIRC.ChannelsTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias ExIRC.Channels, as: Channels 5 | 6 | test "Joining a channel adds it to the tree of currently joined channels" do 7 | channels = Channels.init() |> Channels.join("#testchannel") |> Channels.channels 8 | assert Enum.member?(channels, "#testchannel") 9 | end 10 | 11 | test "The channel name is downcased when joining" do 12 | channels = Channels.init() |> Channels.join("#TestChannel") |> Channels.channels 13 | assert Enum.member?(channels, "#testchannel") 14 | end 15 | 16 | test "Joining the same channel twice is a noop" do 17 | channels = Channels.init() |> Channels.join("#TestChannel") |> Channels.join("#testchannel") |> Channels.channels 18 | assert 1 == Enum.count(channels) 19 | end 20 | 21 | test "Parting a channel removes it from the tree of currently joined channels" do 22 | tree = Channels.init() |> Channels.join("#testchannel") 23 | assert Enum.member?(Channels.channels(tree), "#testchannel") 24 | tree = Channels.part(tree, "#testchannel") 25 | refute Enum.member?(Channels.channels(tree), "#testchannel") 26 | end 27 | 28 | test "Parting a channel not in the tree is a noop" do 29 | tree = Channels.init() 30 | {count, _} = Channels.part(tree, "#testchannel") 31 | assert 0 == count 32 | end 33 | 34 | test "Can set the topic for a channel" do 35 | channels = Channels.init() |> Channels.join("#testchannel") |> Channels.set_topic("#testchannel", "Welcome to Test Channel!") 36 | assert "Welcome to Test Channel!" == Channels.channel_topic(channels, "#testchannel") 37 | end 38 | 39 | test "Setting the topic for a channel we haven't joined returns :error" do 40 | channels = Channels.init() |> Channels.set_topic("#testchannel", "Welcome to Test Channel!") 41 | assert {:error, :no_such_channel} == Channels.channel_topic(channels, "#testchannel") 42 | end 43 | 44 | test "Can set the channel type" do 45 | channels = Channels.init() |> Channels.join("#testchannel") |> Channels.set_type("#testchannel", "@") 46 | assert :secret == Channels.channel_type(channels, "#testchannel") 47 | channels = Channels.set_type(channels, "#testchannel", "*") 48 | assert :private == Channels.channel_type(channels, "#testchannel") 49 | channels = Channels.set_type(channels, "#testchannel", "=") 50 | assert :public == Channels.channel_type(channels, "#testchannel") 51 | end 52 | 53 | test "Setting the channel type for a channel we haven't joined returns :error" do 54 | channels = Channels.init() |> Channels.set_type("#testchannel", "@") 55 | assert {:error, :no_such_channel} == Channels.channel_type(channels, "#testchannel") 56 | end 57 | 58 | test "Setting an invalid channel type raises CaseClauseError" do 59 | assert_raise CaseClauseError, "no case clause matching: '!'", fn -> 60 | Channels.init() |> Channels.join("#testchannel") |> Channels.set_type("#testchannel", "!") 61 | end 62 | end 63 | 64 | test "Can join a user to a channel" do 65 | channels = Channels.init() |> Channels.join("#testchannel") |> Channels.user_join("#testchannel", "testnick") 66 | assert Channels.channel_has_user?(channels, "#testchannel", "testnick") 67 | end 68 | 69 | test "Can join multiple users to a channel" do 70 | channels = Channels.init() |> Channels.join("#testchannel") |> Channels.users_join("#testchannel", ["testnick", "anothernick"]) 71 | assert Channels.channel_has_user?(channels, "#testchannel", "testnick") 72 | assert Channels.channel_has_user?(channels, "#testchannel", "anothernick") 73 | end 74 | 75 | test "Strips rank designations from nicks" do 76 | channels = Channels.init() |> Channels.join("#testchannel") |> Channels.users_join("#testchannel", ["+testnick", "@anothernick", "&athirdnick", "%somanynicks", "~onemorenick"]) 77 | assert Channels.channel_has_user?(channels, "#testchannel", "testnick") 78 | assert Channels.channel_has_user?(channels, "#testchannel", "anothernick") 79 | assert Channels.channel_has_user?(channels, "#testchannel", "athirdnick") 80 | assert Channels.channel_has_user?(channels, "#testchannel", "somanynicks") 81 | assert Channels.channel_has_user?(channels, "#testchannel", "onemorenick") 82 | end 83 | 84 | test "Joining a users to a channel we aren't in is a noop" do 85 | channels = Channels.init() |> Channels.user_join("#testchannel", "testnick") 86 | assert {:error, :no_such_channel} == Channels.channel_has_user?(channels, "#testchannel", "testnick") 87 | channels = Channels.init() |> Channels.users_join("#testchannel", ["testnick", "anothernick"]) 88 | assert {:error, :no_such_channel} == Channels.channel_has_user?(channels, "#testchannel", "testnick") 89 | end 90 | 91 | test "Can part a user from a channel" do 92 | channels = Channels.init() |> Channels.join("#testchannel") |> Channels.user_join("#testchannel", "testnick") 93 | assert Channels.channel_has_user?(channels, "#testchannel", "testnick") 94 | channels = channels |> Channels.user_part("#testchannel", "testnick") 95 | refute Channels.channel_has_user?(channels, "#testchannel", "testnick") 96 | end 97 | 98 | test "Parting a user from a channel we aren't in is a noop" do 99 | channels = Channels.init() |> Channels.user_part("#testchannel", "testnick") 100 | assert {:error, :no_such_channel} == Channels.channel_has_user?(channels, "#testchannel", "testnick") 101 | end 102 | 103 | test "Can quit a user from all channels" do 104 | channels = 105 | Channels.init() 106 | |> Channels.join("#testchannel") 107 | |> Channels.user_join("#testchannel", "testnick") 108 | |> Channels.join("#anotherchannel") 109 | |> Channels.user_join("#anotherchannel", "testnick") 110 | |> Channels.user_join("#anotherchannel", "secondnick") 111 | assert Channels.channel_has_user?(channels, "#testchannel", "testnick") 112 | channels = channels |> Channels.user_quit("testnick") 113 | refute Channels.channel_has_user?(channels, "#testchannel", "testnick") 114 | refute Channels.channel_has_user?(channels, "#anotherchannel", "testnick") 115 | assert Channels.channel_has_user?(channels, "#anotherchannel", "secondnick") 116 | end 117 | 118 | test "Can rename a user" do 119 | channels = Channels.init() 120 | |> Channels.join("#testchannel") 121 | |> Channels.join("#anotherchan") 122 | |> Channels.user_join("#testchannel", "testnick") 123 | |> Channels.user_join("#anotherchan", "testnick") 124 | assert Channels.channel_has_user?(channels, "#testchannel", "testnick") 125 | assert Channels.channel_has_user?(channels, "#anotherchan", "testnick") 126 | channels = Channels.user_rename(channels, "testnick", "newnick") 127 | refute Channels.channel_has_user?(channels, "#testchannel", "testnick") 128 | refute Channels.channel_has_user?(channels, "#anotherchan", "testnick") 129 | assert Channels.channel_has_user?(channels, "#testchannel", "newnick") 130 | assert Channels.channel_has_user?(channels, "#anotherchan", "newnick") 131 | end 132 | 133 | test "Renaming a user that doesn't exist is a noop" do 134 | channels = Channels.init() |> Channels.join("#testchannel") |> Channels.user_rename("testnick", "newnick") 135 | refute Channels.channel_has_user?(channels, "#testchannel", "testnick") 136 | refute Channels.channel_has_user?(channels, "#testchannel", "newnick") 137 | end 138 | 139 | test "Can get the current set of channel data as a tuple of the channel name and it's data as a proplist" do 140 | channels = Channels.init() 141 | |> Channels.join("#testchannel") 142 | |> Channels.set_type("#testchannel", "@") 143 | |> Channels.set_topic("#testchannel", "Welcome to Test!") 144 | |> Channels.join("#anotherchan") 145 | |> Channels.set_type("#anotherchan", "=") 146 | |> Channels.set_topic("#anotherchan", "Welcome to Another Channel!") 147 | |> Channels.user_join("#testchannel", "testnick") 148 | |> Channels.user_join("#anotherchan", "testnick") 149 | |> Channels.to_proplist 150 | testchannel = {"#testchannel", [users: ["testnick"], topic: "Welcome to Test!", type: :secret]} 151 | anotherchan = {"#anotherchan", [users: ["testnick"], topic: "Welcome to Another Channel!", type: :public]} 152 | assert [testchannel, anotherchan] == channels 153 | end 154 | end 155 | -------------------------------------------------------------------------------- /test/client_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExIRC.ClientTest do 2 | use ExUnit.Case, async: false 3 | use ExIRC.Commands 4 | 5 | alias ExIRC.Client 6 | alias ExIRC.SenderInfo 7 | 8 | test "start a client linked to the caller " do 9 | {:ok, _} = ExIRC.start_link!() 10 | end 11 | 12 | test "start multiple clients" do 13 | assert {:ok, pid} = ExIRC.start_client!() 14 | assert {:ok, pid2} = ExIRC.start_client!() 15 | assert pid != pid2 16 | end 17 | 18 | test "client dies if owner process dies" do 19 | test_pid = self() 20 | 21 | pid = 22 | spawn_link(fn -> 23 | assert {:ok, pid} = ExIRC.start_client!() 24 | send(test_pid, {:client, pid}) 25 | 26 | receive do 27 | :stop -> :ok 28 | end 29 | end) 30 | 31 | client_pid = 32 | receive do 33 | {:client, pid} -> pid 34 | end 35 | 36 | assert Process.alive?(client_pid) 37 | send(pid, :stop) 38 | :timer.sleep(1) 39 | refute Process.alive?(client_pid) 40 | end 41 | 42 | test "login sends event to handler" do 43 | state = get_state() 44 | state = %{state | logged_on?: false, channels: []} 45 | msg = %ExIRC.Message{cmd: @rpl_welcome} 46 | {:noreply, new_state} = Client.handle_data(msg, state) 47 | assert new_state.logged_on? == true 48 | assert_receive :logged_in, 10 49 | end 50 | 51 | test "login failed with nick in use sends event to handler" do 52 | state = get_state() 53 | state = %{state | logged_on?: false} 54 | msg = %ExIRC.Message{cmd: @err_nick_in_use} 55 | {:noreply, new_state} = Client.handle_data(msg, state) 56 | assert new_state.logged_on? == false 57 | assert_receive {:login_failed, :nick_in_use}, 10 58 | end 59 | 60 | test "own nick change sends event to handler" do 61 | state = get_state() 62 | msg = %ExIRC.Message{nick: state.nick, cmd: "NICK", args: ["new_nick"]} 63 | {:noreply, new_state} = Client.handle_data(msg, state) 64 | assert new_state.nick == "new_nick" 65 | assert_receive {:nick_changed, "new_nick"}, 10 66 | end 67 | 68 | test "receiving private message sends event to handler" do 69 | state = get_state() 70 | 71 | msg = %ExIRC.Message{ 72 | nick: "other_user", 73 | cmd: "PRIVMSG", 74 | args: [state.nick, "message"], 75 | host: "host", 76 | user: "user" 77 | } 78 | 79 | Client.handle_data(msg, state) 80 | expected_senderinfo = %SenderInfo{nick: "other_user", host: "host", user: "user"} 81 | assert_receive {:received, "message", ^expected_senderinfo}, 10 82 | end 83 | 84 | test "receiving channel message sends event to handler" do 85 | state = get_state() 86 | 87 | msg = %ExIRC.Message{ 88 | nick: "other_user", 89 | cmd: "PRIVMSG", 90 | args: ["#testchannel", "message"], 91 | host: "host", 92 | user: "user" 93 | } 94 | 95 | Client.handle_data(msg, state) 96 | expected_senderinfo = %SenderInfo{nick: "other_user", host: "host", user: "user"} 97 | assert_receive {:received, "message", ^expected_senderinfo, "#testchannel"}, 10 98 | end 99 | 100 | test "receiving channel message with lowercase mention sends events to handler" do 101 | state = get_state() 102 | chat_message = "hi #{String.downcase(state.nick)}!" 103 | 104 | msg = %ExIRC.Message{ 105 | nick: "other_user", 106 | cmd: "PRIVMSG", 107 | args: ["#testchannel", chat_message], 108 | host: "host", 109 | user: "user" 110 | } 111 | 112 | Client.handle_data(msg, state) 113 | expected_senderinfo = %SenderInfo{nick: "other_user", host: "host", user: "user"} 114 | assert_receive {:received, ^chat_message, ^expected_senderinfo, "#testchannel"}, 10 115 | assert_receive {:mentioned, ^chat_message, ^expected_senderinfo, "#testchannel"}, 10 116 | end 117 | 118 | test "receiving channel message with uppercase mention sends events to handler" do 119 | state = get_state() 120 | chat_message = "hi #{String.upcase(state.nick)}!" 121 | 122 | msg = %ExIRC.Message{ 123 | nick: "other_user", 124 | cmd: "PRIVMSG", 125 | args: ["#testchannel", chat_message], 126 | host: "host", 127 | user: "user" 128 | } 129 | 130 | Client.handle_data(msg, state) 131 | expected_senderinfo = %SenderInfo{nick: "other_user", host: "host", user: "user"} 132 | assert_receive {:received, ^chat_message, ^expected_senderinfo, "#testchannel"}, 10 133 | assert_receive {:mentioned, ^chat_message, ^expected_senderinfo, "#testchannel"}, 10 134 | end 135 | 136 | defp get_state() do 137 | %ExIRC.Client.ClientState{ 138 | nick: "tester", 139 | logged_on?: true, 140 | event_handlers: [{self(), Process.monitor(self())}], 141 | channels: [get_channel()] 142 | } 143 | end 144 | 145 | defp get_channel() do 146 | %ExIRC.Channels.Channel{ 147 | name: "testchannel", 148 | topic: "topic", 149 | users: [], 150 | modes: '', 151 | type: '' 152 | } 153 | end 154 | end 155 | -------------------------------------------------------------------------------- /test/commands_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExIRC.CommandsTest do 2 | use ExUnit.Case, async: true 3 | 4 | use ExIRC.Commands 5 | 6 | test "Commands are formatted properly" do 7 | expected = <<0o001, "TESTCMD", 0o001, ?\r, ?\n>> 8 | assert expected == ctcp!("TESTCMD") |> IO.iodata_to_binary 9 | expected = <<"PRIVMSG #testchan :", 0o001, "ACTION mind explodes!!", 0o001, ?\r, ?\n>> 10 | assert expected == me!("#testchan", "mind explodes!!") |> IO.iodata_to_binary 11 | expected = <<"PASS testpass", ?\r, ?\n>> 12 | assert expected == pass!("testpass") |> IO.iodata_to_binary 13 | expected = <<"NICK testnick", ?\r, ?\n>> 14 | assert expected == nick!("testnick") |> IO.iodata_to_binary 15 | expected = <<"USER testuser 0 * :Test User", ?\r, ?\n>> 16 | assert expected == user!("testuser", "Test User") |> IO.iodata_to_binary 17 | expected = <<"PONG testnick", ?\r, ?\n>> 18 | assert expected == pong1!("testnick") |> IO.iodata_to_binary 19 | expected = <<"PONG testnick othernick", ?\r, ?\n>> 20 | assert expected == pong2!("testnick", "othernick") |> IO.iodata_to_binary 21 | expected = <<"PRIVMSG testnick :Test message!", ?\r, ?\n>> 22 | assert expected == privmsg!("testnick", "Test message!") |> IO.iodata_to_binary 23 | expected = <<"NOTICE testnick :Test notice!", ?\r, ?\n>> 24 | assert expected == notice!("testnick", "Test notice!") |> IO.iodata_to_binary 25 | expected = <<"JOIN testchan", ?\r, ?\n>> 26 | assert expected == join!("testchan") |> IO.iodata_to_binary 27 | expected = <<"JOIN testchan chanpass", ?\r, ?\n>> 28 | assert expected == join!("testchan", "chanpass") |> IO.iodata_to_binary 29 | expected = <<"PART testchan", ?\r, ?\n>> 30 | assert expected == part!("testchan") |> IO.iodata_to_binary 31 | expected = <<"QUIT :Leaving", ?\r, ?\n>> 32 | assert expected == quit!() |> IO.iodata_to_binary 33 | expected = <<"QUIT :Goodbye, cruel world.", ?\r, ?\n>> 34 | assert expected == quit!("Goodbye, cruel world.") |> IO.iodata_to_binary 35 | expected = <<"KICK #testchan testuser", ?\r, ?\n>> 36 | assert expected == kick!("#testchan", "testuser") |> IO.iodata_to_binary 37 | expected = <<"KICK #testchan testuser Get outta here!", ?\r, ?\n>> 38 | assert expected == kick!("#testchan", "testuser", "Get outta here!") |> IO.iodata_to_binary 39 | expected = <<"MODE testuser -o", ?\r, ?\n>> 40 | assert expected == mode!("testuser", "-o") |> IO.iodata_to_binary 41 | expected = <<"MODE #testchan +im", ?\r, ?\n>> 42 | assert expected == mode!("#testchan", "+im") |> IO.iodata_to_binary 43 | expected = <<"MODE #testchan +o testuser", ?\r, ?\n>> 44 | assert expected == mode!("#testchan", "+o", "testuser") |> IO.iodata_to_binary 45 | expected = <<"INVITE testuser #testchan", ?\r, ?\n>> 46 | assert expected == invite!("testuser", "#testchan") |> IO.iodata_to_binary 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start 2 | -------------------------------------------------------------------------------- /test/utils_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExIRC.UtilsTest do 2 | use ExUnit.Case, async: true 3 | 4 | use ExIRC.Commands 5 | 6 | alias ExIRC.Utils, as: Utils 7 | alias ExIRC.Client.ClientState, as: ClientState 8 | 9 | doctest ExIRC.Utils 10 | 11 | test "Given a local date/time as a tuple, can retrieve get the CTCP formatted time" do 12 | local_time = {{2013,12,6},{14,5,0}} # Mimics output of :calendar.local_time() 13 | assert Utils.ctcp_time(local_time) == "Fri Dec 06 14:05:00 2013" 14 | end 15 | 16 | test "Can parse a CTCP command" do 17 | message = ':pschoenf NOTICE #testchan :' ++ '#{<<0o001>>}' ++ 'ACTION mind explodes!!' ++ '#{<<0o001>>}' 18 | expected = %ExIRC.Message{ 19 | nick: "pschoenf", 20 | cmd: "ACTION", 21 | ctcp: true, 22 | args: ["#testchan", "mind explodes!!"] 23 | } 24 | result = Utils.parse(message) 25 | assert expected == result 26 | end 27 | 28 | test "Parse cloaked user" do 29 | message = ':foo!foo@unaffiliated/foo PRIVMSG #bar Hiya.' 30 | expected = %ExIRC.Message{ 31 | nick: "foo", 32 | cmd: "PRIVMSG", 33 | host: "unaffiliated/foo", 34 | ctcp: false, 35 | user: "foo", 36 | args: ["#bar", "Hiya."] 37 | } 38 | result = Utils.parse(message) 39 | assert expected == result 40 | end 41 | 42 | test "Parse uncloaked (normal) user" do 43 | message = ':foo!foo@80.21.56.43 PRIVMSG #bar Hiya.' 44 | expected = %ExIRC.Message{ 45 | nick: "foo", 46 | cmd: "PRIVMSG", 47 | host: "80.21.56.43", 48 | ctcp: false, 49 | user: "foo", 50 | args: ["#bar", "Hiya."] 51 | } 52 | result = Utils.parse(message) 53 | assert expected == result 54 | end 55 | 56 | test "Parse INVITE message" do 57 | message = ':pschoenf INVITE testuser #awesomechan' 58 | assert %ExIRC.Message{ 59 | :nick => "pschoenf", 60 | :cmd => "INVITE", 61 | :args => ["testuser", "#awesomechan"] 62 | } = Utils.parse(message) 63 | end 64 | 65 | test "Parse KICK message" do 66 | message = ':pschoenf KICK #testchan lameuser' 67 | assert %ExIRC.Message{ 68 | :nick => "pschoenf", 69 | :cmd => "KICK", 70 | :args => ["#testchan", "lameuser"] 71 | } = Utils.parse(message) 72 | end 73 | 74 | test "Can parse RPL_ISUPPORT commands" do 75 | message = ':irc.example.org 005 nick NETWORK=Freenode PREFIX=(ov)@+ CHANTYPES=#&' 76 | parsed = Utils.parse(message) 77 | state = %ClientState{} 78 | assert %ClientState{ 79 | :channel_prefixes => ["#", "&"], 80 | :user_prefixes => [{?o, ?@}, {?v, ?+}], 81 | :network => "Freenode" 82 | } = Utils.isup(parsed.args, state) 83 | end 84 | 85 | test "Can parse full prefix in messages" do 86 | assert %ExIRC.Message{ 87 | nick: "WiZ", 88 | user: "jto", 89 | host: "tolsun.oulu.fi", 90 | } = Utils.parse(':WiZ!jto@tolsun.oulu.fi NICK Kilroy') 91 | end 92 | 93 | test "Can parse prefix with only hostname in messages" do 94 | assert %ExIRC.Message{ 95 | nick: "WiZ", 96 | host: "tolsun.oulu.fi", 97 | } = Utils.parse(':WiZ!tolsun.oulu.fi NICK Kilroy') 98 | end 99 | 100 | test "Can parse reduced prefix in messages" do 101 | assert %ExIRC.Message{ 102 | nick: "Trillian", 103 | } = Utils.parse(':Trillian SQUIT cm22.eng.umd.edu :Server out of control') 104 | end 105 | 106 | test "Can parse server-only prefix in messages" do 107 | assert %ExIRC.Message{ 108 | server: "ircd.stealth.net" 109 | } = Utils.parse(':ircd.stealth.net 302 yournick :syrk=+syrk@millennium.stealth.net') 110 | end 111 | 112 | test "Can parse FULL STOP in username in prefixes" do 113 | assert %ExIRC.Message{ 114 | nick: "nick", 115 | user: "user.name", 116 | host: "irc.example.org" 117 | } = Utils.parse(':nick!user.name@irc.example.org PART #channel') 118 | end 119 | 120 | test "Can parse EXCLAMATION MARK in username in prefixes" do 121 | assert %ExIRC.Message{ 122 | nick: "nick", 123 | user: "user!name", 124 | host: "irc.example.org" 125 | } = Utils.parse(':nick!user!name@irc.example.org PART #channel') 126 | end 127 | 128 | test "parse join message" do 129 | message = ':pschoenf JOIN #elixir-lang' 130 | assert %ExIRC.Message{ 131 | nick: "pschoenf", 132 | cmd: "JOIN", 133 | args: ["#elixir-lang"] 134 | } = Utils.parse(message) 135 | end 136 | 137 | test "Parse Slack's inappropriate RPL_TOPIC message as if it were an RPL_NOTOPIC" do 138 | # NOTE: This is not a valid message per the RFC. If there's no topic 139 | # (which is the case for Slack in this instance), they should instead send 140 | # us a RPL_NOTOPIC (331). 141 | # 142 | # Two things: 143 | # 144 | # 1) Bad slack! Read your RFCs! (because my code has never had bugs yup obv) 145 | # 2) Don't care, still want to talk to them without falling over dead! 146 | # 147 | # Parsing this as if it were actually an RPL_NOTOPIC (331) seems especially like 148 | # a good idea when I realized that there's nothing in ExIRc that does anything 149 | # with 331 at all - they just fall on the floor, no crashes to be seen (ideally) 150 | message = ':irc.tinyspeck.com 332 jadams #elm-playground-news :' 151 | assert %ExIRC.Message{ 152 | nick: "jadams", 153 | cmd: "331", 154 | args: ["#elm-playground-news", "No topic is set"] 155 | } = Utils.parse(message) 156 | end 157 | 158 | test "Can parse simple unicode" do 159 | # ':foo!~user@172.17.0.1 PRIVMSG #bar :éáçíóö\r\n' 160 | message = [58, 102, 111, 111, 33, 126, 117, 115, 101, 114, 64, 49, 55, 50, 161 | 46, 49, 55, 46, 48, 46, 49, 32, 80, 82, 73, 86, 77, 83, 71, 32, 162 | 35, 98, 97, 114, 32, 58, 195, 169, 195, 161, 195, 167, 195, 173, 163 | 195, 179, 195, 182, 13, 10] 164 | assert %ExIRC.Message{ 165 | args: ["#bar", "éáçíóö"], 166 | cmd: "PRIVMSG", 167 | ctcp: false, 168 | host: "172.17.0.1", 169 | nick: "foo", 170 | server: [], 171 | user: "~user" 172 | } = Utils.parse(message) 173 | end 174 | 175 | test "Can parse complex unicode" do 176 | # ':foo!~user@172.17.0.1 PRIVMSG #bar :Ĥélłø 차\r\n' 177 | message = [58, 102, 111, 111, 33, 126, 117, 115, 101, 114, 64, 49, 55, 50, 178 | 46, 49, 55, 46, 48, 46, 49, 32, 80, 82, 73, 86, 77, 83, 71, 32, 179 | 35, 98, 97, 114, 32, 58, 196, 164, 195, 169, 108, 197, 130, 195, 180 | 184, 32, 236, 176, 168, 13, 10] 181 | assert %ExIRC.Message{ 182 | args: ["#bar", "Ĥélłø 차"], 183 | cmd: "PRIVMSG", 184 | ctcp: false, 185 | host: "172.17.0.1", 186 | nick: "foo", 187 | server: [], 188 | user: "~user" 189 | } = Utils.parse(message) 190 | end 191 | 192 | test "Can parse latin1" do 193 | # ':foo!~user@172.17.0.1 PRIVMSG #bar :ééé\r\n' 194 | message = [58, 102, 111, 111, 33, 126, 117, 115, 101, 114, 64, 49, 55, 50, 195 | 46, 49, 55, 46, 48, 46, 49, 32, 80, 82, 73, 86, 77, 83, 71, 32, 196 | 35, 98, 97, 114, 32, 58, 233, 233, 233, 13, 10] 197 | 198 | assert %ExIRC.Message{ 199 | args: ["#bar", "ééé"], 200 | cmd: "PRIVMSG", 201 | ctcp: false, 202 | host: "172.17.0.1", 203 | nick: "foo", 204 | server: [], 205 | user: "~user" 206 | } = Utils.parse(message) 207 | end 208 | 209 | end 210 | --------------------------------------------------------------------------------