├── .formatter.exs ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── INSTALL.md ├── LICENSE ├── README.md ├── dist ├── Containerfile └── systemd │ └── matrix2051.service ├── lib ├── application.ex ├── config.ex ├── format │ ├── common.ex │ ├── irc2matrix.ex │ └── matrix2irc.ex ├── irc │ ├── command.ex │ ├── handler.ex │ └── word_wrap.ex ├── irc_conn │ ├── reader.ex │ ├── state.ex │ ├── supervisor.ex │ └── writer.ex ├── irc_server.ex ├── matrix │ ├── misc.ex │ ├── raw_client.ex │ ├── room_member.ex │ ├── room_state.ex │ └── utils.ex ├── matrix_client │ ├── chat_history.ex │ ├── client.ex │ ├── poller.ex │ ├── room_handler.ex │ ├── room_supervisor.ex │ ├── sender.ex │ └── state.ex └── supervisor.ex ├── matrix2051.exs ├── mix.exs └── test ├── format └── common_test.exs ├── irc ├── command_test.exs ├── handler_test.exs └── word_wrap_test.exs ├── irc_conn └── state_test.exs ├── matrix_client ├── client_test.exs ├── poller_test.exs └── state_test.exs └── test_helper.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | 'on': [push, pull_request] 2 | 3 | jobs: 4 | test: 5 | runs-on: ${{matrix.os}} 6 | name: OTP ${{matrix.otp}} / Elixir ${{matrix.elixir}} 7 | strategy: 8 | fail-fast: false 9 | matrix: 10 | include: 11 | - otp: '24' 12 | elixir: '1.11' 13 | os: 'ubuntu-22.04' 14 | - otp: '24' 15 | elixir: '1.13' 16 | os: 'ubuntu-22.04' 17 | - otp: '24' 18 | elixir: '1.14' 19 | os: 'ubuntu-22.04' 20 | - otp: '27' 21 | elixir: '1.18' 22 | os: 'ubuntu-22.04' 23 | steps: 24 | - uses: actions/checkout@v2 25 | - uses: erlef/setup-beam@v1 26 | with: 27 | otp-version: ${{matrix.otp}} 28 | elixir-version: ${{matrix.elixir}} 29 | - run: mix deps.get 30 | - run: mix test 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | matrix2051-*.tar 24 | 25 | # Lockfiles are a terrible idea 26 | mix.lock 27 | -------------------------------------------------------------------------------- /INSTALL.md: -------------------------------------------------------------------------------- 1 | # Installing Matrix2051 2 | 3 | This document explains how to deploy Matrix2051 in a production environment. 4 | 5 | The commands below require Elixir >= 1.9 (the version that introduced `mix release`). 6 | If you need to use an older release, refer to the commands in `README.md`. 7 | They won't work as well, but it's the best we can. 8 | 9 | ## Install dependencies 10 | 11 | ``` 12 | sudo apt install elixir erlang erlang-dev erlang-inets erlang-xmerl 13 | MIX_ENV=prod mix deps.get 14 | ``` 15 | 16 | ## Compilation 17 | 18 | ``` 19 | MIX_ENV=prod mix release 20 | ``` 21 | 22 | ## Test run 23 | 24 | You can now run it with this command (instead of `mix run matrix2051.exs`): 25 | 26 | ``` 27 | _build/prod/rel/matrix2051/bin/matrix2051 start 28 | ``` 29 | 30 | Make sure it listens to connections, then press Ctrl-C twice to stop it. 31 | 32 | ## Deployment 33 | 34 | You can now run it with your favorite init. 35 | 36 | For example, with systemd and assuming you cloned the repository in `/opt/matrix2051`: 37 | 38 | ``` 39 | [Unit] 40 | Description=Matrix2051, a Matrix gateway for IRC 41 | After=network.target 42 | 43 | [Service] 44 | Type=simple 45 | ExecStart=/opt/matrix2051/_build/prod/rel/matrix2051/bin/matrix2051 start 46 | ExecStop=/opt/matrix2051/_build/prod/rel/matrix2051/bin/matrix2051 stop 47 | Restart=always 48 | SyslogIdentifier=Matrix2051 49 | Environment=HOME=/tmp/ 50 | DynamicUser=true 51 | 52 | [Install] 53 | WantedBy=multi-user.target 54 | ``` 55 | 56 | This does the following: 57 | 58 | * Set `$HOME` to a writeable directory (requirement of `erlexec`) 59 | * Create a temporary user to run the process as 60 | * Makes sure the process can't write any file on the system or gain new capabilities 61 | (implied by `DynamicUser=true`) 62 | 63 | # Running matrix2051 as a container 64 | 65 | Alternatively a Containerfile is provided as well for convenience. The 66 | image can be built with either [podman](https://podman.io/) or 67 | [docker](https://www.docker.com/). 68 | 69 | To build it: 70 | 71 | ``` 72 | podman build -t matrix2051 --file dist/Containerfile . 73 | ``` 74 | 75 | To run it: 76 | 77 | ``` 78 | podman run --publish 2051:2051 --interactive matrix2051 79 | ``` 80 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Matrix2051 2 | 3 | *Join Matrix from your favorite IRC client* 4 | 5 | Matrix2051 (or M51 for short) is an IRC server backed by Matrix. You can also see it 6 | as an IRC bouncer that connects to Matrix homeservers instead of IRC servers. 7 | In other words: 8 | 9 | ``` 10 | IRC client 11 | (eg. weechat or hexchat) 12 | | 13 | | IRC protocol 14 | v 15 | Matrix2051 16 | | 17 | | Matrix protocol 18 | v 19 | Your Homeserver 20 | (eg. matrix.org) 21 | ``` 22 | 23 | 24 | Goals: 25 | 26 | 1. Make it easy for IRC users to join Matrix seamlessly 27 | 2. Support existing relay bots, to allows relays that behave better on IRC than 28 | existing IRC/Matrix bridges 29 | 3. Bleeding-edge IRCv3 implementation 30 | 4. Very easy to install. This means: 31 | 1. as little configuration and database as possible (ideally zero) 32 | 2. small set of depenencies. 33 | 34 | Non-goals: 35 | 36 | 1. Being a hosted service (it would require spam countermeasures, and that's a lot of work). 37 | 2. TLS support (see previous point). Just run it on localhost. If you really need it to be remote, access it via a VPN or a reverse proxy. 38 | 3. Connecting to multiple accounts per IRC connection or to other protocols (à la [Bitlbee](https://www.bitlbee.org/)). This conflicts with goals 1 and 4. 39 | 4. Implementing any features not natively by **both** protocols (ie. no need for service bots that you interract with using PRIVMSG) 40 | 41 | ## Major features 42 | 43 | * Registration and password authentication 44 | * Joining rooms 45 | * Sending and receiving messages (supports formatting, multiline, highlights, replying, reacting to messages) 46 | * Partial [IRCv3 ChatHistory](https://ircv3.net/specs/extensions/chathistory) support; 47 | enough for Gamja to work. 48 | [open chathistory issues](https://github.com/progval/matrix2051/milestone/3) 49 | * [Partial](https://github.com/progval/matrix2051/issues/14) display name support 50 | 51 | ## Shortcomings 52 | 53 | * [Direct chats are shown as regular channels, with random names](https://github.com/progval/matrix2051/issues/11) 54 | * Does not "feel" like a real IRC network (yet?) 55 | * User IDs and room names are uncomfortably long 56 | * Loading the nick list of huge rooms like #matrix:matrix.org overloads some IRC clients 57 | * IRC clients without [hex color](https://modern.ircdocs.horse/formatting.html#hex-color) 58 | support will see some garbage instead of colors. (Though colored text seems very uncommon on Matrix) 59 | * IRC clients without advanced IRCv3 support work miss out on many features: 60 | [quote replies](https://github.com/progval/matrix2051/issues/16), reacts, display names. 61 | 62 | ## Screenshot 63 | 64 | ![screenshot of #synapse:matrix.org with Element and IRCCloud side-by-side](https://raw.githubusercontent.com/progval/matrix2051/assets/screenshot_element_irccloud.png) 65 | 66 | Two notes on this screenshot: 67 | 68 | * [Message edits](https://spec.matrix.org/v1.4/client-server-api/#event-replacements) are rendered with a fallback, as message edits are [not yet supported by IRC](https://github.com/ircv3/ircv3-specifications/pull/425), 69 | * Replies on IRCCloud are rendered with colored icons, and clicking these icons opens a column showing the whole thread. Other clients may render replies differently. 70 | 71 | ## Usage 72 | 73 | * Install system dependencies. For example, on Debian: `sudo apt install elixir erlang erlang-dev erlang-inets erlang-xmerl` 74 | * Install Elixir dependencies: `mix deps.get` 75 | * Run tests to make sure everything is working: `mix test` 76 | * Run: `mix run matrix2051.exs` 77 | * Connect a client to `localhost:2051`, with the following config: 78 | * no SSL/TLS 79 | * SASL username: your full matrix ID (`user:homeserver.example.org`) 80 | * SASL password: your matrix password 81 | 82 | See below for extra instructions to work with web clients. 83 | 84 | See `INSTALL.md` for a more production-oriented guide. 85 | 86 | ## Architecture 87 | 88 | * `matrix2051.exs` starts M51.Application, which starts M51.Supervisor, which 89 | supervises: 90 | * `config.ex`: global config agent 91 | * `irc_server.ex`: a `DynamicSupervisor` that receives connections from IRC clients. 92 | 93 | Every time `irc_server.ex` receives a connection, it spawns `irc_conn/supervisor.ex`, 94 | which supervises: 95 | 96 | * `irc_conn/state.ex`: stores the state of the connection 97 | * `irc_conn/writer.ex`: genserver holding the socket and allowing 98 | to write lines to it (and batches of lines in the future) 99 | * `irc_conn/handler.ex`: task busy-waiting on the incoming commands 100 | from the reader, answers to the simple ones, and dispatches more complex 101 | commands 102 | * `matrix_client/state.ex`: keeps the state of the connection to a Matrix homeserver 103 | * `matrix_client/client.ex`: handles one connection to a Matrix homeserver, as a single user 104 | * `matrix_client/sender.ex`: sends events to the Matrix homeserver and with retries on failure 105 | * `matrix_client/poller.ex`: repeatedly asks the Matrix homeserver for new events (including the initial sync) 106 | * `irc_conn/reader.ex`: task busy-waiting on the incoming lines, 107 | and sends them to the handler 108 | 109 | Utilities: 110 | 111 | * `matrix/raw_client.ex`: low-level Matrix client / thin wrapper around HTTP requests 112 | * `irc/command.ex`: IRC line manipulation, including "downgrading" them for clients 113 | that don't support some capabilities. 114 | * `irc/word_wrap.ex`: generic line wrapping 115 | * `format/`: Convert between IRC's formatting and `org.matrix.custom.html` 116 | * `matrix_client/chat_history.ex`: fetches message history from Matrix, when requested 117 | by the IRC client 118 | 119 | ## Questions 120 | 121 | ### Why? 122 | 123 | There are many great IRC clients, but I can't find a Matrix client I like. 124 | Yet, some communities are moving from IRC to Matrix, so I wrote this so I can 125 | join them with a comfortable client. 126 | 127 | This is also a way to prototype the latest IRCv3 features easily, 128 | and for me to learn the Matrix protocol. 129 | 130 | ### What IRC clients are supported? 131 | 132 | In theory, any IRC client should work. In particular, I test it with 133 | [Gamja](https://git.sr.ht/~emersion/gamja/), [IRCCloud](https://www.irccloud.com/), 134 | [The Lounge](https://thelounge.chat/), and [WeeChat](https://weechat.org/). 135 | 136 | Please open an issue if your client has any issue. 137 | 138 | ### What Matrix homeservers are supported? 139 | 140 | In theory, any, as I wrote this by reading the Matrix specs. 141 | In practice, this is only tested with [Synapse](https://github.com/matrix-org/synapse/). 142 | 143 | A notable exception is registration, which uses a Synapse-specific API 144 | as Matrix itself does not specify registration. 145 | 146 | Please open an issue if you have any issue with your homeserver 147 | (a dummy login/password I can use to connect to it would be appreciated). 148 | 149 | ### Are you planning to support features X, Y, ...? 150 | 151 | At the time of writing, if both Matrix and IRC/IRCv3 support them, Matrix2051 likely will. 152 | Take a look at [the list of open 'enhancement' issues](https://github.com/progval/matrix2051/issues?q=is%3Aissue+is%3Aopen+label%3Aenhancement). 153 | 154 | A notable exception is [direct messages](https://github.com/progval/matrix2051/issues/11), 155 | because Matrix's model differs significantly from IRC's. 156 | 157 | ### Can I connect with a web client? 158 | 159 | To connect web clients, you need a websocket gateway. 160 | Matrix2051 was tested with [KiwiIRC's webircgateway](https://github.com/kiwiirc/webircgateway) 161 | (try [this patch](https://github.com/kiwiirc/webircgateway/pull/91) if you need to run it on old Go versions). 162 | 163 | Here is how you can configure it to connect to Matrix2051 with [Gamja](https://git.sr.ht/~emersion/gamja/): 164 | 165 | ```toml 166 | [fileserving] 167 | enabled = true 168 | webroot = "/path/to/gamja" 169 | 170 | 171 | [upstream.1] 172 | hostname = "localhost" 173 | port = 2051 174 | tls = false 175 | # Connection timeout in seconds 176 | timeout = 20 177 | # Throttle the lines being written by X per second 178 | throttle = 100 179 | webirc = "" 180 | serverpassword = "" 181 | ``` 182 | 183 | ### What's with the name? 184 | 185 | This is a reference to [xkcd 1782](https://xkcd.com/1782/): 186 | 187 | ![2004: Our team stays in touch over IRC. 2010: Our team mainly uses Skype, but some of us prefer to stick to IRC. 2017: We've got almost everyone on Slack, But three people refuse to quit IRC and connect via gateway. 2051: All consciousnesses have merged with the Galactic Singularity, Except for one guy who insists on joining through his IRC client. "I just have it set up the way I want, okay?!" *Sigh*](https://imgs.xkcd.com/comics/team_chat.png) 188 | 189 | ### I still have a question, how can I contact you? 190 | 191 | Join [#matrix2051 at irc.interlinked.me](ircs://irc.interlinked.me/matrix2051). 192 | (No I am not eating my own dogfood, I still prefer "native" IRC.) 193 | -------------------------------------------------------------------------------- /dist/Containerfile: -------------------------------------------------------------------------------- 1 | FROM docker.io/alpine:3.19 2 | 3 | ENV MIX_ENV=prod 4 | 5 | RUN apk add --update --no-cache elixir 6 | 7 | WORKDIR /app 8 | 9 | COPY . /app 10 | 11 | RUN mix deps.get 12 | RUN mix release 13 | 14 | CMD _build/prod/rel/matrix2051/bin/matrix2051 start 15 | -------------------------------------------------------------------------------- /dist/systemd/matrix2051.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=A Matrix gateway for IRC, join from your favorite IRC client 3 | After=network.target 4 | Wants=network.target 5 | 6 | [Service] 7 | Type=simple 8 | User=matrix2051 9 | Group=matrix2051 10 | DynamicUser=true 11 | SyslogIdentifier=matrix2051 12 | StateDirectory=matrix2051 13 | RuntimeDirectory=matrix2051 14 | ExecStart=/usr/lib/matrix2051/bin/matrix2051 start 15 | ExecStop=/usr/lib/matrix2051/bin/matrix2051 stop 16 | Environment=HOME=/var/lib/matrix2051 17 | ProtectKernelTunables=true 18 | ProtectKernelModules=true 19 | ProtectKernelLogs=true 20 | ProtectControlGroups=true 21 | RestrictRealtime=true 22 | Restart=always 23 | RestartSec=10 24 | CapabilityBoundingSet= 25 | AmbientCapabilities= 26 | NoNewPrivileges=true 27 | #SecureBits= 28 | ProtectSystem=strict 29 | ProtectHome=true 30 | PrivateTmp=true 31 | PrivateDevices=true 32 | PrivateNetwork=false 33 | PrivateUsers=true 34 | ProtectHostname=true 35 | ProtectClock=true 36 | ProtectKernelTunables=true 37 | ProtectKernelModules=true 38 | ProtectKernelLogs=true 39 | ProtectControlGroups=true 40 | RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 41 | RestrictNamespaces=true 42 | LockPersonality=true 43 | RestrictRealtime=true 44 | RestrictSUIDSGID=true 45 | SystemCallFilter=@system-service 46 | SystemCallArchitectures=native 47 | 48 | 49 | [Install] 50 | WantedBy=multi-user.target 51 | -------------------------------------------------------------------------------- /lib/application.ex: -------------------------------------------------------------------------------- 1 | ## 2 | # Copyright (C) 2021 Valentin Lorentz 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License version 3, 6 | # as published by the Free Software Foundation. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU Affero General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU Affero General Public License 14 | # along with this program. If not, see . 15 | ### 16 | 17 | defmodule M51.Application do 18 | @moduledoc """ 19 | Main module of M51. 20 | """ 21 | use Application 22 | 23 | require Logger 24 | 25 | @doc """ 26 | Entrypoint. Takes the global config as args, and starts M51.Supervisor 27 | """ 28 | @impl true 29 | def start(_type, args) do 30 | if Enum.member?(System.argv(), "--debug") do 31 | Logger.warn("Starting in debug mode") 32 | Logger.configure(level: :debug) 33 | else 34 | Logger.configure(level: :info) 35 | end 36 | 37 | HTTPoison.start() 38 | 39 | children = [ 40 | {M51.Supervisor, args} 41 | ] 42 | 43 | {:ok, res} = Supervisor.start_link(children, strategy: :one_for_one) 44 | Logger.info("Matrix2051 started.") 45 | {:ok, res} 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/config.ex: -------------------------------------------------------------------------------- 1 | ## 2 | # Copyright (C) 2021 Valentin Lorentz 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License version 3, 6 | # as published by the Free Software Foundation. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU Affero General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU Affero General Public License 14 | # along with this program. If not, see . 15 | ### 16 | 17 | defmodule M51.Config do 18 | @moduledoc """ 19 | Global configuration. 20 | """ 21 | use GenServer 22 | 23 | def start_link(args) do 24 | GenServer.start_link(__MODULE__, fn -> args end, name: __MODULE__) 25 | end 26 | 27 | @impl true 28 | def init(_args) do 29 | {:ok, []} 30 | end 31 | 32 | @impl true 33 | def handle_call({:get_httpoison}, _from, state) do 34 | {:reply, Keyword.get(state, :httpoison, HTTPoison), state} 35 | end 36 | 37 | @impl true 38 | def handle_call({:set_httpoison, httpoison}, _from, state) do 39 | {:reply, {}, Keyword.put(state, :httpoison, httpoison)} 40 | end 41 | 42 | def httpoison() do 43 | GenServer.call(__MODULE__, {:get_httpoison}) 44 | end 45 | 46 | def set_httpoison(httpoison) do 47 | GenServer.call(__MODULE__, {:set_httpoison, httpoison}) 48 | end 49 | 50 | def port() do 51 | 2051 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/format/common.ex: -------------------------------------------------------------------------------- 1 | ## 2 | # Copyright (C) 2021 Valentin Lorentz 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License version 3, 6 | # as published by the Free Software Foundation. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU Affero General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU Affero General Public License 14 | # along with this program. If not, see . 15 | ### 16 | 17 | defmodule M51.Format do 18 | # pairs of {irc_code, matrix_html_tag} 19 | # this excludes color ("\x03"), which must be handled with specific code. 20 | @translations [ 21 | {"\x02", "strong"}, 22 | {"\x02", "b"}, 23 | {"\x11", "pre"}, 24 | {"\x11", "code"}, 25 | {"\x1d", "em"}, 26 | {"\x1d", "i"}, 27 | {"\x1e", "del"}, 28 | {"\x1e", "strike"}, 29 | {"\x1f", "u"}, 30 | {"\n", "br"} 31 | ] 32 | 33 | def matrix2irc_map() do 34 | @translations 35 | |> Enum.map(fn {irc, matrix} -> {matrix, irc} end) 36 | |> Map.new() 37 | end 38 | 39 | def irc2matrix_map() do 40 | @translations 41 | |> Map.new() 42 | end 43 | 44 | @doc ~S""" 45 | Converts "org.matrix.custom.html" to IRC formatting. 46 | 47 | ## Examples 48 | 49 | iex> M51.Format.matrix2irc(~s(foo)) 50 | "\x02foo\x02" 51 | 52 | iex> M51.Format.matrix2irc(~s(foo)) 53 | "foo " 54 | 55 | iex> M51.Format.matrix2irc(~s(foo
bar)) 56 | "foo\nbar" 57 | 58 | iex> M51.Format.matrix2irc(~s(foo bar baz)) 59 | "foo \x04FF0000bar\x0399,99 baz" 60 | """ 61 | def matrix2irc(html, homeserver \\ nil) do 62 | tree = :mochiweb_html.parse("" <> html <> "") 63 | 64 | String.trim( 65 | M51.Format.Matrix2Irc.transform(tree, %M51.Format.Matrix2Irc.State{homeserver: homeserver}) 66 | ) 67 | end 68 | 69 | @doc ~S""" 70 | Converts IRC formatting to Matrix's plain text flavor and "org.matrix.custom.html" 71 | 72 | ## Examples 73 | 74 | iex> M51.Format.irc2matrix("\x02foo\x02") 75 | {"*foo*", "foo"} 76 | 77 | iex> M51.Format.irc2matrix("foo https://example.org bar") 78 | {"foo https://example.org bar", ~s(foo https://example.org bar)} 79 | 80 | iex> M51.Format.irc2matrix("foo\nbar") 81 | {"foo\nbar", ~s(foo
bar)} 82 | 83 | iex> M51.Format.irc2matrix("foo \x0304bar") 84 | {"foo bar", ~s(foo bar)} 85 | 86 | """ 87 | def irc2matrix(text, nicklist \\ []) do 88 | stateful_tokens = 89 | (text <> "\x0f") 90 | |> M51.Format.Irc2Matrix.tokenize() 91 | |> Stream.transform(%M51.Format.Irc2Matrix.State{}, fn token, state -> 92 | {new_state, new_token} = M51.Format.Irc2Matrix.update_state(state, token) 93 | {[{state, new_state, new_token}], new_state} 94 | end) 95 | |> Enum.to_list() 96 | 97 | plain_text = 98 | stateful_tokens 99 | |> Enum.map(fn {previous_state, state, token} -> 100 | M51.Format.Irc2Matrix.make_plain_text(previous_state, state, token) 101 | end) 102 | |> Enum.join() 103 | 104 | html_tree = 105 | stateful_tokens 106 | |> Enum.flat_map(fn {previous_state, state, token} -> 107 | M51.Format.Irc2Matrix.make_html(previous_state, state, token, nicklist) 108 | end) 109 | 110 | html = 111 | {"html", [], html_tree} 112 | |> :mochiweb_html.to_html() 113 | |> IO.iodata_to_binary() 114 | 115 | html = Regex.replace(~R((.*\)), html, fn _, content -> content end) 116 | # more compact 117 | html = Regex.replace(~R(
), html, fn _ -> "
" end) 118 | 119 | {plain_text, html} 120 | end 121 | end 122 | -------------------------------------------------------------------------------- /lib/format/irc2matrix.ex: -------------------------------------------------------------------------------- 1 | ## 2 | # Copyright (C) 2021 Valentin Lorentz 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License version 3, 6 | # as published by the Free Software Foundation. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU Affero General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU Affero General Public License 14 | # along with this program. If not, see . 15 | ### 16 | 17 | defmodule M51.Format.Irc2Matrix.State do 18 | defstruct bold: false, 19 | italic: false, 20 | underlined: false, 21 | stroke: false, 22 | monospace: false, 23 | color: {nil, nil} 24 | end 25 | 26 | defmodule M51.Format.Irc2Matrix do 27 | @simple_tags M51.Format.irc2matrix_map() 28 | @chars ["\x0f" | Map.keys(@simple_tags)] 29 | @digits Enum.to_list(?0..?9) 30 | @hexdigits Enum.concat(Enum.to_list(?0..?9), Enum.to_list(?A..?F)) 31 | 32 | # References: 33 | # * https://modern.ircdocs.horse/formatting.html#colors 34 | # * https://modern.ircdocs.horse/formatting.html#colors-16-98 35 | @color2hex { 36 | # 00, white 37 | "#FFFFFF", 38 | # 01, black 39 | "#000000", 40 | # 02, blue 41 | "#0000FF", 42 | # 03, green 43 | "#009300", 44 | # 04, red 45 | "#FF0000", 46 | # 05, brown 47 | "#7F0000", 48 | # 06, magenta 49 | "#9C009C", 50 | # 07, orange 51 | "#FC7F00", 52 | # 08, yellow 53 | "#FFFF00", 54 | # 09, light green 55 | "#00FC00", 56 | # 10, cyan 57 | "#009393", 58 | # 11, light cyan 59 | "#00FFFF", 60 | # 12, light blue 61 | "#0080FF", 62 | # 13, pink 63 | "#FF00FF", 64 | # 14, grey 65 | "#7F7F7F", 66 | # 15, light grey 67 | "#D2D2D2", 68 | # 16 69 | "#470000", 70 | # 17 71 | "#472100", 72 | # 18 73 | "#474700", 74 | # 19 75 | "#324700", 76 | # 20 77 | "#004700", 78 | # 21 79 | "#00472C", 80 | # 22 81 | "#004747", 82 | # 23 83 | "#002747", 84 | # 24 85 | "#000047", 86 | # 25 87 | "#2E0047", 88 | # 26 89 | "#470047", 90 | # 27 91 | "#47002A", 92 | # 28 93 | "#740000", 94 | # 29 95 | "#743A00", 96 | # 30 97 | "#747400", 98 | # 31 99 | "#517400", 100 | # 32 101 | "#007400", 102 | # 33 103 | "#007449", 104 | # 34 105 | "#007474", 106 | # 35 107 | "#004074", 108 | # 36 109 | "#000074", 110 | # 37 111 | "#4B0074", 112 | # 38 113 | "#740074", 114 | # 39 115 | "#740045", 116 | # 40 117 | "#B50000", 118 | # 41 119 | "#B56300", 120 | # 42 121 | "#B5B500", 122 | # 43 123 | "#7DB500", 124 | # 44 125 | "#00B500", 126 | # 45 127 | "#00B571", 128 | # 46 129 | "#00B5B5", 130 | # 47 131 | "#0063B5", 132 | # 48 133 | "#0000B5", 134 | # 49 135 | "#7500B5", 136 | # 50 137 | "#B500B5", 138 | # 51 139 | "#B5006B", 140 | # 52 141 | "#FF0000", 142 | # 53 143 | "#FF8C00", 144 | # 54 145 | "#FFFF00", 146 | # 55 147 | "#B2FF00", 148 | # 56 149 | "#00FF00", 150 | # 57 151 | "#00FFA0", 152 | # 58 153 | "#00FFFF", 154 | # 59 155 | "#008CFF", 156 | # 60 157 | "#0000FF", 158 | # 61 159 | "#A500FF", 160 | # 62 161 | "#FF00FF", 162 | # 63 163 | "#FF0098", 164 | # 64 165 | "#FF5959", 166 | # 65 167 | "#FFB459", 168 | # 66 169 | "#FFFF71", 170 | # 67 171 | "#CFFF60", 172 | # 68 173 | "#6FFF6F", 174 | # 69 175 | "#65FFC9", 176 | # 70 177 | "#6DFFFF", 178 | # 71 179 | "#59B4FF", 180 | # 72 181 | "#5959FF", 182 | # 73 183 | "#C459FF", 184 | # 74 185 | "#FF66FF", 186 | # 75 187 | "#FF59BC", 188 | # 76 189 | "#FF9C9C", 190 | # 77 191 | "#FFD39C", 192 | # 78 193 | "#FFFF9C", 194 | # 79 195 | "#E2FF9C", 196 | # 80 197 | "#9CFF9C", 198 | # 81 199 | "#9CFFDB", 200 | # 82 201 | "#9CFFFF", 202 | # 83 203 | "#9CD3FF", 204 | # 84 205 | "#9C9CFF", 206 | # 85 207 | "#DC9CFF", 208 | # 86 209 | "#FF9CFF", 210 | # 87 211 | "#FF94D3", 212 | # 88 213 | "#000000", 214 | # 89 215 | "#131313", 216 | # 90 217 | "#282828", 218 | # 91 219 | "#363636", 220 | # 92 221 | "#4D4D4D", 222 | # 93 223 | "#656565", 224 | # 94 225 | "#818181", 226 | # 95 227 | "#9F9F9F", 228 | # 96 229 | "#BCBCBC", 230 | # 97 231 | "#E2E2E2", 232 | # 98 233 | "#FFFFFF", 234 | # 99, reset 235 | nil 236 | } 237 | 238 | def tokenize(text) do 239 | text 240 | |> String.to_charlist() 241 | |> do_tokenize(['']) 242 | |> Enum.reverse() 243 | |> Stream.map(fn token -> token |> Enum.reverse() |> to_string() end) 244 | end 245 | 246 | defp do_tokenize([], acc) do 247 | acc 248 | end 249 | 250 | defp do_tokenize([c | tail], acc) when <> in @chars do 251 | # new token 252 | do_tokenize(tail, ['' | [[c] | acc]]) 253 | end 254 | 255 | defp do_tokenize([0x03 | tail], acc) do 256 | # new token, color. 257 | # see https://modern.ircdocs.horse/formatting.html#forms-of-color-codes for details 258 | # on this awful format 259 | {tail, normalized_color} = 260 | case tail do 261 | [a, b, ?,, c, d | tail] 262 | when a in @digits and b in @digits and c in @digits and d in @digits -> 263 | {tail, [a, b, ?,, c, d]} 264 | 265 | [a, b, ?,, c | tail] when a in @digits and b in @digits and c in @digits -> 266 | {tail, [a, b, ?,, ?0, c]} 267 | 268 | [a, b, ?, | tail] when a in @digits and b in @digits -> 269 | {tail, [a, b, ?,]} 270 | 271 | [a, b | tail] when a in @digits and b in @digits -> 272 | {tail, [a, b, ?,]} 273 | 274 | [a, ?,, c, d | tail] when a in @digits and c in @digits and d in @digits -> 275 | {tail, [a, ?,, c, d]} 276 | 277 | [a, ?,, c | tail] when a in @digits and c in @digits -> 278 | {tail, [?0, a, ?,, ?0, c]} 279 | 280 | [a, ?, | tail] when a in @digits -> 281 | {tail, [?0, a, ?,]} 282 | 283 | [a | tail] when a in @digits -> 284 | {tail, [?0, a, ?,]} 285 | 286 | tail -> 287 | {tail, []} 288 | end 289 | 290 | do_tokenize(tail, ['' | [Enum.reverse([0x03 | normalized_color]) | acc]]) 291 | end 292 | 293 | defp do_tokenize([0x04 | tail], acc) do 294 | # new token, hex color. 295 | {tail, normalized_color} = 296 | case tail do 297 | [a, b, c, d, e, f, ?,, g, h, i, j, k, l | tail] 298 | when a in @hexdigits and b in @hexdigits and c in @hexdigits and d in @hexdigits and 299 | e in @hexdigits and f in @hexdigits and g in @hexdigits and h in @hexdigits and 300 | i in @hexdigits and j in @hexdigits and k in @hexdigits and l in @hexdigits -> 301 | {tail, [a, b, c, d, e, f, ?,, g, h, i, j, k, l]} 302 | 303 | [a, b, c, d, e, f, ?, | tail] 304 | when a in @hexdigits and b in @hexdigits and c in @hexdigits and d in @hexdigits and 305 | e in @hexdigits and f in @hexdigits -> 306 | {tail, [a, b, c, d, e, f, ?,]} 307 | 308 | [a, b, c, d, e, f | tail] 309 | when a in @hexdigits and b in @hexdigits and c in @hexdigits and d in @hexdigits and 310 | e in @hexdigits and f in @hexdigits -> 311 | {tail, [a, b, c, d, e, f, ?,]} 312 | 313 | tail -> 314 | {tail, []} 315 | end 316 | 317 | do_tokenize(tail, ['' | [Enum.reverse([0x04 | normalized_color]) | acc]]) 318 | end 319 | 320 | defp do_tokenize([c | tail], [head | acc]) do 321 | # append to the current token 322 | do_tokenize(tail, [[c | head] | acc]) 323 | end 324 | 325 | defp color2hex(color) do 326 | # this is safe because color is computed a string with 2 decimal digits, 327 | # and tuple_size(@color2hex) == 100 328 | elem(@color2hex, color) 329 | end 330 | 331 | def update_state(_state, "\x0f") do 332 | # reset state 333 | {%M51.Format.Irc2Matrix.State{}, ""} 334 | end 335 | 336 | def update_state(state, token) do 337 | key = 338 | case token do 339 | "\x02" -> 340 | :bold 341 | 342 | "\x11" -> 343 | :monospace 344 | 345 | "\x1d" -> 346 | :italic 347 | 348 | "\x1e" -> 349 | :stroke 350 | 351 | "\x1f" -> 352 | :underlined 353 | 354 | <<0x03, a, b, ?,, c, d>> -> 355 | {:color, color2hex((a - ?0) * 10 + (b - ?0)), color2hex((c - ?0) * 10 + (d - ?0))} 356 | 357 | <<0x03, a, b, ?,>> -> 358 | {:color, color2hex((a - ?0) * 10 + (b - ?0)), nil} 359 | 360 | <<0x03>> -> 361 | {:color, nil, nil} 362 | 363 | <<0x04, a, b, c, d, e, f, ?,, g, h, i, j, k, l>> -> 364 | {:color, "#" <> <>, "#" <> <>} 365 | 366 | <<0x04, a, b, c, d, e, f, ?,>> -> 367 | {:color, "#" <> <>, nil} 368 | 369 | <<0x04>> -> 370 | {:color, nil, nil} 371 | 372 | _ -> 373 | nil 374 | end 375 | 376 | case key do 377 | nil -> {state, token} 378 | {:color, fg, bg} -> {state |> Map.put(:color, {fg, bg}), ""} 379 | _ -> {Map.update!(state, key, fn old_value -> !old_value end), ""} 380 | end 381 | end 382 | 383 | def make_plain_text(previous_state, state, token) do 384 | replacement = 385 | [ 386 | {:bold, "*"}, 387 | {:monospace, "`"}, 388 | {:italic, "/"}, 389 | {:underlined, "_"}, 390 | {:stroke, "~"} 391 | ] 392 | |> Enum.map(fn {key, action} -> 393 | if Map.get(previous_state, key) != Map.get(state, key) do 394 | action 395 | else 396 | "" 397 | end 398 | end) 399 | |> Enum.join() 400 | 401 | case replacement do 402 | "" -> token 403 | _ -> replacement 404 | end 405 | end 406 | 407 | defp linkify_urls(text) when is_binary(text) do 408 | # yet another shitty URL detection regexp 409 | [first_part | other_parts] = 410 | Regex.split( 411 | ~r/(mailto:|[a-z][a-z0-9]+:\/\/)\S+(?=\s|>|$)/, 412 | text, 413 | include_captures: true 414 | ) 415 | 416 | other_parts = 417 | other_parts 418 | |> Enum.map_every( 419 | 2, 420 | fn url -> {"a", [{"href", url}], [url]} end 421 | ) 422 | 423 | [first_part | other_parts] 424 | end 425 | 426 | defp linkify_urls({tag, attributes, children}) do 427 | [{tag, attributes, Enum.flat_map(children, &linkify_urls/1)}] 428 | end 429 | 430 | defp linkify_nicks(text, nicklist) when is_binary(text) do 431 | [first_part | other_parts] = 432 | Regex.split( 433 | ~r/\b[a-zA-Z0-9._=\/-]+:\S+\b/, 434 | text, 435 | include_captures: true 436 | ) 437 | 438 | other_parts = 439 | other_parts 440 | |> Enum.map_every( 441 | 2, 442 | fn userid -> 443 | [localpart, _] = String.split(userid, ":", parts: 2) 444 | 445 | if Enum.member?(nicklist, userid) do 446 | {"a", [{"href", "https://matrix.to/#/@#{userid}"}], [localpart]} 447 | else 448 | userid 449 | end 450 | end 451 | ) 452 | 453 | [first_part | other_parts] 454 | end 455 | 456 | defp linkify_nicks({tag, attributes, children}, nicklist) do 457 | [{tag, attributes, Enum.flat_map(children, fn child -> linkify_nicks(child, nicklist) end)}] 458 | end 459 | 460 | def make_html(_previous_state, state, token, nicklist) do 461 | tree = 462 | token 463 | # replace formatting chars 464 | |> String.graphemes() 465 | |> Enum.filter(fn char -> char == "\n" || !Enum.member?(@chars, char) end) 466 | |> Enum.join() 467 | # newlines to
: 468 | |> String.split("\n") 469 | |> Enum.intersperse({"br", [], []}) 470 | # URLs: 471 | |> Enum.flat_map(&linkify_urls/1) 472 | # Nicks: 473 | |> Enum.flat_map(fn subtree -> linkify_nicks(subtree, nicklist) end) 474 | 475 | case tree do 476 | # don't bother formatting empty strings 477 | [""] -> 478 | [] 479 | 480 | _ -> 481 | [ 482 | {:bold, "b"}, 483 | {:monospace, "code"}, 484 | {:italic, "i"}, 485 | {:underlined, "u"}, 486 | {:stroke, "strike"}, 487 | {:color, 488 | fn color, tree -> 489 | case color do 490 | {nil, nil} -> tree 491 | {fg, nil} -> [{"font", [{"data-mx-color", fg}], tree}] 492 | {nil, bg} -> [{"font", [{"data-mx-bg-color", bg}], tree}] 493 | {fg, bg} -> [{"font", [{"data-mx-color", fg}, {"data-mx-bg-color", bg}], tree}] 494 | end 495 | end} 496 | ] 497 | |> Enum.reduce(tree, fn {key, action}, tree -> 498 | case Map.get(state, key) do 499 | true -> [{action, [], tree}] 500 | false -> tree 501 | value -> action.(value, tree) 502 | end 503 | end) 504 | end 505 | end 506 | end 507 | -------------------------------------------------------------------------------- /lib/format/matrix2irc.ex: -------------------------------------------------------------------------------- 1 | ## 2 | # Copyright (C) 2021 Valentin Lorentz 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License version 3, 6 | # as published by the Free Software Foundation. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU Affero General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU Affero General Public License 14 | # along with this program. If not, see . 15 | ### 16 | 17 | defmodule M51.Format.Matrix2Irc.State do 18 | defstruct homeserver: nil, 19 | preserve_whitespace: false, 20 | color: {nil, nil} 21 | end 22 | 23 | defmodule M51.Format.Matrix2Irc do 24 | @simple_tags M51.Format.matrix2irc_map() 25 | 26 | def transform(s, state) when is_binary(s) do 27 | # Pure text; just replace sequences of newlines with a space 28 | # (unless there is already a space) 29 | if state.preserve_whitespace do 30 | s 31 | else 32 | Regex.replace(~r/([\n\r]+ ?[\n\r]*| [\n\r]+)/, s, " ") 33 | end 34 | end 35 | 36 | def transform({"a", attributes, children}, state) do 37 | case attributes |> Map.new() |> Map.get("href") do 38 | nil -> 39 | transform_children(children, state) 40 | 41 | link -> 42 | case Regex.named_captures( 43 | ~r{https://matrix.to/#/((@|%40)(?[^/?]*)|(!|%21)(?[^/#]*)|(#|%23)(?[^/?]*))(/.*)?(\?.*)?}, 44 | link 45 | ) do 46 | %{"userid" => encoded_user_id} when encoded_user_id != "" -> 47 | URI.decode(encoded_user_id) 48 | 49 | %{"roomid" => encoded_room_id} when encoded_room_id != "" -> 50 | "!" <> URI.decode(encoded_room_id) 51 | 52 | %{"roomalias" => encoded_room_alias} when encoded_room_alias != "" -> 53 | "#" <> URI.decode(encoded_room_alias) 54 | 55 | _ -> 56 | text = transform_children(children, state) 57 | 58 | if text == link do 59 | link 60 | else 61 | "#{text} <#{link}>" 62 | end 63 | end 64 | end 65 | end 66 | 67 | def transform({"img", attributes, children}, state) do 68 | attributes = attributes |> Map.new() 69 | src = attributes |> Map.get("src") 70 | alt = attributes |> Map.get("alt") 71 | title = attributes |> Map.get("title") 72 | 73 | alt = 74 | if useless_img_alt?(alt) do 75 | nil 76 | else 77 | alt 78 | end 79 | 80 | case {src, alt, title} do 81 | {nil, nil, nil} -> transform_children(children, state) 82 | {nil, nil, title} -> title 83 | {nil, alt, _} -> alt 84 | {link, nil, nil} -> format_url(link, state.homeserver) 85 | {link, nil, title} -> "#{title} <#{format_url(link, state.homeserver)}>" 86 | {link, alt, _} -> "#{alt} <#{format_url(link, state.homeserver)}>" 87 | end 88 | end 89 | 90 | def transform({"br", _, []}, _state) do 91 | "\n" 92 | end 93 | 94 | def transform({tag, _, children}, state) when tag in ["ol", "ul"] do 95 | "\n" <> transform_children(children, state) 96 | end 97 | 98 | def transform({"li", _, children}, state) do 99 | "* " <> transform_children(children, state) <> "\n" 100 | end 101 | 102 | def transform({tag, attributes, children}, state) when tag in ["font", "span"] do 103 | attributes = Map.new(attributes) 104 | fg = Map.get(attributes, "data-mx-color") 105 | bg = Map.get(attributes, "data-mx-bg-color") 106 | 107 | case {fg, bg} do 108 | {nil, nil} -> 109 | transform_children(children, state) 110 | 111 | _ -> 112 | fg = fg && String.trim_leading(fg, "#") 113 | bg = bg && String.trim_leading(bg, "#") 114 | 115 | restored_colors = get_color_code(state.color) 116 | 117 | state = %M51.Format.Matrix2Irc.State{state | color: {fg, bg}} 118 | 119 | get_color_code({fg, bg}) <> 120 | transform_children(children, state) <> restored_colors 121 | end 122 | end 123 | 124 | def transform({"mx-reply", _, _}, _color) do 125 | "" 126 | end 127 | 128 | def transform({tag, _, children}, state) do 129 | char = Map.get(@simple_tags, tag, "") 130 | children = paragraph_to_newline(children, []) 131 | 132 | state = 133 | case tag do 134 | "pre" -> %M51.Format.Matrix2Irc.State{state | preserve_whitespace: true} 135 | _ -> state 136 | end 137 | 138 | transform_children(children, state, char) 139 | end 140 | 141 | def get_color_code({fg, bg}) do 142 | case {fg, bg} do 143 | # reset 144 | {nil, nil} -> "\x0399,99" 145 | {fg, nil} -> "\x04#{fg}" 146 | # set both fg and bg, then reset fg 147 | {nil, bg} -> "\x04000000,#{bg}\x0399" 148 | {fg, bg} -> "\x04#{fg},#{bg}" 149 | end 150 | end 151 | 152 | defp transform_children(children, state, char \\ "") do 153 | Stream.concat([ 154 | [char], 155 | Stream.map(children, fn child -> transform(child, state) end), 156 | [char] 157 | ]) 158 | |> Enum.join() 159 | end 160 | 161 | defp paragraph_to_newline([], acc) do 162 | Enum.reverse(acc) 163 | end 164 | 165 | defp paragraph_to_newline([{"p", _, children1}, {"p", _, children2} | tail], acc) do 166 | paragraph_to_newline(tail, [ 167 | {"span", [], children2}, 168 | {"br", [], []}, 169 | {"span", [], children1} 170 | | acc 171 | ]) 172 | end 173 | 174 | defp paragraph_to_newline([{"p", _, text} | tail], acc) do 175 | paragraph_to_newline(tail, [ 176 | {"br", [], []}, 177 | {"span", [], text}, 178 | {"br", [], []} 179 | | acc 180 | ]) 181 | end 182 | 183 | defp paragraph_to_newline([head | tail], acc) do 184 | paragraph_to_newline(tail, [head | acc]) 185 | end 186 | 187 | @doc "Transforms a mxc:// \"URL\" into an actually usable URL." 188 | def format_url(url, homeserver \\ nil, filename \\ nil) do 189 | case URI.parse(url) do 190 | %{scheme: "mxc", host: host, path: path} -> 191 | # prefer the homeserver when available, it is more reliable than arbitrary 192 | # hosts chosen by message senders 193 | homeserver = homeserver || host 194 | 195 | base_url = M51.MatrixClient.Client.get_base_url(homeserver, M51.Config.httpoison()) 196 | 197 | case filename do 198 | nil -> 199 | "#{base_url}/_matrix/media/r0/download/#{urlquote(host)}#{path}" 200 | 201 | _ -> 202 | "#{base_url}/_matrix/media/r0/download/#{urlquote(host)}#{path}/#{urlquote(filename)}" 203 | end 204 | 205 | _ -> 206 | url 207 | end 208 | end 209 | 210 | @doc """ 211 | Returns whether the given string is a useless alt that should not 212 | be displayed (eg. a stock filename). 213 | """ 214 | def useless_img_alt?(s) do 215 | s == nil or String.match?(s, ~r/(image|unknown)\.(png|jpe?g|gif)/i) 216 | end 217 | 218 | defp urlquote(s) do 219 | M51.Matrix.Utils.urlquote(s) 220 | end 221 | end 222 | -------------------------------------------------------------------------------- /lib/irc/command.ex: -------------------------------------------------------------------------------- 1 | ## 2 | # Copyright (C) 2021-2023 Valentin Lorentz 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License version 3, 6 | # as published by the Free Software Foundation. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU Affero General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU Affero General Public License 14 | # along with this program. If not, see . 15 | ### 16 | 17 | defmodule M51.Irc.Command do 18 | @enforce_keys [:command, :params] 19 | defstruct [{:tags, %{}}, :source, :command, :params, {:is_echo, false}] 20 | 21 | @doc ~S""" 22 | Parses an IRC line into the `M51.Irc.Command` structure. 23 | 24 | ## Examples 25 | 26 | iex> M51.Irc.Command.parse("PRIVMSG #chan :hello\r\n") 27 | {:ok, 28 | %M51.Irc.Command{ 29 | command: "PRIVMSG", 30 | params: ["#chan", "hello"] 31 | }} 32 | 33 | iex> M51.Irc.Command.parse("@+typing=active TAGMSG #chan\r\n") 34 | {:ok, 35 | %M51.Irc.Command{ 36 | tags: %{"+typing" => "active"}, 37 | command: "TAGMSG", 38 | params: ["#chan"] 39 | }} 40 | 41 | iex> M51.Irc.Command.parse("@msgid=foo :nick!user@host PRIVMSG #chan :hello\r\n") 42 | {:ok, 43 | %M51.Irc.Command{ 44 | tags: %{"msgid" => "foo"}, 45 | source: "nick!user@host", 46 | command: "PRIVMSG", 47 | params: ["#chan", "hello"] 48 | }} 49 | """ 50 | def parse(line) do 51 | line = Regex.replace(~r/[\r\n]+/, line, "") 52 | 53 | # IRCv3 message-tags https://ircv3.net/specs/extensions/message-tags 54 | {tags, rfc1459_line} = 55 | if String.starts_with?(line, "@") do 56 | [tags | [rest]] = Regex.split(~r/ +/, line, parts: 2) 57 | {_, tags} = String.split_at(tags, 1) 58 | {Map.new(Regex.split(~r/;/, tags), fn s -> M51.Irc.Command.parse_tag(s) end), rest} 59 | else 60 | {%{}, line} 61 | end 62 | 63 | # Tokenize 64 | tokens = 65 | case Regex.split(~r/ +:/, rfc1459_line, parts: 2) do 66 | [main] -> Regex.split(~r/ +/, main) 67 | [main, trailing] -> Regex.split(~r/ +/, main) ++ [trailing] 68 | end 69 | 70 | # aka "prefix" or "source" 71 | {source, tokens} = 72 | if String.starts_with?(hd(tokens), ":") do 73 | [source | rest] = tokens 74 | {_, source} = String.split_at(source, 1) 75 | {source, rest} 76 | else 77 | {nil, tokens} 78 | end 79 | 80 | [command | params] = tokens 81 | 82 | parsed_line = %__MODULE__{ 83 | tags: tags, 84 | source: source, 85 | command: String.upcase(command), 86 | params: params 87 | } 88 | 89 | {:ok, parsed_line} 90 | end 91 | 92 | def parse_tag(s) do 93 | captures = Regex.named_captures(~r/^(?[a-zA-Z0-9\/+.-]+)(=(?.*))?$/U, s) 94 | %{"key" => key, "value" => value} = captures 95 | 96 | {key, 97 | case value do 98 | nil -> "" 99 | _ -> unescape_tag_value(value) 100 | end} 101 | end 102 | 103 | @doc ~S""" 104 | Formats an IRC line from the `M51.Irc.Command` structure. 105 | 106 | ## Examples 107 | 108 | iex> M51.Irc.Command.format(%M51.Irc.Command{ 109 | ...> command: "PRIVMSG", 110 | ...> params: ["#chan", "hello"] 111 | ...> }) 112 | "PRIVMSG #chan :hello\r\n" 113 | 114 | iex> M51.Irc.Command.format(%M51.Irc.Command{ 115 | ...> tags: %{"+typing" => "active"}, 116 | ...> command: "TAGMSG", 117 | ...> params: ["#chan"] 118 | ...> }) 119 | "@+typing=active TAGMSG :#chan\r\n" 120 | 121 | iex> M51.Irc.Command.format(%M51.Irc.Command{ 122 | ...> tags: %{"msgid" => "foo"}, 123 | ...> source: "nick!user@host", 124 | ...> command: "PRIVMSG", 125 | ...> params: ["#chan", "hello"] 126 | ...> }) 127 | "@msgid=foo :nick!user@host PRIVMSG #chan :hello\r\n" 128 | """ 129 | def format(command) do 130 | reversed_params = 131 | case Enum.reverse(command.params) do 132 | # Prepend trailing with ":" 133 | [head | tail] -> [":" <> head | tail] 134 | [] -> [] 135 | end 136 | 137 | tokens = [command.command | Enum.reverse(reversed_params)] 138 | 139 | tokens = 140 | case command.source do 141 | nil -> tokens 142 | "" -> tokens 143 | _ -> [":" <> command.source | tokens] 144 | end 145 | 146 | tokens = 147 | case command.tags do 148 | nil -> 149 | tokens 150 | 151 | tags when map_size(tags) == 0 -> 152 | tokens 153 | 154 | _ -> 155 | [ 156 | "@" <> 157 | Enum.join( 158 | Enum.map(Map.to_list(command.tags), fn {key, value} -> 159 | case value do 160 | nil -> key 161 | _ -> key <> "=" <> escape_tag_value(value) 162 | end 163 | end), 164 | ";" 165 | ) 166 | | tokens 167 | ] 168 | end 169 | 170 | # Sanitize tokens, just in case (None of these should be generated from well-formed 171 | # Matrix events; but servers do not validate them). 172 | # So instead of exhaustively sanitizing in every part of the code, we do it here 173 | tokens = 174 | tokens 175 | |> Enum.reverse() 176 | |> Enum.with_index() 177 | |> Enum.map(fn {token, i} -> 178 | Regex.replace(~r/[\0\r\n ]/, token, fn <> -> 179 | case char do 180 | 0 -> 181 | "\\0" 182 | 183 | ?\r -> 184 | "\\r" 185 | 186 | ?\n -> 187 | "\\n" 188 | 189 | ?\s -> 190 | if i == 0 && String.starts_with?(token, ":") do 191 | # trailing param; no need to escape spaces 192 | " " 193 | else 194 | "\\s" 195 | end 196 | end 197 | end) 198 | end) 199 | |> Enum.reverse() 200 | 201 | Enum.join(tokens, " ") <> "\r\n" 202 | end 203 | 204 | # https://ircv3.net/specs/extensions/message-tags#escaping-values 205 | @escapes [ 206 | {";", "\\:"}, 207 | {" ", "\\s"}, 208 | {"\\", "\\\\"}, 209 | {"\r", "\\r"}, 210 | {"\n", "\\n"} 211 | ] 212 | @escape_map Map.new(@escapes) 213 | @escaped_re Regex.compile!( 214 | "[" <> 215 | (@escapes 216 | |> Enum.map(fn {char, _escape} -> Regex.escape(char) end) 217 | |> Enum.join()) <> "]" 218 | ) 219 | @unescape_map Map.new(Enum.map(@escapes, fn {char, escape} -> {escape, char} end)) 220 | @unescaped_re Regex.compile!( 221 | "(" <> 222 | (@escapes 223 | |> Enum.map(fn {_char, escape} -> Regex.escape(escape) end) 224 | |> Enum.join("|")) <> ")" 225 | ) 226 | 227 | defp escape_tag_value(value) do 228 | Regex.replace(@escaped_re, value, fn char -> Map.get(@escape_map, char) end) 229 | end 230 | 231 | defp unescape_tag_value(value) do 232 | Regex.replace(@unescaped_re, value, fn escape -> Map.get(@unescape_map, escape) end) 233 | end 234 | 235 | @doc ~S""" 236 | Rewrites the command to remove features the IRC client does not support 237 | 238 | # Example 239 | 240 | iex> cmd = %M51.Irc.Command{ 241 | ...> tags: %{"account" => "abcd"}, 242 | ...> command: "JOIN", 243 | ...> params: ["#foo", "account", "realname"] 244 | ...> } 245 | iex> M51.Irc.Command.downgrade(cmd, [:extended_join]) 246 | %M51.Irc.Command{ 247 | tags: %{}, 248 | command: "JOIN", 249 | params: ["#foo", "account", "realname"] 250 | } 251 | 252 | """ 253 | def downgrade(command, capabilities) do 254 | original_tags = command.tags 255 | 256 | # downgrade echo-message 257 | command = 258 | if Enum.member?(capabilities, :echo_message) do 259 | command 260 | else 261 | case command do 262 | %{is_echo: true, command: "PRIVMSG"} -> nil 263 | %{is_echo: true, command: "NOTICE"} -> nil 264 | %{is_echo: true, command: "TAGMSG"} -> nil 265 | _ -> command 266 | end 267 | end 268 | 269 | # downgrade tags 270 | command = 271 | if command == nil do 272 | command 273 | else 274 | tags = 275 | command.tags 276 | |> Map.to_list() 277 | |> Enum.filter(fn {key, _value} -> 278 | if String.starts_with?(key, "+") do 279 | Enum.member?(capabilities, :message_tags) 280 | else 281 | case key do 282 | "account" -> Enum.member?(capabilities, :account_tag) 283 | "batch" -> Enum.member?(capabilities, :batch) 284 | "label" -> Enum.member?(capabilities, :labeled_response) 285 | "draft/multiline-concat" -> Enum.member?(capabilities, :multiline) 286 | "msgid" -> Enum.member?(capabilities, :message_tags) 287 | "time" -> Enum.member?(capabilities, :server_time) 288 | _ -> false 289 | end 290 | end 291 | end) 292 | |> Enum.filter(&(&1 != nil)) 293 | |> Map.new() 294 | 295 | %M51.Irc.Command{command | tags: tags} 296 | end 297 | 298 | # downgrade commands 299 | command = 300 | case command do 301 | %{command: "JOIN", params: params} -> 302 | [channel, _account_name, _real_name] = params 303 | 304 | if Enum.member?(capabilities, :extended_join) do 305 | command 306 | else 307 | %{command | params: [channel]} 308 | end 309 | 310 | %{command: "ACK"} -> 311 | if Map.has_key?(command.tags, "label") do 312 | command 313 | else 314 | nil 315 | end 316 | 317 | %{command: "BATCH"} -> 318 | if Enum.member?(capabilities, :batch) do 319 | command 320 | else 321 | nil 322 | end 323 | 324 | %{command: "REDACT"} -> 325 | if Enum.member?(capabilities, :message_redaction) do 326 | command 327 | else 328 | sender = Map.get(original_tags, "account") 329 | 330 | display_name = 331 | case Map.get(original_tags, "+draft/display-name", nil) do 332 | dn when is_binary(dn) -> " (#{dn})" 333 | _ -> "" 334 | end 335 | 336 | tags = Map.drop(command.tags, ["+draft/display-name", "account"]) 337 | 338 | command = 339 | case command do 340 | %{params: [channel, msgid, reason]} -> 341 | %M51.Irc.Command{ 342 | tags: Map.put(tags, "+draft/reply", msgid), 343 | source: "server.", 344 | command: "NOTICE", 345 | params: [channel, "#{sender}#{display_name} deleted an event: #{reason}"] 346 | } 347 | 348 | %{params: [channel, msgid]} -> 349 | %M51.Irc.Command{ 350 | tags: Map.put(tags, "+draft/reply", msgid), 351 | source: "server.", 352 | command: "NOTICE", 353 | params: [channel, "#{sender}#{display_name} deleted an event"] 354 | } 355 | 356 | _ -> 357 | # shouldn't happen 358 | nil 359 | end 360 | 361 | # run downgrade() recursively in order to drop the new tags if necessary 362 | downgrade(command, capabilities) 363 | end 364 | 365 | %{command: "TAGMSG"} -> 366 | if Enum.member?(capabilities, :message_tags) do 367 | command 368 | else 369 | nil 370 | end 371 | 372 | %{command: "353", params: params} -> 373 | if Enum.member?(capabilities, :userhost_in_names) do 374 | command 375 | else 376 | [client, symbol, channel, userlist] = params 377 | 378 | nicklist = 379 | userlist 380 | |> String.split() 381 | |> Enum.map(fn item -> 382 | # item is a NUH, possibly with one (or more) prefix char. 383 | [nick | _] = String.split(item, "!") 384 | nick 385 | end) 386 | |> Enum.join(" ") 387 | 388 | %M51.Irc.Command{command | params: [client, symbol, channel, nicklist]} 389 | end 390 | 391 | _ -> 392 | command 393 | end 394 | 395 | command 396 | end 397 | 398 | @doc ~S""" 399 | Splits the line so that it does not exceed the protocol's 512 bytes limit 400 | in the non-tags part. 401 | 402 | ## Examples 403 | 404 | iex> M51.Irc.Command.linewrap(%M51.Irc.Command{ 405 | ...> command: "PRIVMSG", 406 | ...> params: ["#chan", "hello world"] 407 | ...> }, 25) 408 | [ 409 | %M51.Irc.Command{ 410 | tags: %{}, 411 | source: nil, 412 | command: "PRIVMSG", 413 | params: ["#chan", "hello "] 414 | }, 415 | %M51.Irc.Command{ 416 | tags: %{"draft/multiline-concat" => nil}, 417 | source: nil, 418 | command: "PRIVMSG", 419 | params: ["#chan", "world"] 420 | } 421 | ] 422 | 423 | """ 424 | def linewrap(command, nbytes \\ 512) do 425 | case command do 426 | %M51.Irc.Command{command: "PRIVMSG", params: [target, text]} -> 427 | do_linewrap(command, nbytes, target, text) 428 | 429 | %M51.Irc.Command{command: "NOTICE", params: [target, text]} -> 430 | do_linewrap(command, nbytes, target, text) 431 | 432 | _ -> 433 | command 434 | end 435 | end 436 | 437 | defp do_linewrap(command, nbytes, target, text) do 438 | overhead = byte_size(M51.Irc.Command.format(%{command | tags: %{}, params: [target, ""]})) 439 | 440 | case M51.Irc.WordWrap.split(text, nbytes - overhead) do 441 | [] -> 442 | # line is empty, send it as-is. 443 | [command] 444 | 445 | [_line] -> 446 | # no change needed 447 | [command] 448 | 449 | [first_line | next_lines] -> 450 | make_command = fn text -> %{command | params: [target, text]} end 451 | 452 | [ 453 | make_command.(first_line) 454 | | Enum.map(next_lines, fn line -> 455 | cmd = make_command.(line) 456 | %{cmd | tags: Map.put(cmd.tags, "draft/multiline-concat", nil)} 457 | end) 458 | ] 459 | end 460 | end 461 | end 462 | -------------------------------------------------------------------------------- /lib/irc/word_wrap.ex: -------------------------------------------------------------------------------- 1 | ## 2 | # Copyright (C) 2021 Valentin Lorentz 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License version 3, 6 | # as published by the Free Software Foundation. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU Affero General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU Affero General Public License 14 | # along with this program. If not, see . 15 | ### 16 | 17 | defmodule M51.Irc.WordWrap do 18 | @doc ~S""" 19 | Splits text into lines not larger than the specified number of bytes. 20 | 21 | The resulting list contains all characters in the original output, 22 | even spaces. 23 | 24 | The input is assumed to be free of newline characters. 25 | 26 | graphemes are never split between lines, even if they are larger than 27 | the specified number of bytes. 28 | 29 | ## Examples 30 | 31 | iex> M51.Irc.WordWrap.split("foo bar baz", 20) 32 | ["foo bar baz"] 33 | 34 | iex> M51.Irc.WordWrap.split("foo bar baz", 10) 35 | ["foo bar ", "baz"] 36 | 37 | iex> M51.Irc.WordWrap.split("foo bar baz", 4) 38 | ["foo ", "bar ", "baz"] 39 | 40 | iex> M51.Irc.WordWrap.split("foo bar baz", 3) 41 | ["foo", " ", "bar", " ", "baz"] 42 | 43 | iex> M51.Irc.WordWrap.split("abcdefghijk", 10) 44 | ["abcdefghij", "k"] 45 | 46 | iex> M51.Irc.WordWrap.split("abcdefghijk", 4) 47 | ["abcd", "efgh", "ijk"] 48 | 49 | iex> M51.Irc.WordWrap.split("réellement", 2) 50 | ["r", "é", "el", "le", "me", "nt"] 51 | 52 | """ 53 | def split(text, nbytes) do 54 | if byte_size(text) <= nbytes do 55 | # Shortcut for small strings 56 | [text] 57 | else 58 | # Split after each whitespace 59 | Regex.split(~r/((?<=\s)|(?=\s))/, text) 60 | |> join_tokens(nbytes) 61 | end 62 | end 63 | 64 | @doc """ 65 | Joins a list of strings (token) into lines. This is equivalent to `|> Enum.join(" ") |> split()`, 66 | """ 67 | def join_tokens(tokens, nbytes) do 68 | tokens 69 | |> join_reverse_tokens(0, [], [], nbytes) 70 | |> Enum.reverse() 71 | end 72 | 73 | defp join_reverse_tokens([], _current_size, reversed_current_line, other_lines, _nbytes) do 74 | [Enum.join(Enum.reverse(reversed_current_line)) | other_lines] 75 | end 76 | 77 | defp join_reverse_tokens( 78 | [token | next_tokens], 79 | current_size, 80 | reversed_current_line, 81 | other_lines, 82 | nbytes 83 | ) do 84 | token_size = byte_size(token) 85 | 86 | cond do 87 | current_size + token_size <= nbytes -> 88 | # The token fits in the current line. Add it. 89 | join_reverse_tokens( 90 | next_tokens, 91 | current_size + token_size, 92 | [token | reversed_current_line], 93 | other_lines, 94 | nbytes 95 | ) 96 | 97 | token_size > nbytes -> 98 | # The token is larger than the max line size. Split it. 99 | graphemes = String.graphemes(token) 100 | 101 | {first_part, rest} = split_graphemes_at(graphemes, nbytes - current_size) 102 | 103 | {middle_parts, last_part} = split_graphemes(rest, nbytes) 104 | 105 | join_reverse_tokens( 106 | next_tokens, 107 | byte_size(last_part), 108 | [last_part], 109 | Enum.reverse(middle_parts) ++ 110 | [Enum.join(Enum.reverse([first_part | reversed_current_line]))] ++ other_lines, 111 | nbytes 112 | ) 113 | 114 | true -> 115 | # It doesn't. Flush the current line, and create a new one. 116 | join_reverse_tokens( 117 | next_tokens, 118 | token_size, 119 | [token], 120 | [Enum.join(Enum.reverse(reversed_current_line)) | other_lines], 121 | nbytes 122 | ) 123 | end 124 | end 125 | 126 | @doc """ 127 | Splits an enumerable of graphemes into {left, right} just before 128 | the specified number of bytes, so that 'left' is the maximal substring 129 | of 'graphemes' smaller than 'nbytes' without splitting a grapheme. 130 | 131 | ## Examples 132 | 133 | iex> M51.Irc.WordWrap.split_graphemes_at(String.graphemes("foobar"), 2) 134 | {["f", "o"], ["o", "b", "a", "r"]} 135 | 136 | iex> M51.Irc.WordWrap.split_graphemes_at(String.graphemes("réel"), 2) 137 | {["r"], ["é", "e", "l"]} 138 | """ 139 | def split_graphemes_at(graphemes, nbytes) do 140 | {first_part, rest} = split_reverse_graphemes_at(graphemes, [], nbytes) 141 | {Enum.reverse(first_part), rest} 142 | end 143 | 144 | defp split_reverse_graphemes_at([], acc, _nbytes) do 145 | {acc, []} 146 | end 147 | 148 | defp split_reverse_graphemes_at([first_grapheme | other_graphemes] = graphemes, acc, nbytes) do 149 | first_grapheme_size = byte_size(first_grapheme) 150 | 151 | if first_grapheme_size <= nbytes do 152 | split_reverse_graphemes_at( 153 | other_graphemes, 154 | [first_grapheme | acc], 155 | nbytes - first_grapheme_size 156 | ) 157 | else 158 | {acc, graphemes} 159 | end 160 | end 161 | 162 | @doc """ 163 | Splits an enumerable of graphemes into a list of strings such that all item 164 | in the list is smaller than 'nbytes', without splitting a grapheme. 165 | 166 | The last item of the list it returned separately, as it may be significantly 167 | smaller than the byte limit. 168 | 169 | ## Examples 170 | 171 | iex> M51.Irc.WordWrap.split_graphemes(String.graphemes("foobar"), 2) 172 | {["fo", "ob"], "ar"} 173 | 174 | iex> M51.Irc.WordWrap.split_graphemes(String.graphemes("réellement"), 2) 175 | {["r", "é", "el", "le", "me"], "nt"} 176 | 177 | iex> M51.Irc.WordWrap.split_graphemes(String.graphemes("réel"), 1) 178 | {["r", "é", "e"], "l"} 179 | """ 180 | 181 | def split_graphemes(graphemes, nbytes) do 182 | case split_reverse_graphemes(graphemes, [], nbytes) do 183 | [] -> {[], ""} 184 | [last_part | rest] -> {Enum.reverse(rest), last_part} 185 | end 186 | end 187 | 188 | defp split_reverse_graphemes([], acc, _nbytes) do 189 | acc 190 | end 191 | 192 | defp split_reverse_graphemes(graphemes, acc, nbytes) do 193 | {first_part, rest} = split_reverse_graphemes_at(graphemes, [], nbytes) 194 | 195 | case first_part do 196 | [] -> 197 | # grapheme does not fit, give up. (this will send an oversized message 198 | # to the IRC client; let's hope it can handle it.) 199 | case rest do 200 | [] -> split_reverse_graphemes([], acc, nbytes) 201 | [head | tail] -> split_reverse_graphemes(tail, [head | acc], nbytes) 202 | end 203 | 204 | _ -> 205 | split_reverse_graphemes(rest, [Enum.join(Enum.reverse(first_part)) | acc], nbytes) 206 | end 207 | end 208 | end 209 | -------------------------------------------------------------------------------- /lib/irc_conn/reader.ex: -------------------------------------------------------------------------------- 1 | ## 2 | # Copyright (C) 2021 Valentin Lorentz 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License version 3, 6 | # as published by the Free Software Foundation. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU Affero General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU Affero General Public License 14 | # along with this program. If not, see . 15 | ### 16 | 17 | defmodule M51.IrcConn.Reader do 18 | @moduledoc """ 19 | Reads from a client, and sends commands to the handler. 20 | """ 21 | 22 | use Task, restart: :permanent 23 | 24 | require Logger 25 | 26 | def start_link(args) do 27 | Task.start_link(__MODULE__, :serve, [args]) 28 | end 29 | 30 | def serve(args) do 31 | {supervisor, sock} = args 32 | loop_serve(supervisor, sock) 33 | end 34 | 35 | defp loop_serve(supervisor, sock) do 36 | case :gen_tcp.recv(sock, 0) do 37 | {:ok, line} -> 38 | Logger.debug("IRC C->S #{Regex.replace(~r/[\r\n]/, line, "")}") 39 | {:ok, command} = M51.Irc.Command.parse(line) 40 | Registry.send({M51.Registry, {supervisor, :irc_handler}}, command) 41 | loop_serve(supervisor, sock) 42 | 43 | {:error, :closed} -> 44 | Supervisor.stop(supervisor) 45 | 46 | {:error, :einval} -> 47 | # happens sometimes when Goguma tries to connect, for some reason 48 | Supervisor.stop(supervisor) 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/irc_conn/state.ex: -------------------------------------------------------------------------------- 1 | ## 2 | # Copyright (C) 2021 Valentin Lorentz 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License version 3, 6 | # as published by the Free Software Foundation. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU Affero General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU Affero General Public License 14 | # along with this program. If not, see . 15 | ### 16 | 17 | defmodule M51.IrcConn.State do 18 | @moduledoc """ 19 | Stores the state of an open IRC connection. 20 | """ 21 | defstruct [:sup_pid, :registered, :nick, :gecos, :capabilities, :batches] 22 | 23 | use Agent 24 | 25 | def start_link(args) do 26 | {sup_pid} = args 27 | 28 | Agent.start_link( 29 | fn -> 30 | %M51.IrcConn.State{ 31 | sup_pid: sup_pid, 32 | registered: false, 33 | nick: nil, 34 | gecos: nil, 35 | capabilities: [], 36 | # %{id => {type, args, reversed_messages}} 37 | batches: Map.new() 38 | } 39 | end, 40 | name: {:via, Registry, {M51.Registry, {sup_pid, :irc_state}}} 41 | ) 42 | end 43 | 44 | def dump_state(pid) do 45 | Agent.get(pid, fn state -> state end) 46 | end 47 | 48 | @doc """ 49 | Return {local_name, hostname}. Must be joined with ":" to get the actual nick. 50 | """ 51 | def nick(pid) do 52 | Agent.get(pid, fn state -> state.nick end) 53 | end 54 | 55 | def set_nick(pid, nick) do 56 | Agent.update(pid, fn state -> %{state | nick: nick} end) 57 | end 58 | 59 | def registered(pid) do 60 | Agent.get(pid, fn state -> state.registered end) 61 | end 62 | 63 | def set_registered(pid) do 64 | Agent.update(pid, fn state -> %{state | registered: true} end) 65 | end 66 | 67 | def gecos(pid) do 68 | Agent.get(pid, fn state -> state.gecos end) 69 | end 70 | 71 | def set_gecos(pid, gecos) do 72 | Agent.update(pid, fn state -> %{state | gecos: gecos} end) 73 | end 74 | 75 | def capabilities(pid) do 76 | Agent.get(pid, fn state -> state.capabilities end) 77 | end 78 | 79 | def add_capabilities(pid, new_capabilities) do 80 | Agent.update(pid, fn state -> 81 | %{state | capabilities: new_capabilities ++ state.capabilities} 82 | end) 83 | end 84 | 85 | def batch(pid, id) do 86 | Agent.get(pid, fn state -> Map.get(state.batches, id) end) 87 | end 88 | 89 | @doc """ 90 | Creates a buffer for a client-initiated batch. 91 | 92 | https://ircv3.net/specs/extensions/batch 93 | https://github.com/ircv3/ircv3-specifications/pull/454 94 | """ 95 | def create_batch(pid, reference_tag, opening_command) do 96 | Agent.update(pid, fn state -> 97 | %{state | batches: state.batches |> Map.put(reference_tag, {opening_command, []})} 98 | end) 99 | end 100 | 101 | def add_batch_command(pid, reference_tag, command) do 102 | Agent.update(pid, fn state -> 103 | %{ 104 | state 105 | | batches: 106 | state.batches 107 | |> Map.update!(reference_tag, fn batch -> 108 | {opening_command, reversed_commands} = batch 109 | {opening_command, [command | reversed_commands]} 110 | end) 111 | } 112 | end) 113 | end 114 | 115 | @doc """ 116 | Removes a batch and returns it as {opening_command, messages} 117 | """ 118 | def pop_batch(pid, reference_tag) do 119 | Agent.get_and_update(pid, fn state -> 120 | {batch, batches} = Map.pop(state.batches, reference_tag) 121 | state = %{state | batches: batches} 122 | 123 | # reverse commands so they are in chronological order 124 | {opening_command, reversed_commands} = batch 125 | batch = {opening_command, Enum.reverse(reversed_commands)} 126 | 127 | {batch, state} 128 | end) 129 | end 130 | end 131 | -------------------------------------------------------------------------------- /lib/irc_conn/supervisor.ex: -------------------------------------------------------------------------------- 1 | ## 2 | # Copyright (C) 2021 Valentin Lorentz 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License version 3, 6 | # as published by the Free Software Foundation. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU Affero General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU Affero General Public License 14 | # along with this program. If not, see . 15 | ### 16 | 17 | defmodule M51.IrcConn.Supervisor do 18 | @moduledoc """ 19 | Supervises the connection with a single IRC client: M51.IrcConn.State 20 | to store its state, and M51.IrcConn.Writer and M51.IrcConn.Reader 21 | to interact with it. 22 | """ 23 | 24 | use Supervisor 25 | 26 | def start_link(args) do 27 | Supervisor.start_link(__MODULE__, args) 28 | end 29 | 30 | @impl true 31 | def init(args) do 32 | {sock} = args 33 | 34 | children = [ 35 | {M51.IrcConn.State, {self()}}, 36 | {M51.IrcConn.Writer, {self(), sock}}, 37 | {M51.MatrixClient.State, {self()}}, 38 | {M51.MatrixClient.Client, {self(), []}}, 39 | {M51.MatrixClient.Sender, {self()}}, 40 | {M51.MatrixClient.Poller, {self()}}, 41 | {M51.MatrixClient.RoomSupervisor, {self()}}, 42 | {M51.IrcConn.Handler, {self()}}, 43 | {M51.IrcConn.Reader, {self(), sock}} 44 | ] 45 | 46 | Supervisor.init(children, strategy: :one_for_one) 47 | end 48 | 49 | @doc "Returns the pid of the M51.IrcConn.State child." 50 | def state(sup) do 51 | {:via, Registry, {M51.Registry, {sup, :irc_state}}} 52 | end 53 | 54 | @doc "Returns the pid of the M51.IrcConn.Writer child." 55 | def writer(sup) do 56 | {:via, Registry, {M51.Registry, {sup, :irc_writer}}} 57 | end 58 | 59 | @doc "Returns the pid of the M51.MatrixClient.Client child." 60 | def matrix_client(sup) do 61 | {:via, Registry, {M51.Registry, {sup, :matrix_client}}} 62 | end 63 | 64 | @doc "Returns the pid of the M51.MatrixClient.Sender child." 65 | def matrix_sender(sup) do 66 | {:via, Registry, {M51.Registry, {sup, :matrix_sender}}} 67 | end 68 | 69 | @doc "Returns the pid of the M51.MatrixClient.State child." 70 | def matrix_state(sup) do 71 | {:via, Registry, {M51.Registry, {sup, :matrix_state}}} 72 | end 73 | 74 | @doc "Returns the pid of the M51.MatrixClient.Poller child." 75 | def matrix_poller(sup) do 76 | {:via, Registry, {M51.Registry, {sup, :matrix_poller}}} 77 | end 78 | 79 | @doc "Returns the pid of the M51.IrcConn.Handler child." 80 | def matrix_room_supervisor(sup) do 81 | {:via, Registry, {M51.Registry, {sup, :matrix_room_supervisor}}} 82 | end 83 | 84 | @doc "Returns the pid of the M51.IrcConn.Handler child." 85 | def handler(sup) do 86 | {:via, Registry, {M51.Registry, {sup, :irc_handler}}} 87 | end 88 | 89 | @doc "Returns the pid of the M51.IrcConn.Reader child." 90 | def reader(sup) do 91 | {:via, Registry, {M51.Registry, {sup, :irc_reader}}} 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /lib/irc_conn/writer.ex: -------------------------------------------------------------------------------- 1 | ## 2 | # Copyright (C) 2021 Valentin Lorentz 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License version 3, 6 | # as published by the Free Software Foundation. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU Affero General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU Affero General Public License 14 | # along with this program. If not, see . 15 | ### 16 | 17 | defmodule M51.IrcConn.Writer do 18 | @moduledoc """ 19 | Writes lines to a client. 20 | """ 21 | 22 | use GenServer 23 | 24 | require Logger 25 | 26 | def start_link(args) do 27 | {sup_pid, _sock} = args 28 | 29 | GenServer.start_link(__MODULE__, args, 30 | name: {:via, Registry, {M51.Registry, {sup_pid, :irc_writer}}} 31 | ) 32 | end 33 | 34 | @impl true 35 | def init(state) do 36 | {:ok, state} 37 | end 38 | 39 | def write_command(writer, command) do 40 | if command != nil do 41 | write_line(writer, M51.Irc.Command.format(command)) 42 | end 43 | end 44 | 45 | def write_line(writer, line) do 46 | GenServer.call(writer, {:line, line}) 47 | end 48 | 49 | def close(writer) do 50 | GenServer.call(writer, {:close}) 51 | end 52 | 53 | @impl true 54 | def handle_call(arg, _from, state) do 55 | case arg do 56 | {:line, line} -> 57 | {_supervisor, sock} = state 58 | Logger.debug("IRC S->C #{Regex.replace(~r/[\r\n]/, line, "")}") 59 | :gen_tcp.send(sock, line) 60 | 61 | {:close} -> 62 | {_supervisor, sock} = state 63 | :gen_tcp.close(sock) 64 | end 65 | 66 | {:reply, :ok, state} 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /lib/irc_server.ex: -------------------------------------------------------------------------------- 1 | ## 2 | # Copyright (C) 2021 Valentin Lorentz 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License version 3, 6 | # as published by the Free Software Foundation. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU Affero General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU Affero General Public License 14 | # along with this program. If not, see . 15 | ### 16 | 17 | defmodule M51.IrcServer do 18 | @moduledoc """ 19 | Holds the main server socket and spawns a supervised 20 | M51.IrcConn.Supervisor process for each incoming IRC connection. 21 | """ 22 | use Supervisor 23 | 24 | require Logger 25 | 26 | def start_link(args) do 27 | Supervisor.start_link(__MODULE__, args, name: __MODULE__) 28 | end 29 | 30 | @impl true 31 | def init(_args) do 32 | port = M51.Config.port() 33 | 34 | children = [ 35 | {DynamicSupervisor, name: M51.IrcServer.DynamicSupervisor, strategy: :one_for_one}, 36 | {Task, fn -> accept(port) end} 37 | ] 38 | 39 | Supervisor.init(children, strategy: :one_for_one) 40 | end 41 | 42 | defp accept(port, retries_left \\ 10) do 43 | opts = [ 44 | :binary, 45 | :inet6, 46 | packet: :line, 47 | active: false, 48 | reuseaddr: true, 49 | buffer: M51.IrcConn.Handler.multiline_max_bytes() * 2 50 | ] 51 | 52 | case :gen_tcp.listen(port, opts) do 53 | {:ok, server_sock} -> 54 | Logger.info("Listening on port #{port}") 55 | loop_accept(server_sock) 56 | 57 | {:error, :eaddrinuse} when retries_left > 0 -> 58 | # happens sometimes when recovering from a crash... 59 | Process.sleep(100) 60 | accept(port, retries_left - 1) 61 | end 62 | end 63 | 64 | defp loop_accept(server_sock) do 65 | {:ok, sock} = :gen_tcp.accept(server_sock) 66 | 67 | {:ok, {peer_address, peer_port}} = :inet.peername(sock) 68 | 69 | Logger.info("Incoming connection from #{:inet_parse.ntoa(peer_address)}:#{peer_port}") 70 | 71 | {:ok, conn_supervisor} = 72 | DynamicSupervisor.start_child( 73 | M51.IrcServer.DynamicSupervisor, 74 | {M51.IrcConn.Supervisor, {sock}} 75 | ) 76 | 77 | :ok = :gen_tcp.controlling_process(sock, conn_supervisor) 78 | 79 | loop_accept(server_sock) 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /lib/matrix/misc.ex: -------------------------------------------------------------------------------- 1 | ## 2 | # Copyright (C) 2021-2022 Valentin Lorentz 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License version 3, 6 | # as published by the Free Software Foundation. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU Affero General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU Affero General Public License 14 | # along with this program. If not, see . 15 | ### 16 | 17 | defmodule M51.Matrix.Misc do 18 | def parse_userid(userid) do 19 | case String.split(userid, ":") do 20 | [local_name, hostname] -> 21 | cond do 22 | !Regex.match?(~r|^[0-9a-z.=_/-]+$|, local_name) -> 23 | {:error, 24 | "your local name may only contain lowercase latin letters, digits, and the following characters: -.=_/"} 25 | 26 | Regex.match?(~r/.*\s.*/u, hostname) -> 27 | {:error, "\"#{hostname}\" is not a valid hostname"} 28 | 29 | true -> 30 | {:ok, {local_name, hostname}} 31 | end 32 | 33 | [local_name, hostname, port_str] -> 34 | port = 35 | case Integer.parse(port_str) do 36 | {i, ""} -> i 37 | _ -> nil 38 | end 39 | 40 | cond do 41 | !Regex.match?(~r|^[0-9a-z.=_/-]+$|, local_name) -> 42 | {:error, 43 | "your local name may only contain lowercase latin letters, digits, and the following characters: -.=_/"} 44 | 45 | Regex.match?(~r/.*\s.*/u, hostname) -> 46 | {:error, "\"#{hostname}\" is not a valid hostname"} 47 | 48 | port == nil -> 49 | {:error, "\"#{port_str}\" is not a valid port number"} 50 | 51 | true -> 52 | {:ok, {local_name, "#{hostname}:#{port}"}} 53 | end 54 | 55 | [nick] -> 56 | {:error, 57 | "must contain a colon (':'), to separate the username and hostname. For example: " <> 58 | nick <> ":matrix.org"} 59 | 60 | _ -> 61 | {:error, "must not contain more than two colons."} 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /lib/matrix/raw_client.ex: -------------------------------------------------------------------------------- 1 | ## 2 | # Copyright (C) 2021 Valentin Lorentz 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License version 3, 6 | # as published by the Free Software Foundation. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU Affero General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU Affero General Public License 14 | # along with this program. If not, see . 15 | ### 16 | 17 | defmodule M51.Matrix.RawClient do 18 | require Logger 19 | 20 | @moduledoc """ 21 | Sends queries to a Matrix homeserver. 22 | """ 23 | defstruct [:base_url, :access_token, :httpoison] 24 | 25 | def get(client, path, headers \\ [], options \\ []) do 26 | headers = [Authorization: "Bearer " <> client.access_token] ++ headers 27 | options = options |> Keyword.put_new(:timeout, 120_000) 28 | 29 | url = client.base_url <> path 30 | 31 | Logger.debug("GET #{url}") 32 | 33 | response = client.httpoison.get(url, headers, options) 34 | Logger.debug(Kernel.inspect(response)) 35 | 36 | case response do 37 | {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> 38 | {:ok, Jason.decode!(body)} 39 | 40 | {:ok, %HTTPoison.Response{status_code: status_code, body: body}} -> 41 | {:error, status_code, body} 42 | 43 | {:error, %HTTPoison.Error{reason: reason}} -> 44 | {:error, nil, reason} 45 | end 46 | end 47 | 48 | def post(client, path, body, headers \\ [], options \\ []) do 49 | headers = [Authorization: "Bearer " <> client.access_token] ++ headers 50 | options = options |> Keyword.put_new(:timeout, 60000) 51 | 52 | url = client.base_url <> path 53 | 54 | Logger.debug("POST #{url} " <> Kernel.inspect(body)) 55 | 56 | response = client.httpoison.post(url, body, headers, options) 57 | 58 | Logger.debug(Kernel.inspect(response)) 59 | 60 | case response do 61 | {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> 62 | {:ok, Jason.decode!(body)} 63 | 64 | {:ok, %HTTPoison.Response{status_code: status_code, body: body}} -> 65 | {:error, status_code, Jason.decode!(body)} 66 | 67 | {:error, %HTTPoison.Error{reason: reason}} -> 68 | {:error, nil, reason} 69 | end 70 | end 71 | 72 | def put(client, path, body, headers \\ [], options \\ []) do 73 | headers = [Authorization: "Bearer " <> client.access_token] ++ headers 74 | options = options |> Keyword.put_new(:timeout, 60000) 75 | 76 | url = client.base_url <> path 77 | 78 | Logger.debug("POST #{url} " <> Kernel.inspect(body)) 79 | 80 | response = client.httpoison.put(url, body, headers, options) 81 | Logger.debug(Kernel.inspect(response)) 82 | 83 | case response do 84 | {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> 85 | {:ok, Jason.decode!(body)} 86 | 87 | {:ok, %HTTPoison.Response{status_code: status_code, body: body}} -> 88 | {:error, status_code, Jason.decode!(body)} 89 | 90 | {:error, %HTTPoison.Error{reason: reason}} -> 91 | {:error, nil, reason} 92 | end 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /lib/matrix/room_member.ex: -------------------------------------------------------------------------------- 1 | ## 2 | # Copyright (C) 2021 Valentin Lorentz 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License version 3, 6 | # as published by the Free Software Foundation. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU Affero General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU Affero General Public License 14 | # along with this program. If not, see . 15 | ### 16 | 17 | defmodule M51.Matrix.RoomMember do 18 | @moduledoc """ 19 | Stores the state of the member of a Matrix room 20 | """ 21 | 22 | defstruct [ 23 | :display_name 24 | ] 25 | end 26 | -------------------------------------------------------------------------------- /lib/matrix/room_state.ex: -------------------------------------------------------------------------------- 1 | ## 2 | # Copyright (C) 2021 Valentin Lorentz 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License version 3, 6 | # as published by the Free Software Foundation. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU Affero General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU Affero General Public License 14 | # along with this program. If not, see . 15 | ### 16 | 17 | defmodule M51.Matrix.RoomState do 18 | @moduledoc """ 19 | Stores the state of a Matrix client (access token, joined rooms, ...) 20 | """ 21 | 22 | defstruct [ 23 | # human-readable identifier for the room 24 | :canonical_alias, 25 | # human-readable non-unique name for the room 26 | :name, 27 | # as on IRC 28 | :topic, 29 | # %{user_id => M51.Matrix.RoomMember{...}} 30 | members: Map.new(), 31 | # whether the whole state was fetched 32 | synced: false 33 | ] 34 | end 35 | -------------------------------------------------------------------------------- /lib/matrix/utils.ex: -------------------------------------------------------------------------------- 1 | ## 2 | # Copyright (C) 2021-2022 Valentin Lorentz 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License version 3, 6 | # as published by the Free Software Foundation. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU Affero General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU Affero General Public License 14 | # along with this program. If not, see . 15 | ### 16 | 17 | defmodule M51.Matrix.Utils do 18 | def urlquote(s) do 19 | URI.encode(s, &URI.char_unreserved?/1) 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/matrix_client/chat_history.ex: -------------------------------------------------------------------------------- 1 | ## 2 | # Copyright (C) 2021 Valentin Lorentz 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License version 3, 6 | # as published by the Free Software Foundation. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU Affero General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU Affero General Public License 14 | # along with this program. If not, see . 15 | ### 16 | 17 | defmodule M51.MatrixClient.ChatHistory do 18 | @moduledoc """ 19 | Queries history when queried from IRC clients 20 | """ 21 | 22 | def after_(sup_pid, room_id, anchor, limit) do 23 | client = M51.IrcConn.Supervisor.matrix_client(sup_pid) 24 | 25 | case parse_anchor(anchor) do 26 | {:ok, event_id} -> 27 | case M51.MatrixClient.Client.get_event_context( 28 | client, 29 | room_id, 30 | event_id, 31 | limit * 2 32 | ) do 33 | {:ok, events} -> {:ok, process_events(sup_pid, room_id, events["events_after"])} 34 | {:error, message} -> {:error, Kernel.inspect(message)} 35 | end 36 | 37 | {:error, message} -> 38 | {:error, message} 39 | end 40 | end 41 | 42 | def around(sup_pid, room_id, anchor, limit) do 43 | client = M51.IrcConn.Supervisor.matrix_client(sup_pid) 44 | 45 | case parse_anchor(anchor) do 46 | {:ok, event_id} -> 47 | case M51.MatrixClient.Client.get_event_context(client, room_id, event_id, limit) do 48 | {:ok, events} -> 49 | # TODO: if there aren't enough events after (resp. before), allow more 50 | # events before (resp. after) than half the limit. 51 | nb_before = ((limit - 1) / 2) |> Float.ceil() |> Kernel.trunc() 52 | nb_after = ((limit - 1) / 2) |> Kernel.trunc() 53 | 54 | events_before = events["events_before"] |> Enum.slice(0, nb_before) |> Enum.reverse() 55 | events_after = events["events_after"] |> Enum.slice(0, nb_after) 56 | events = Enum.concat([events_before, [events["event"]], events_after]) 57 | 58 | {:ok, process_events(sup_pid, room_id, events)} 59 | 60 | {:error, message} -> 61 | {:error, Kernel.inspect(message)} 62 | end 63 | 64 | {:error, message} -> 65 | {:error, message} 66 | end 67 | end 68 | 69 | def before(sup_pid, room_id, anchor, limit) do 70 | client = M51.IrcConn.Supervisor.matrix_client(sup_pid) 71 | 72 | case parse_anchor(anchor) do 73 | {:ok, event_id} -> 74 | case M51.MatrixClient.Client.get_event_context( 75 | client, 76 | room_id, 77 | event_id, 78 | limit * 2 79 | ) do 80 | {:ok, events} -> 81 | {:ok, process_events(sup_pid, room_id, Enum.reverse(events["events_before"]))} 82 | 83 | {:error, message} -> 84 | {:error, Kernel.inspect(message)} 85 | end 86 | 87 | {:error, message} -> 88 | {:error, message} 89 | end 90 | end 91 | 92 | def latest(sup_pid, room_id, limit) do 93 | client = M51.IrcConn.Supervisor.matrix_client(sup_pid) 94 | 95 | case M51.MatrixClient.Client.get_latest_events( 96 | client, 97 | room_id, 98 | limit 99 | ) do 100 | {:ok, events} -> 101 | {:ok, process_events(sup_pid, room_id, Enum.reverse(events["chunk"]))} 102 | 103 | {:error, message} -> 104 | {:error, Kernel.inspect(message)} 105 | end 106 | end 107 | 108 | defp parse_anchor(anchor) do 109 | case String.split(anchor, "=", parts: 2) do 110 | ["msgid", msgid] -> 111 | {:ok, msgid} 112 | 113 | ["timestamp", _] -> 114 | {:error, 115 | "CHATHISTORY with timestamps is not supported. See https://github.com/progval/matrix2051/issues/1"} 116 | 117 | _ -> 118 | {:error, "Invalid anchor: '#{anchor}', it should start with 'msgid='."} 119 | end 120 | end 121 | 122 | defp process_events(sup_pid, room_id, events) do 123 | pid = self() 124 | write = fn cmd -> send(pid, {:command, cmd}) end 125 | 126 | # Run the poller with this "mock" write function. 127 | # This allows us to collect commands, so put them all in the chathistory batch. 128 | # 129 | # It is tempting to make M51.MatrixClient.Poller.handle_event return 130 | # a list of commands instead of making it send them directly, but it makes 131 | # it hard to deal with state changes. 132 | # TODO: still... it would be nice to find a way to avoid this. 133 | Task.async(fn -> 134 | Enum.map(events, fn event -> 135 | # TODO: dedup this computation with Poller 136 | sender = 137 | case Map.get(event, "sender") do 138 | nil -> nil 139 | sender -> String.replace_prefix(sender, "@", "") 140 | end 141 | 142 | M51.MatrixClient.Poller.handle_event( 143 | sup_pid, 144 | room_id, 145 | sender, 146 | false, 147 | write, 148 | event 149 | ) 150 | end) 151 | 152 | send(pid, {:finished_processing}) 153 | end) 154 | |> Task.await() 155 | 156 | # Collect all commands 157 | Stream.unfold(nil, fn _ -> 158 | receive do 159 | {:command, cmd} -> {cmd, nil} 160 | {:finished_processing} -> nil 161 | end 162 | end) 163 | |> Enum.to_list() 164 | end 165 | end 166 | -------------------------------------------------------------------------------- /lib/matrix_client/client.ex: -------------------------------------------------------------------------------- 1 | ## 2 | # Copyright (C) 2021-2022 Valentin Lorentz 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License version 3, 6 | # as published by the Free Software Foundation. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU Affero General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU Affero General Public License 14 | # along with this program. If not, see . 15 | ### 16 | 17 | defmodule M51.MatrixClient.Client do 18 | @moduledoc """ 19 | Manages connections to a Matrix homeserver. 20 | """ 21 | use GenServer 22 | 23 | require Logger 24 | 25 | # The state of this client 26 | defstruct [ 27 | # :initial_state or :connected 28 | :state, 29 | # extra keyword list passed to init/1 30 | :args, 31 | # pid of IrcConnSupervisor 32 | :irc_pid, 33 | # M51.Matrix.RawClient structure 34 | :raw_client, 35 | :local_name, 36 | :hostname 37 | ] 38 | 39 | # timeout used for all requests sent to a homeserver. 40 | # It should be slightly larger than M51.Matrix.RawClient's timeout, 41 | @timeout 125_000 42 | 43 | def start_link(opts) do 44 | {sup_pid, _extra_args} = opts 45 | 46 | GenServer.start_link(__MODULE__, opts, 47 | name: {:via, Registry, {M51.Registry, {sup_pid, :matrix_client}}} 48 | ) 49 | end 50 | 51 | @impl true 52 | def init(args) do 53 | {irc_pid, extra_args} = args 54 | 55 | {:ok, 56 | %M51.MatrixClient.Client{ 57 | state: :initial_state, 58 | irc_pid: irc_pid, 59 | args: extra_args 60 | }} 61 | end 62 | 63 | @impl true 64 | def handle_call({:dump_state}, _from, state) do 65 | {:reply, state, state} 66 | end 67 | 68 | @impl true 69 | def handle_call({:connect, local_name, hostname, password}, _from, state) do 70 | case state do 71 | %M51.MatrixClient.Client{ 72 | state: :initial_state, 73 | irc_pid: irc_pid 74 | } -> 75 | httpoison = M51.Config.httpoison() 76 | base_url = get_base_url(hostname) 77 | 78 | # Check the server supports password login 79 | url = base_url <> "/_matrix/client/r0/login" 80 | Logger.debug("(raw) GET #{url}") 81 | response = httpoison.get!(url, [], timeout: @timeout, recv_timeout: @timeout) 82 | Logger.debug(Kernel.inspect(response)) 83 | 84 | case response do 85 | %HTTPoison.Response{status_code: 200, body: body} -> 86 | data = Jason.decode!(body) 87 | 88 | flow = 89 | case data["flows"] do 90 | flows when is_list(flows) -> 91 | Enum.find(flows, nil, fn flow -> flow["type"] == "m.login.password" end) 92 | 93 | _ -> 94 | nil 95 | end 96 | 97 | case flow do 98 | nil -> 99 | {:reply, {:error, :no_password_flow, "No password flow"}, state} 100 | 101 | _ -> 102 | body = 103 | Jason.encode!(%{ 104 | "type" => "m.login.password", 105 | "identifier" => %{ 106 | "type" => "m.id.user", 107 | "user" => local_name 108 | }, 109 | "password" => password 110 | }) 111 | 112 | url = base_url <> "/_matrix/client/r0/login" 113 | Logger.debug("(raw) POST #{url} " <> Kernel.inspect(body)) 114 | 115 | response = 116 | httpoison.post!(url, body, [{"content-type", "application/json"}], 117 | timeout: @timeout, 118 | recv_timeout: @timeout 119 | ) 120 | 121 | Logger.debug(Kernel.inspect(response)) 122 | 123 | case response do 124 | %HTTPoison.Response{status_code: 200, body: body} -> 125 | data = Jason.decode!(body) 126 | 127 | if data["user_id"] != "@" <> local_name <> ":" <> hostname do 128 | raise "Unexpected user_id: " <> data["user_id"] 129 | end 130 | 131 | access_token = data["access_token"] 132 | 133 | raw_client = %M51.Matrix.RawClient{ 134 | base_url: base_url, 135 | access_token: access_token, 136 | httpoison: httpoison 137 | } 138 | 139 | state = %M51.MatrixClient.Client{ 140 | state: :connected, 141 | irc_pid: irc_pid, 142 | raw_client: raw_client, 143 | local_name: local_name, 144 | hostname: hostname 145 | } 146 | 147 | Registry.send({M51.Registry, {irc_pid, :matrix_poller}}, :connected) 148 | 149 | {:reply, {:ok}, state} 150 | 151 | %HTTPoison.Response{status_code: 403, body: body} -> 152 | data = Jason.decode!(body) 153 | {:reply, {:error, :denied, data["error"]}, state} 154 | end 155 | end 156 | 157 | %HTTPoison.Response{status_code: status_code} -> 158 | message = 159 | "Could not reach the Matrix homeserver for #{hostname}, #{url} returned HTTP #{status_code}. Make sure this is a Matrix homeserver and https://#{hostname}/.well-known/matrix/client is properly configured." 160 | 161 | {:reply, {:error, :unknown, message}, state} 162 | end 163 | 164 | %M51.MatrixClient.Client{ 165 | state: :connected, 166 | local_name: local_name, 167 | hostname: hostname 168 | } -> 169 | {:reply, {:error, {:already_connected, local_name, hostname}}, state} 170 | end 171 | end 172 | 173 | @impl true 174 | def handle_call({:register, local_name, hostname, password}, _from, state) do 175 | case state do 176 | %M51.MatrixClient.Client{ 177 | state: :initial_state, 178 | irc_pid: irc_pid 179 | } -> 180 | httpoison = M51.Config.httpoison() 181 | base_url = get_base_url(hostname, httpoison) 182 | 183 | # XXX: This is not part of the Matrix specification; 184 | # but there is nothing else we can do to support registration. 185 | # This seems to be only documented here: 186 | # https://matrix.org/docs/guides/client-server-api/#accounts 187 | body = 188 | Jason.encode!(%{ 189 | "auth" => %{type: "m.login.dummy"}, 190 | "username" => local_name, 191 | "password" => password 192 | }) 193 | 194 | case httpoison.post!(base_url <> "/_matrix/client/r0/register", body) do 195 | %HTTPoison.Response{status_code: 200, body: body} -> 196 | data = Jason.decode!(body) 197 | 198 | # TODO: check data["user_id"] 199 | {_, user_id} = String.split_at(data["user_id"], 1) 200 | access_token = data["access_token"] 201 | 202 | raw_client = %M51.Matrix.RawClient{ 203 | base_url: base_url, 204 | access_token: access_token, 205 | httpoison: httpoison 206 | } 207 | 208 | state = %M51.MatrixClient.Client{ 209 | state: :connected, 210 | irc_pid: irc_pid, 211 | raw_client: raw_client, 212 | local_name: local_name, 213 | hostname: hostname 214 | } 215 | 216 | Registry.send({M51.Registry, {irc_pid, :matrix_poller}}, :connected) 217 | 218 | {:reply, {:ok, user_id}, state} 219 | 220 | %HTTPoison.Response{status_code: 400, body: body} -> 221 | data = Jason.decode!(body) 222 | 223 | case data do 224 | %{errcode: "M_USER_IN_USE", error: message} -> 225 | {:reply, {:error, :user_in_use, message}, state} 226 | 227 | %{errcode: "M_INVALID_USERNAME", error: message} -> 228 | {:reply, {:error, :invalid_username, message}, state} 229 | 230 | %{errcode: "M_EXCLUSIVE", error: message} -> 231 | {:reply, {:error, :exclusive, message}, state} 232 | end 233 | 234 | %HTTPoison.Response{status_code: 403, body: body} -> 235 | data = Jason.decode!(body) 236 | {:reply, {:error, :unknown, data["error"]}, state} 237 | 238 | %HTTPoison.Response{status_code: _, body: body} -> 239 | {:reply, {:error, :unknown, Kernel.inspect(body)}, state} 240 | end 241 | 242 | %M51.MatrixClient.Client{ 243 | state: :connected, 244 | local_name: local_name, 245 | hostname: hostname 246 | } -> 247 | {:reply, {:error, {:already_connected, local_name, hostname}}, state} 248 | end 249 | end 250 | 251 | @impl true 252 | def handle_call({:join_room, room_alias}, _from, state) do 253 | %M51.MatrixClient.Client{state: :connected, raw_client: raw_client, irc_pid: irc_pid} = state 254 | 255 | matrix_state = M51.IrcConn.Supervisor.matrix_state(irc_pid) 256 | 257 | path = "/_matrix/client/r0/join/" <> urlquote(room_alias) 258 | 259 | case M51.MatrixClient.State.room_from_irc_channel(matrix_state, room_alias) do 260 | {room_id, _room} -> 261 | {:reply, {:error, :already_joined, room_id}, state} 262 | 263 | nil -> 264 | case M51.Matrix.RawClient.post(raw_client, path, "{}") do 265 | {:ok, %{"room_id" => room_id}} -> 266 | {:reply, {:ok, room_id}, state} 267 | 268 | {:error, 403, %{"errcode" => errcode, "error" => message}} -> 269 | {:reply, {:error, :banned_or_missing_invite, errcode <> ": " <> message}, state} 270 | 271 | {:error, _, %{"errcode" => errcode, "error" => message}} -> 272 | {:reply, {:error, :unknown, errcode <> ": " <> message}, state} 273 | 274 | {:error, nil, error} -> 275 | {:reply, {:error, :unknown, Kernel.inspect(error)}, state} 276 | end 277 | end 278 | end 279 | 280 | @impl true 281 | def handle_call({:send_event, channel, event_type, label, event}, _from, state) do 282 | %M51.MatrixClient.Client{ 283 | state: :connected, 284 | irc_pid: irc_pid 285 | } = state 286 | 287 | matrix_state = M51.IrcConn.Supervisor.matrix_state(irc_pid) 288 | 289 | transaction_id = label_to_transaction_id(label) 290 | 291 | case M51.MatrixClient.State.room_from_irc_channel(matrix_state, channel) do 292 | nil -> 293 | {:reply, {:error, {:room_not_found, channel}}, state} 294 | 295 | {room_id, _room} -> 296 | M51.MatrixClient.Sender.queue_event( 297 | irc_pid, 298 | room_id, 299 | event_type, 300 | transaction_id, 301 | event 302 | ) 303 | 304 | {:reply, {:ok, {transaction_id}}, state} 305 | end 306 | end 307 | 308 | @impl true 309 | def handle_call({:send_redact, channel, label, event_id, reason}, _from, state) do 310 | %M51.MatrixClient.Client{ 311 | state: :connected, 312 | irc_pid: irc_pid, 313 | raw_client: raw_client 314 | } = state 315 | 316 | matrix_state = M51.IrcConn.Supervisor.matrix_state(irc_pid) 317 | 318 | transaction_id = label_to_transaction_id(label) 319 | 320 | reply = 321 | case M51.MatrixClient.State.room_from_irc_channel(matrix_state, channel) do 322 | nil -> 323 | {:reply, {:error, {:room_not_found, channel}}, state} 324 | 325 | {room_id, _room} -> 326 | path = 327 | "/_matrix/client/r0/rooms/#{urlquote(room_id)}/redact/#{urlquote(event_id)}/#{transaction_id}" 328 | 329 | body = 330 | case reason do 331 | reason when is_binary(reason) -> Jason.encode!(%{"reason" => reason}) 332 | _ -> Jason.encode!({}) 333 | end 334 | 335 | case M51.Matrix.RawClient.put(raw_client, path, body) do 336 | {:ok, %{"event_id" => event_id}} -> {:ok, event_id} 337 | {:error, nil, error} -> {:error, error} 338 | {:error, http_code, error} -> {:error, "Error #{http_code}: #{error}"} 339 | end 340 | end 341 | 342 | {:reply, reply, state} 343 | end 344 | 345 | @impl true 346 | def handle_call({:get_event_context, channel, event_id, limit}, _from, state) do 347 | %M51.MatrixClient.Client{ 348 | state: :connected, 349 | irc_pid: irc_pid, 350 | raw_client: raw_client 351 | } = state 352 | 353 | matrix_state = M51.IrcConn.Supervisor.matrix_state(irc_pid) 354 | 355 | reply = 356 | case M51.MatrixClient.State.room_from_irc_channel(matrix_state, channel) do 357 | nil -> 358 | {:error, {:room_not_found, channel}} 359 | 360 | {room_id, _room} -> 361 | path = 362 | "/_matrix/client/r0/rooms/#{urlquote(room_id)}/context/#{urlquote(event_id)}?" <> 363 | URI.encode_query(%{"limit" => limit}) 364 | 365 | case M51.Matrix.RawClient.get(raw_client, path) do 366 | {:ok, events} -> {:ok, events} 367 | {:error, nil, error} -> {:error, error} 368 | {:error, http_code, error} -> {:error, "Error #{http_code}: #{error}"} 369 | end 370 | end 371 | 372 | {:reply, reply, state} 373 | end 374 | 375 | @impl true 376 | def handle_call({:get_latest_events, channel, limit}, _from, state) do 377 | %M51.MatrixClient.Client{ 378 | state: :connected, 379 | irc_pid: irc_pid, 380 | raw_client: raw_client 381 | } = state 382 | 383 | matrix_state = M51.IrcConn.Supervisor.matrix_state(irc_pid) 384 | 385 | reply = 386 | case M51.MatrixClient.State.room_from_irc_channel(matrix_state, channel) do 387 | nil -> 388 | {:error, {:room_not_found, channel}} 389 | 390 | {room_id, _room} -> 391 | path = 392 | "/_matrix/client/v3/rooms/#{urlquote(room_id)}/messages?" <> 393 | URI.encode_query(%{"limit" => limit, "dir" => "b"}) 394 | 395 | case M51.Matrix.RawClient.get(raw_client, path) do 396 | {:ok, events} -> {:ok, events} 397 | {:error, nil, error} -> {:error, error} 398 | {:error, http_code, error} -> {:error, "Error #{http_code}: #{error}"} 399 | end 400 | end 401 | 402 | {:reply, reply, state} 403 | end 404 | 405 | @impl true 406 | def handle_call({:is_valid_alias, room_id, room_alias}, _from, state) do 407 | %M51.MatrixClient.Client{ 408 | raw_client: raw_client 409 | } = state 410 | 411 | path = "/_matrix/client/r0/directory/room/#{urlquote(room_alias)}" 412 | 413 | case M51.Matrix.RawClient.get(raw_client, path) do 414 | {:ok, event} -> 415 | if Map.get(event, "room_id") == room_id do 416 | {:reply, true, state} 417 | else 418 | {:reply, false, state} 419 | end 420 | 421 | {:error, 404, _} -> 422 | {:reply, false, state} 423 | 424 | {:error, _, _} -> 425 | # TODO: retry 426 | {:reply, false, state} 427 | end 428 | end 429 | 430 | @doc """ 431 | Generates a unique transaction id, assuming the 'label' is either a unique string, 432 | or 'nil'. 433 | 434 | 'transaction_id_to_label' is the inverse of this function. 435 | 436 | # Examples 437 | 438 | iex> M51.MatrixClient.Client.label_to_transaction_id("foo") 439 | "m51-cl-Zm9v" 440 | iex> M51.MatrixClient.Client.label_to_transaction_id("foo") 441 | "m51-cl-Zm9v" 442 | iex> txid1 = M51.MatrixClient.Client.label_to_transaction_id(nil) 443 | iex> txid2 = M51.MatrixClient.Client.label_to_transaction_id(nil) 444 | iex> txid1 == txid2 445 | false 446 | iex> M51.MatrixClient.Client.transaction_id_to_label( 447 | ...> M51.MatrixClient.Client.label_to_transaction_id("foo") 448 | ...> ) 449 | "foo" 450 | iex> M51.MatrixClient.Client.transaction_id_to_label(txid1) 451 | nil 452 | """ 453 | def label_to_transaction_id(label) do 454 | case label do 455 | nil -> "m51-gen-" <> Base.url_encode64(:crypto.strong_rand_bytes(64)) 456 | # URI.encode() may be shorter 457 | label -> "m51-cl-" <> Base.url_encode64(label) 458 | end 459 | end 460 | 461 | @doc """ 462 | Inverse function of 'label_to_transaction_id': recomputes the original label if any, 463 | or returns nil. 464 | 465 | # Examples 466 | 467 | iex> M51.MatrixClient.Client.transaction_id_to_label("m51-cl-Zm9v") 468 | "foo" 469 | iex> M51.MatrixClient.Client.transaction_id_to_label("m51-gen-AAAA") 470 | nil 471 | iex> M51.MatrixClient.Client.transaction_id_to_label( 472 | ...> M51.MatrixClient.Client.label_to_transaction_id("foo") 473 | ...> ) 474 | "foo" 475 | iex> M51.MatrixClient.Client.transaction_id_to_label( 476 | ...> M51.MatrixClient.Client.label_to_transaction_id(nil) 477 | ...> ) 478 | nil 479 | """ 480 | def transaction_id_to_label(transaction_id) do 481 | captures = Regex.named_captures(~r/m51-cl-(?