├── .formatter.exs ├── dist ├── Containerfile └── systemd │ └── matrix2051.service ├── .gitignore ├── matrix2051.exs ├── test ├── irc │ ├── word_wrap_test.exs │ └── command_test.exs ├── irc_conn │ └── state_test.exs ├── matrix_client │ ├── state_test.exs │ └── client_test.exs ├── test_helper.exs └── format │ └── common_test.exs ├── lib ├── matrix │ ├── utils.ex │ ├── room_member.ex │ ├── room_state.ex │ ├── misc.ex │ └── raw_client.ex ├── supervisor.ex ├── application.ex ├── config.ex ├── irc_conn │ ├── reader.ex │ ├── writer.ex │ ├── supervisor.ex │ └── state.ex ├── matrix_client │ ├── room_supervisor.ex │ ├── room_handler.ex │ ├── sender.ex │ ├── chat_history.ex │ ├── state.ex │ └── client.ex ├── irc_server.ex ├── format │ ├── common.ex │ ├── matrix2irc.ex │ └── irc2matrix.ex └── irc │ ├── word_wrap.ex │ └── command.ex ├── .github └── workflows │ └── ci.yml ├── mix.exs ├── INSTALL.md └── README.md /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /matrix2051.exs: -------------------------------------------------------------------------------- 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 | {:ok, _} = Application.ensure_all_started(:matrix2051, :permanent) 18 | Process.sleep(:infinity) 19 | -------------------------------------------------------------------------------- /test/irc/word_wrap_test.exs: -------------------------------------------------------------------------------- 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.WordWrapTest do 18 | use ExUnit.Case 19 | doctest M51.Irc.WordWrap 20 | end 21 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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/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.Supervisor do 18 | @moduledoc """ 19 | Main supervisor of M51. Starts the M51.Config agent, 20 | and the M51.IrcServer tree. 21 | """ 22 | 23 | use Supervisor 24 | 25 | def start_link(args) do 26 | Supervisor.start_link(__MODULE__, args) 27 | end 28 | 29 | @impl true 30 | def init(args) do 31 | children = [ 32 | {Registry, keys: :unique, name: M51.Registry}, 33 | {M51.Config, args}, 34 | M51.IrcServer 35 | ] 36 | 37 | Supervisor.init(children, strategy: :one_for_one) 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /test/irc_conn/state_test.exs: -------------------------------------------------------------------------------- 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.StateTest do 18 | use ExUnit.Case 19 | doctest M51.IrcConn.State 20 | 21 | test "batches" do 22 | state = start_supervised!({M51.IrcConn.State, {nil}}) 23 | 24 | opening_command = %M51.Irc.Command{ 25 | command: "BATCH", 26 | params: ["+tag", "type", "foo", "bar"] 27 | } 28 | 29 | M51.IrcConn.State.create_batch(state, "tag", opening_command) 30 | M51.IrcConn.State.add_batch_command(state, "tag", :foo) 31 | M51.IrcConn.State.add_batch_command(state, "tag", :bar) 32 | M51.IrcConn.State.add_batch_command(state, "tag", :baz) 33 | 34 | assert M51.IrcConn.State.pop_batch(state, "tag") == {opening_command, [:foo, :bar, :baz]} 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /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.warning("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 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 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.MixProject do 18 | use Mix.Project 19 | 20 | def project do 21 | [ 22 | app: :matrix2051, 23 | version: version(), 24 | elixir: "~> 1.7", 25 | start_permanent: Mix.env() == :prod, 26 | deps: deps(), 27 | releases: [ 28 | matrix2051: [ 29 | version: version(), 30 | applications: [matrix2051: :permanent] 31 | ] 32 | ] 33 | ] 34 | end 35 | 36 | defp version do 37 | "0.1.0" 38 | end 39 | 40 | defp source_code_url do 41 | "https://github.com/progval/matrix2051" 42 | end 43 | 44 | def application do 45 | [ 46 | mod: {M51.Application, []}, 47 | env: [source_code_url: source_code_url()], 48 | extra_applications: [:logger] 49 | ] 50 | end 51 | 52 | defp deps do 53 | [ 54 | # only using :mochiweb_html 55 | {:mochiweb, "~> 3.2.2"}, 56 | {:jason, "~> 1.2"}, 57 | {:httpoison, "~> 1.7"}, 58 | {:mox, "~> 1.0.0", only: :test} 59 | ] 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /lib/matrix_client/room_supervisor.ex: -------------------------------------------------------------------------------- 1 | ## 2 | # Copyright (C) 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.RoomSupervisor do 18 | @moduledoc """ 19 | Supervises a GenServer for each joined room, which receives events for 20 | the room and sends them to IRC. 21 | """ 22 | use DynamicSupervisor 23 | 24 | def start_link(init_arg) do 25 | {sup_pid} = init_arg 26 | room_sup = M51.IrcConn.Supervisor.matrix_room_supervisor(sup_pid) 27 | DynamicSupervisor.start_link(__MODULE__, init_arg, name: room_sup) 28 | end 29 | 30 | @impl true 31 | def init(init_arg) do 32 | {sup_pid} = init_arg 33 | 34 | ret = DynamicSupervisor.init(strategy: :one_for_one) 35 | 36 | Registry.register(M51.Registry, {sup_pid, :matrix_room_supervisor}, nil) 37 | 38 | ret 39 | end 40 | 41 | def start_or_get_room_handler(sup_pid, room_id) do 42 | room_sup = M51.IrcConn.Supervisor.matrix_room_supervisor(sup_pid) 43 | 44 | case Registry.lookup(M51.Registry, {sup_pid, :matrix_room_handler, room_id}) do 45 | [] -> 46 | {:ok, new_pid} = 47 | DynamicSupervisor.start_child( 48 | room_sup, 49 | {M51.MatrixClient.RoomHandler, {sup_pid, room_id}} 50 | ) 51 | 52 | new_pid 53 | 54 | [{existing_pid, _}] -> 55 | existing_pid 56 | end 57 | end 58 | 59 | def handle_events(sup_pid, room_id, type, is_backlog, events) do 60 | room_handler_pid = M51.MatrixClient.RoomSupervisor.start_or_get_room_handler(sup_pid, room_id) 61 | 62 | GenServer.cast(room_handler_pid, {:events, type, is_backlog, events}) 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /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/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_client/room_handler.ex: -------------------------------------------------------------------------------- 1 | ## 2 | # Copyright (C) 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.RoomHandler do 18 | @moduledoc """ 19 | Receives events from a Matrix room and sends them to IRC. 20 | """ 21 | 22 | use GenServer 23 | 24 | def start_link(args) do 25 | {sup_pid, room_id} = args 26 | 27 | GenServer.start_link(__MODULE__, args, 28 | name: {:via, Registry, {M51.Registry, {sup_pid, :matrix_room_handler, room_id}}} 29 | ) 30 | end 31 | 32 | @impl true 33 | def init(args) do 34 | {sup_pid, room_id} = args 35 | 36 | {:ok, {sup_pid, room_id}} 37 | end 38 | 39 | @impl true 40 | def handle_cast({:events, room_type, is_backlog, events}, state) do 41 | {sup_pid, room_id} = state 42 | state_pid = M51.IrcConn.Supervisor.matrix_state(sup_pid) 43 | irc_state = M51.IrcConn.Supervisor.state(sup_pid) 44 | capabilities = M51.IrcConn.State.capabilities(irc_state) 45 | writer = M51.IrcConn.Supervisor.writer(sup_pid) 46 | handled_event_ids = M51.MatrixClient.State.handled_events(state_pid, room_id) 47 | 48 | write = fn cmd -> 49 | M51.IrcConn.Writer.write_command( 50 | writer, 51 | M51.Irc.Command.downgrade(cmd, capabilities) 52 | ) 53 | end 54 | 55 | case room_type do 56 | :join -> 57 | M51.MatrixClient.Poller.handle_joined_room( 58 | sup_pid, 59 | is_backlog, 60 | handled_event_ids, 61 | room_id, 62 | write, 63 | events 64 | ) 65 | 66 | :leave -> 67 | M51.MatrixClient.Poller.handle_left_room( 68 | sup_pid, 69 | is_backlog, 70 | handled_event_ids, 71 | room_id, 72 | write, 73 | events 74 | ) 75 | 76 | :invite -> 77 | M51.MatrixClient.Poller.handle_invited_room( 78 | sup_pid, 79 | is_backlog, 80 | handled_event_ids, 81 | room_id, 82 | write, 83 | events 84 | ) 85 | end 86 | 87 | {:noreply, state} 88 | end 89 | end 90 | -------------------------------------------------------------------------------- /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/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/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/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/matrix_client/sender.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.Sender do 18 | @moduledoc """ 19 | Sends events to the homeserver. 20 | 21 | Reads messages and repeatedly tries to send them in order until they succeed. 22 | """ 23 | use Task, restart: :permanent 24 | 25 | require Logger 26 | 27 | # totals 4 minutes, as the backoff of each attempt is 2^(number of attempts so far) 28 | @max_attempts 7 29 | 30 | def start_link(args) do 31 | Task.start_link(__MODULE__, :poll, [args]) 32 | end 33 | 34 | def poll(args) do 35 | {sup_pid} = args 36 | Registry.register(M51.Registry, {sup_pid, :matrix_sender}, nil) 37 | loop_poll(sup_pid) 38 | end 39 | 40 | defp loop_poll(sup_pid) do 41 | receive do 42 | {:send, room_id, event_type, transaction_id, event} -> 43 | loop_send(sup_pid, room_id, event_type, transaction_id, event) 44 | end 45 | 46 | loop_poll(sup_pid) 47 | end 48 | 49 | defp loop_send(sup_pid, room_id, event_type, transaction_id, event, nb_attempts \\ 0) do 50 | client = M51.IrcConn.Supervisor.matrix_client(sup_pid) 51 | send = make_send_function(sup_pid, transaction_id) 52 | 53 | case M51.MatrixClient.Client.raw_client(client) do 54 | nil -> 55 | # Wait for it to be initialized 56 | Process.sleep(100) 57 | loop_send(sup_pid, room_id, event_type, transaction_id, event) 58 | 59 | raw_client -> 60 | path = 61 | "/_matrix/client/r0/rooms/#{urlquote(room_id)}/send/#{urlquote(event_type)}/#{urlquote(transaction_id)}" 62 | 63 | body = Jason.encode!(event) 64 | 65 | Logger.debug("Sending event: #{body}") 66 | 67 | case M51.Matrix.RawClient.put(raw_client, path, body) do 68 | {:ok, _body} -> 69 | nil 70 | 71 | {:error, _status_code, reason} -> 72 | if nb_attempts < @max_attempts do 73 | Logger.warning("Error while sending event, retrying: #{Kernel.inspect(reason)}") 74 | backoff_delay = :math.pow(2, nb_attempts) 75 | Process.sleep(round(backoff_delay * 1000)) 76 | 77 | loop_send( 78 | sup_pid, 79 | room_id, 80 | event_type, 81 | transaction_id, 82 | event, 83 | nb_attempts + 1 84 | ) 85 | else 86 | Logger.warning("Error while sending event, giving up: #{Kernel.inspect(reason)}") 87 | state = M51.IrcConn.Supervisor.matrix_state(sup_pid) 88 | channel = M51.MatrixClient.State.room_irc_channel(state, room_id) 89 | 90 | send.(%M51.Irc.Command{ 91 | source: "server.", 92 | command: "NOTICE", 93 | params: [channel, "Error while sending message: " <> Kernel.inspect(reason)] 94 | }) 95 | end 96 | end 97 | end 98 | end 99 | 100 | # Returns a function that can be used to send messages 101 | defp make_send_function(sup_pid, transaction_id) do 102 | writer = M51.IrcConn.Supervisor.writer(sup_pid) 103 | state = M51.IrcConn.Supervisor.state(sup_pid) 104 | capabilities = M51.IrcConn.State.capabilities(state) 105 | label = M51.MatrixClient.Client.transaction_id_to_label(transaction_id) 106 | 107 | fn cmd -> 108 | cmd = 109 | case label do 110 | nil -> cmd 111 | _ -> %{cmd | tags: %{cmd.tags | "label" => label}} 112 | end 113 | 114 | M51.IrcConn.Writer.write_command( 115 | writer, 116 | M51.Irc.Command.downgrade(cmd, capabilities) 117 | ) 118 | end 119 | end 120 | 121 | def queue_event(sup_pid, room_id, event_type, transaction_id, event) do 122 | Registry.send( 123 | {M51.Registry, {sup_pid, :matrix_sender}}, 124 | {:send, room_id, event_type, transaction_id, event} 125 | ) 126 | end 127 | 128 | defp urlquote(s) do 129 | M51.Matrix.Utils.urlquote(s) 130 | end 131 | end 132 | -------------------------------------------------------------------------------- /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/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/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({:comment, _comment}, _state) do 37 | "" 38 | end 39 | 40 | def transform({"a", attributes, children}, state) do 41 | case attributes |> Map.new() |> Map.get("href") do 42 | nil -> 43 | transform_children(children, state) 44 | 45 | link -> 46 | case Regex.named_captures( 47 | ~r{https://matrix.to/#/((@|%40)(?[^/?]*)|(!|%21)(?[^/#]*)|(#|%23)(?[^/?]*))(/.*)?(\?.*)?}, 48 | link 49 | ) do 50 | %{"userid" => encoded_user_id} when encoded_user_id != "" -> 51 | URI.decode(encoded_user_id) 52 | 53 | %{"roomid" => encoded_room_id} when encoded_room_id != "" -> 54 | "!" <> URI.decode(encoded_room_id) 55 | 56 | %{"roomalias" => encoded_room_alias} when encoded_room_alias != "" -> 57 | "#" <> URI.decode(encoded_room_alias) 58 | 59 | _ -> 60 | text = transform_children(children, state) 61 | 62 | if text == link do 63 | link 64 | else 65 | "#{text} <#{link}>" 66 | end 67 | end 68 | end 69 | end 70 | 71 | def transform({"img", attributes, children}, state) do 72 | attributes = attributes |> Map.new() 73 | src = attributes |> Map.get("src") 74 | alt = attributes |> Map.get("alt") 75 | title = attributes |> Map.get("title") 76 | 77 | alt = 78 | if useless_img_alt?(alt) do 79 | nil 80 | else 81 | alt 82 | end 83 | 84 | case {src, alt, title} do 85 | {nil, nil, nil} -> transform_children(children, state) 86 | {nil, nil, title} -> title 87 | {nil, alt, _} -> alt 88 | {link, nil, nil} -> format_url(link, state.homeserver) 89 | {link, nil, title} -> "#{title} <#{format_url(link, state.homeserver)}>" 90 | {link, alt, _} -> "#{alt} <#{format_url(link, state.homeserver)}>" 91 | end 92 | end 93 | 94 | def transform({"br", _, []}, _state) do 95 | "\n" 96 | end 97 | 98 | def transform({tag, _, children}, state) when tag in ["ol", "ul"] do 99 | "\n" <> transform_children(children, state) 100 | end 101 | 102 | def transform({"li", _, children}, state) do 103 | "* " <> transform_children(children, state) <> "\n" 104 | end 105 | 106 | def transform({tag, attributes, children}, state) when tag in ["font", "span"] do 107 | attributes = Map.new(attributes) 108 | fg = Map.get(attributes, "data-mx-color") 109 | bg = Map.get(attributes, "data-mx-bg-color") 110 | 111 | case {fg, bg} do 112 | {nil, nil} -> 113 | transform_children(children, state) 114 | 115 | _ -> 116 | fg = fg && String.trim_leading(fg, "#") 117 | bg = bg && String.trim_leading(bg, "#") 118 | 119 | restored_colors = get_color_code(state.color) 120 | 121 | state = %M51.Format.Matrix2Irc.State{state | color: {fg, bg}} 122 | 123 | get_color_code({fg, bg}) <> 124 | transform_children(children, state) <> restored_colors 125 | end 126 | end 127 | 128 | def transform({"mx-reply", _, _}, _color) do 129 | "" 130 | end 131 | 132 | def transform({tag, _, children}, state) do 133 | char = Map.get(@simple_tags, tag, "") 134 | children = paragraph_to_newline(children, []) 135 | 136 | state = 137 | case tag do 138 | "pre" -> %M51.Format.Matrix2Irc.State{state | preserve_whitespace: true} 139 | _ -> state 140 | end 141 | 142 | transform_children(children, state, char) 143 | end 144 | 145 | def get_color_code({fg, bg}) do 146 | case {fg, bg} do 147 | # reset 148 | {nil, nil} -> "\x0399,99" 149 | {fg, nil} -> "\x04#{fg}" 150 | # set both fg and bg, then reset fg 151 | {nil, bg} -> "\x04000000,#{bg}\x0399" 152 | {fg, bg} -> "\x04#{fg},#{bg}" 153 | end 154 | end 155 | 156 | defp transform_children(children, state, char \\ "") do 157 | Stream.concat([ 158 | [char], 159 | Stream.map(children, fn child -> transform(child, state) end), 160 | [char] 161 | ]) 162 | |> Enum.join() 163 | end 164 | 165 | defp paragraph_to_newline([], acc) do 166 | Enum.reverse(acc) 167 | end 168 | 169 | defp paragraph_to_newline([{"p", _, children1}, {"p", _, children2} | tail], acc) do 170 | paragraph_to_newline(tail, [ 171 | {"span", [], children2}, 172 | {"br", [], []}, 173 | {"span", [], children1} 174 | | acc 175 | ]) 176 | end 177 | 178 | defp paragraph_to_newline([{"p", _, text} | tail], acc) do 179 | paragraph_to_newline(tail, [ 180 | {"br", [], []}, 181 | {"span", [], text}, 182 | {"br", [], []} 183 | | acc 184 | ]) 185 | end 186 | 187 | defp paragraph_to_newline([head | tail], acc) do 188 | paragraph_to_newline(tail, [head | acc]) 189 | end 190 | 191 | @doc "Transforms a mxc:// \"URL\" into an actually usable URL." 192 | def format_url(url, homeserver \\ nil, filename \\ nil) do 193 | case URI.parse(url) do 194 | %{scheme: "mxc", host: host, path: path} -> 195 | # prefer the homeserver when available, it is more reliable than arbitrary 196 | # hosts chosen by message senders 197 | homeserver = homeserver || host 198 | 199 | base_url = M51.MatrixClient.Client.get_base_url(homeserver, M51.Config.httpoison()) 200 | 201 | case filename do 202 | nil -> 203 | "#{base_url}/_matrix/media/r0/download/#{urlquote(host)}#{path}" 204 | 205 | _ -> 206 | "#{base_url}/_matrix/media/r0/download/#{urlquote(host)}#{path}/#{urlquote(filename)}" 207 | end 208 | 209 | _ -> 210 | url 211 | end 212 | end 213 | 214 | @doc """ 215 | Returns whether the given string is a useless alt that should not 216 | be displayed (eg. a stock filename). 217 | """ 218 | def useless_img_alt?(s) do 219 | s == nil or String.match?(s, ~r/(image|unknown)\.(png|jpe?g|gif)/i) 220 | end 221 | 222 | defp urlquote(s) do 223 | M51.Matrix.Utils.urlquote(s) 224 | end 225 | end 226 | -------------------------------------------------------------------------------- /test/matrix_client/state_test.exs: -------------------------------------------------------------------------------- 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.StateTest do 18 | use ExUnit.Case 19 | doctest M51.MatrixClient.State 20 | 21 | setup do 22 | start_supervised!({M51.MatrixClient.State, {nil}}) 23 | |> Process.register(:process_matrix_state) 24 | 25 | :ok 26 | end 27 | 28 | test "canonical alias" do 29 | M51.MatrixClient.State.set_room_canonical_alias( 30 | :process_matrix_state, 31 | "!foo:example.org", 32 | "#alias1:example.org" 33 | ) 34 | 35 | assert M51.MatrixClient.State.room_canonical_alias( 36 | :process_matrix_state, 37 | "!foo:example.org" 38 | ) == "#alias1:example.org" 39 | 40 | M51.MatrixClient.State.set_room_canonical_alias( 41 | :process_matrix_state, 42 | "!foo:example.org", 43 | "#alias2:example.org" 44 | ) 45 | 46 | assert M51.MatrixClient.State.room_canonical_alias( 47 | :process_matrix_state, 48 | "!foo:example.org" 49 | ) == "#alias2:example.org" 50 | end 51 | 52 | test "default canonical alias" do 53 | assert M51.MatrixClient.State.room_canonical_alias( 54 | :process_matrix_state, 55 | "!foo:example.org" 56 | ) == nil 57 | end 58 | 59 | test "room members" do 60 | M51.MatrixClient.State.room_member_add( 61 | :process_matrix_state, 62 | "!foo:example.org", 63 | "user1:example.com", 64 | %M51.Matrix.RoomMember{display_name: "user one"} 65 | ) 66 | 67 | assert M51.MatrixClient.State.room_members(:process_matrix_state, "!foo:example.org") == 68 | %{"user1:example.com" => %M51.Matrix.RoomMember{display_name: "user one"}} 69 | 70 | M51.MatrixClient.State.room_member_add( 71 | :process_matrix_state, 72 | "!foo:example.org", 73 | "user2:example.com", 74 | %M51.Matrix.RoomMember{display_name: nil} 75 | ) 76 | 77 | assert M51.MatrixClient.State.room_members(:process_matrix_state, "!foo:example.org") == 78 | %{ 79 | "user1:example.com" => %M51.Matrix.RoomMember{display_name: "user one"}, 80 | "user2:example.com" => %M51.Matrix.RoomMember{display_name: nil} 81 | } 82 | 83 | M51.MatrixClient.State.room_member_add( 84 | :process_matrix_state, 85 | "!foo:example.org", 86 | "user2:example.com", 87 | %M51.Matrix.RoomMember{display_name: nil} 88 | ) 89 | 90 | assert M51.MatrixClient.State.room_members(:process_matrix_state, "!foo:example.org") == 91 | %{ 92 | "user1:example.com" => %M51.Matrix.RoomMember{display_name: "user one"}, 93 | "user2:example.com" => %M51.Matrix.RoomMember{display_name: nil} 94 | } 95 | 96 | M51.MatrixClient.State.room_member_add( 97 | :process_matrix_state, 98 | "!bar:example.org", 99 | "user1:example.com", 100 | %M51.Matrix.RoomMember{display_name: nil} 101 | ) 102 | 103 | assert M51.MatrixClient.State.room_members(:process_matrix_state, "!foo:example.org") == 104 | %{ 105 | "user1:example.com" => %M51.Matrix.RoomMember{display_name: "user one"}, 106 | "user2:example.com" => %M51.Matrix.RoomMember{display_name: nil} 107 | } 108 | 109 | assert M51.MatrixClient.State.room_members(:process_matrix_state, "!bar:example.org") == 110 | %{"user1:example.com" => %M51.Matrix.RoomMember{display_name: nil}} 111 | end 112 | 113 | test "default room members" do 114 | assert M51.MatrixClient.State.room_members(:process_matrix_state, "!foo:example.org") == %{} 115 | end 116 | 117 | test "irc channel" do 118 | assert M51.MatrixClient.State.room_irc_channel( 119 | :process_matrix_state, 120 | "!foo:example.org" 121 | ) == "!foo:example.org" 122 | 123 | M51.MatrixClient.State.set_room_canonical_alias( 124 | :process_matrix_state, 125 | "!foo:example.org", 126 | "#alias1:example.org" 127 | ) 128 | 129 | assert M51.MatrixClient.State.room_irc_channel( 130 | :process_matrix_state, 131 | "!foo:example.org" 132 | ) == "#alias1:example.org" 133 | 134 | M51.MatrixClient.State.set_room_canonical_alias( 135 | :process_matrix_state, 136 | "!bar:example.org", 137 | "#alias2:example.org" 138 | ) 139 | 140 | {room_id, _} = 141 | M51.MatrixClient.State.room_from_irc_channel( 142 | :process_matrix_state, 143 | "#alias1:example.org" 144 | ) 145 | 146 | assert room_id == "!foo:example.org" 147 | 148 | {room_id, _} = 149 | M51.MatrixClient.State.room_from_irc_channel( 150 | :process_matrix_state, 151 | "#alias2:example.org" 152 | ) 153 | 154 | assert room_id == "!bar:example.org" 155 | 156 | assert M51.MatrixClient.State.room_from_irc_channel( 157 | :process_matrix_state, 158 | "!roomid:example.org" 159 | ) == nil 160 | end 161 | 162 | test "runs callbacks on sync" do 163 | pid = self() 164 | 165 | M51.MatrixClient.State.queue_on_channel_sync( 166 | :process_matrix_state, 167 | "!room:example.org", 168 | fn room_id, _room -> send(pid, {:synced1, room_id}) end 169 | ) 170 | 171 | M51.MatrixClient.State.queue_on_channel_sync( 172 | :process_matrix_state, 173 | "#chan:example.org", 174 | fn room_id, _room -> send(pid, {:synced2, room_id}) end 175 | ) 176 | 177 | M51.MatrixClient.State.set_room_canonical_alias( 178 | :process_matrix_state, 179 | "!room:example.org", 180 | "#chan:example.org" 181 | ) 182 | 183 | M51.MatrixClient.State.mark_synced(:process_matrix_state, "!room:example.org") 184 | 185 | receive do 186 | msg -> assert msg == {:synced1, "!room:example.org"} 187 | end 188 | 189 | receive do 190 | msg -> assert msg == {:synced2, "!room:example.org"} 191 | end 192 | end 193 | 194 | test "runs callbacks immediately when already synced" do 195 | pid = self() 196 | 197 | M51.MatrixClient.State.mark_synced(:process_matrix_state, "!room:example.org") 198 | 199 | M51.MatrixClient.State.set_room_canonical_alias( 200 | :process_matrix_state, 201 | "!room:example.org", 202 | "#chan:example.org" 203 | ) 204 | 205 | M51.MatrixClient.State.queue_on_channel_sync( 206 | :process_matrix_state, 207 | "!room:example.org", 208 | fn room_id, _room -> send(pid, {:synced1, room_id}) end 209 | ) 210 | 211 | receive do 212 | msg -> assert msg == {:synced1, "!room:example.org"} 213 | end 214 | 215 | M51.MatrixClient.State.queue_on_channel_sync( 216 | :process_matrix_state, 217 | "#chan:example.org", 218 | fn room_id, _room -> send(pid, {:synced2, room_id}) end 219 | ) 220 | 221 | receive do 222 | msg -> assert msg == {:synced2, "!room:example.org"} 223 | end 224 | end 225 | 226 | test "runs callbacks on canonical alias when already synced" do 227 | pid = self() 228 | 229 | M51.MatrixClient.State.queue_on_channel_sync( 230 | :process_matrix_state, 231 | "#chan:example.org", 232 | fn room_id, _room -> send(pid, {:synced2, room_id}) end 233 | ) 234 | 235 | M51.MatrixClient.State.mark_synced(:process_matrix_state, "!room:example.org") 236 | 237 | M51.MatrixClient.State.set_room_canonical_alias( 238 | :process_matrix_state, 239 | "!room:example.org", 240 | "#chan:example.org" 241 | ) 242 | 243 | receive do 244 | msg -> assert msg == {:synced2, "!room:example.org"} 245 | end 246 | end 247 | end 248 | -------------------------------------------------------------------------------- /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 | * (Optional / advanced configuration) If you cannot use homeserver URL discovery, configure `homeserver-url=https://homeserver.example.org` as your IRC client's GECOS/"real name" 82 | 83 | See below for extra instructions to work with web clients. 84 | 85 | See `INSTALL.md` for a more production-oriented guide. 86 | 87 | ## End-to-end encryption 88 | 89 | Matrix2051 does not support Matrix's end-to-end encryption (E2EE), but can optionally be used with [Pantalaimon](https://github.com/matrix-org/pantalaimon). 90 | 91 | To do so, setup Pantalaimon locally, and configure `homeserverurl=http://localhost:8009` as your IRC client's GECOS/"real name". 92 | 93 | ## Architecture 94 | 95 | * `matrix2051.exs` starts M51.Application, which starts M51.Supervisor, which 96 | supervises: 97 | * `config.ex`: global config agent 98 | * `irc_server.ex`: a `DynamicSupervisor` that receives connections from IRC clients. 99 | 100 | Every time `irc_server.ex` receives a connection, it spawns `irc_conn/supervisor.ex`, 101 | which supervises: 102 | 103 | * `irc_conn/state.ex`: stores the state of the connection 104 | * `irc_conn/writer.ex`: genserver holding the socket and allowing 105 | to write lines to it (and batches of lines in the future) 106 | * `irc_conn/handler.ex`: task busy-waiting on the incoming commands 107 | from the reader, answers to the simple ones, and dispatches more complex 108 | commands 109 | * `matrix_client/state.ex`: keeps the state of the connection to a Matrix homeserver 110 | * `matrix_client/client.ex`: handles one connection to a Matrix homeserver, as a single user 111 | * `matrix_client/sender.ex`: sends events to the Matrix homeserver and with retries on failure 112 | * `matrix_client/poller.ex`: repeatedly asks the Matrix homeserver for new events (including the initial sync) 113 | * `irc_conn/reader.ex`: task busy-waiting on the incoming lines, 114 | and sends them to the handler 115 | 116 | Utilities: 117 | 118 | * `matrix/raw_client.ex`: low-level Matrix client / thin wrapper around HTTP requests 119 | * `irc/command.ex`: IRC line manipulation, including "downgrading" them for clients 120 | that don't support some capabilities. 121 | * `irc/word_wrap.ex`: generic line wrapping 122 | * `format/`: Convert between IRC's formatting and `org.matrix.custom.html` 123 | * `matrix_client/chat_history.ex`: fetches message history from Matrix, when requested 124 | by the IRC client 125 | 126 | ## Questions 127 | 128 | ### Why? 129 | 130 | There are many great IRC clients, but I can't find a Matrix client I like. 131 | Yet, some communities are moving from IRC to Matrix, so I wrote this so I can 132 | join them with a comfortable client. 133 | 134 | This is also a way to prototype the latest IRCv3 features easily, 135 | and for me to learn the Matrix protocol. 136 | 137 | ### What IRC clients are supported? 138 | 139 | In theory, any IRC client should work. In particular, I test it with 140 | [Gamja](https://git.sr.ht/~emersion/gamja/), [IRCCloud](https://www.irccloud.com/), 141 | [The Lounge](https://thelounge.chat/), and [WeeChat](https://weechat.org/). 142 | 143 | Please open an issue if your client has any issue. 144 | 145 | ### What Matrix homeservers are supported? 146 | 147 | In theory, any, as I wrote this by reading the Matrix specs. 148 | In practice, this is only tested with [Synapse](https://github.com/matrix-org/synapse/). 149 | 150 | A notable exception is registration, which uses a Synapse-specific API 151 | as Matrix itself does not specify registration. 152 | 153 | Please open an issue if you have any issue with your homeserver 154 | (a dummy login/password I can use to connect to it would be appreciated). 155 | 156 | ### Are you planning to support features X, Y, ...? 157 | 158 | At the time of writing, if both Matrix and IRC/IRCv3 support them, Matrix2051 likely will. 159 | Take a look at [the list of open 'enhancement' issues](https://github.com/progval/matrix2051/issues?q=is%3Aissue+is%3Aopen+label%3Aenhancement). 160 | 161 | A notable exception is [direct messages](https://github.com/progval/matrix2051/issues/11), 162 | because Matrix's model differs significantly from IRC's. 163 | 164 | ### Can I connect with a web client? 165 | 166 | To connect web clients, you need a websocket gateway. 167 | Matrix2051 was tested with [KiwiIRC's webircgateway](https://github.com/kiwiirc/webircgateway) 168 | (try [this patch](https://github.com/kiwiirc/webircgateway/pull/91) if you need to run it on old Go versions). 169 | 170 | Here is how you can configure it to connect to Matrix2051 with [Gamja](https://git.sr.ht/~emersion/gamja/): 171 | 172 | ```toml 173 | [fileserving] 174 | enabled = true 175 | webroot = "/path/to/gamja" 176 | 177 | 178 | [upstream.1] 179 | hostname = "localhost" 180 | port = 2051 181 | tls = false 182 | # Connection timeout in seconds 183 | timeout = 20 184 | # Throttle the lines being written by X per second 185 | throttle = 100 186 | webirc = "" 187 | serverpassword = "" 188 | ``` 189 | 190 | ### What's with the name? 191 | 192 | This is a reference to [xkcd 1782](https://xkcd.com/1782/): 193 | 194 | ![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) 195 | 196 | ### I still have a question, how can I contact you? 197 | 198 | Join [#matrix2051 at irc.interlinked.me](ircs://irc.interlinked.me/matrix2051). 199 | (No I am not eating my own dogfood, I still prefer "native" IRC.) 200 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 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 | ExUnit.start() 18 | ExUnit.start(timeout: 5000) 19 | 20 | Mox.defmock(MockHTTPoison, for: HTTPoison.Base) 21 | M51.Config.set_httpoison(MockHTTPoison) 22 | 23 | # Replaces the value defined in mix.exs, so tests don't depend on a particular 24 | # value (which may be inconvenient for forks) 25 | Application.put_env(:matrix2051, :source_code_url, "http://example.org/source.git") 26 | 27 | Logger.configure(level: :info) 28 | 29 | defmodule MockIrcConnWriter do 30 | use GenServer 31 | 32 | def start_link(args) do 33 | {test_pid} = args 34 | name = {:via, Registry, {M51.Registry, {test_pid, :irc_writer}}} 35 | GenServer.start_link(__MODULE__, args, name: name) 36 | end 37 | 38 | @impl true 39 | def init(state) do 40 | {:ok, state} 41 | end 42 | 43 | @impl true 44 | def handle_call(arg, _from, state) do 45 | {test_pid} = state 46 | send(test_pid, arg) 47 | {:reply, :ok, state} 48 | end 49 | end 50 | 51 | defmodule MockMatrixState do 52 | use Agent 53 | 54 | def start_link(args) do 55 | {test_pid} = args 56 | 57 | name = {:via, Registry, {M51.Registry, {test_pid, :matrix_state}}} 58 | 59 | Agent.start_link( 60 | fn -> 61 | %M51.MatrixClient.State{ 62 | rooms: %{ 63 | "!room_id:example.org" => %M51.Matrix.RoomState{ 64 | synced: true, 65 | canonical_alias: "#existing_room:example.org", 66 | members: %{ 67 | "user1:example.org" => %M51.Matrix.RoomMember{display_name: "user one"}, 68 | "user2:example.com" => %M51.Matrix.RoomMember{} 69 | } 70 | } 71 | } 72 | } 73 | end, 74 | name: name 75 | ) 76 | end 77 | end 78 | 79 | defmodule MockMatrixClient do 80 | use GenServer 81 | 82 | def start_link(args) do 83 | {sup_pid} = args 84 | name = {:via, Registry, {M51.Registry, {sup_pid, :matrix_client}}} 85 | GenServer.start_link(__MODULE__, args, name: name) 86 | end 87 | 88 | @impl true 89 | def init({sup_pid}) do 90 | {:ok, 91 | %M51.MatrixClient.Client{ 92 | state: :initial_state, 93 | irc_pid: sup_pid, 94 | args: [] 95 | }} 96 | end 97 | 98 | @impl true 99 | def handle_call({:connect, local_name, hostname, password, nil}, _from, state) do 100 | case {hostname, password} do 101 | {"i-hate-passwords.example.org", _} -> 102 | {:reply, {:error, :no_password_flow, "No password flow"}, state} 103 | 104 | {_, "correct password"} -> 105 | state = %{state | local_name: local_name, hostname: hostname} 106 | {:reply, {:ok}, %{state | state: :connected}} 107 | 108 | {_, "invalid password"} -> 109 | {:reply, {:error, :invalid_password, "Invalid password"}, state} 110 | end 111 | end 112 | 113 | @impl true 114 | def handle_call({:register, local_name, hostname, password}, _from, state) do 115 | case {local_name, password} do 116 | {"user", "my p4ssw0rd"} -> 117 | state = %{state | state: :connected, local_name: local_name, hostname: hostname} 118 | {:reply, {:ok, local_name <> ":" <> hostname}, state} 119 | 120 | {"reserveduser", _} -> 121 | {:reply, {:error, :exclusive, "This username is reserved"}, state} 122 | end 123 | end 124 | 125 | @impl true 126 | def handle_call({:join_room, room_alias}, _from, state) do 127 | case room_alias do 128 | "#existing_room:example.org" -> {:reply, {:ok, "!existing_room_id:example.org"}, state} 129 | end 130 | end 131 | 132 | @impl true 133 | def handle_call({:dump_state}, _from, state) do 134 | {:reply, state, state} 135 | end 136 | 137 | @impl true 138 | def handle_call({:get_event_context, _channel, _event_id, limit}, _from, state) do 139 | event1 = %{ 140 | "content" => %{"body" => "first message", "msgtype" => "m.text"}, 141 | "event_id" => "$event1", 142 | "origin_server_ts" => 1_632_946_233_579, 143 | "sender" => "@nick:example.org", 144 | "type" => "m.room.message", 145 | "unsigned" => %{} 146 | } 147 | 148 | event2 = %{ 149 | "content" => %{"body" => "second message", "msgtype" => "m.text"}, 150 | "event_id" => "$event2", 151 | "origin_server_ts" => 1_632_946_233_579, 152 | "sender" => "@nick:example.org", 153 | "type" => "m.room.message", 154 | "unsigned" => %{} 155 | } 156 | 157 | event3 = %{ 158 | "content" => %{"body" => "third message", "msgtype" => "m.text"}, 159 | "event_id" => "$event3", 160 | "origin_server_ts" => 1_632_946_233_579, 161 | "sender" => "@nick:example.org", 162 | "type" => "m.room.message", 163 | "unsigned" => %{} 164 | } 165 | 166 | event4 = %{ 167 | "content" => %{"body" => "fourth message", "msgtype" => "m.text"}, 168 | "event_id" => "$event4", 169 | "origin_server_ts" => 1_632_946_233_579, 170 | "sender" => "@nick:example.org", 171 | "type" => "m.room.message", 172 | "unsigned" => %{} 173 | } 174 | 175 | event5 = %{ 176 | "content" => %{"body" => "fifth message", "msgtype" => "m.text"}, 177 | "event_id" => "$event5", 178 | "origin_server_ts" => 1_632_946_233_579, 179 | "sender" => "@nick:example.org", 180 | "type" => "m.room.message", 181 | "unsigned" => %{} 182 | } 183 | 184 | reply = 185 | case limit do 186 | 0 -> 187 | %{ 188 | "events_before" => [], 189 | "event" => event3, 190 | "events_after" => [] 191 | } 192 | 193 | 1 -> 194 | %{ 195 | "events_before" => [event2], 196 | "event" => event3, 197 | "events_after" => [] 198 | } 199 | 200 | 2 -> 201 | %{ 202 | "events_before" => [event2], 203 | "event" => event3, 204 | "events_after" => [event4] 205 | } 206 | 207 | 3 -> 208 | %{ 209 | # reverse-chronological order, as per the spec 210 | "events_before" => [event2, event1], 211 | "event" => event3, 212 | "events_after" => [event4] 213 | } 214 | 215 | n when n >= 4 -> 216 | %{ 217 | # reverse-chronological order, as per the spec 218 | "events_before" => [event2, event1], 219 | "event" => event3, 220 | "events_after" => [event4, event5] 221 | } 222 | end 223 | 224 | {:reply, {:ok, reply}, state} 225 | end 226 | 227 | @impl true 228 | def handle_call({:get_latest_events, _channel, limit}, _from, state) do 229 | events = 230 | [ 231 | %{ 232 | "content" => %{"body" => "first message", "msgtype" => "m.text"}, 233 | "event_id" => "$event1", 234 | "origin_server_ts" => 1_632_946_233_579, 235 | "sender" => "@nick:example.org", 236 | "type" => "m.room.message", 237 | "unsigned" => %{} 238 | }, 239 | %{ 240 | "content" => %{"body" => "second message", "msgtype" => "m.text"}, 241 | "event_id" => "$event2", 242 | "origin_server_ts" => 1_632_946_233_579, 243 | "sender" => "@nick:example.org", 244 | "type" => "m.room.message", 245 | "unsigned" => %{} 246 | }, 247 | %{ 248 | "content" => %{"body" => "third message", "msgtype" => "m.text"}, 249 | "event_id" => "$event3", 250 | "origin_server_ts" => 1_632_946_233_579, 251 | "sender" => "@nick:example.org", 252 | "type" => "m.room.message", 253 | "unsigned" => %{} 254 | }, 255 | %{ 256 | "content" => %{"body" => "fourth message", "msgtype" => "m.text"}, 257 | "event_id" => "$event4", 258 | "origin_server_ts" => 1_632_946_233_579, 259 | "sender" => "@nick:example.org", 260 | "type" => "m.room.message", 261 | "unsigned" => %{} 262 | }, 263 | %{ 264 | "content" => %{"body" => "fifth message", "msgtype" => "m.text"}, 265 | "event_id" => "$event5", 266 | "origin_server_ts" => 1_632_946_233_579, 267 | "sender" => "@nick:example.org", 268 | "type" => "m.room.message", 269 | "unsigned" => %{} 270 | } 271 | ] 272 | # Keep the last ones 273 | |> Enum.slice(-limit..-1) 274 | # "For dir=b events will be in reverse-chronological order" 275 | |> Enum.reverse() 276 | 277 | {:reply, {:ok, %{"state" => [], "chunk" => events}}, state} 278 | end 279 | 280 | :w 281 | 282 | @impl true 283 | def handle_call({:is_valid_alias, _room_id, "#invalidalias:example.org"}, _from, state) do 284 | {:reply, false, state} 285 | end 286 | 287 | @impl true 288 | def handle_call({:is_valid_alias, _room_id, _room_alias}, _from, state) do 289 | {:reply, true, state} 290 | end 291 | 292 | @impl true 293 | def handle_call(msg, _from, state) do 294 | %M51.MatrixClient.Client{irc_pid: irc_pid} = state 295 | send(irc_pid, msg) 296 | {:reply, {:ok, nil}, state} 297 | end 298 | end 299 | -------------------------------------------------------------------------------- /lib/matrix_client/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.MatrixClient.State do 18 | @moduledoc """ 19 | Stores the state of a Matrix client (access token, joined rooms, ...) 20 | """ 21 | 22 | defstruct [ 23 | :rooms, 24 | # current value of the 'since' parameter to /_matrix/client/r0/sync 25 | poll_since: nil, 26 | # events handled since the last update to :poll_since (the poller updates 27 | # this set as it handles events in a batch; then updates :poll_since 28 | # an resets this set when it is done with a batch). 29 | # Stored as a Map from room ids to the set of event ids. 30 | handled_events: Map.new(), 31 | # %{channel name => list of callbacks to run when a room 32 | # with that channel name is completely synced } 33 | channel_sync_callbacks: Map.new() 34 | ] 35 | 36 | use Agent 37 | 38 | @emptyroom %M51.Matrix.RoomState{} 39 | 40 | def start_link(opts) do 41 | {sup_pid} = opts 42 | 43 | Agent.start_link(fn -> %M51.MatrixClient.State{rooms: %{}} end, 44 | name: {:via, Registry, {M51.Registry, {sup_pid, :matrix_state}}} 45 | ) 46 | end 47 | 48 | defp update_room(pid, room_id, fun) do 49 | Agent.update(pid, fn state -> 50 | room = Map.get(state.rooms, room_id, @emptyroom) 51 | room = fun.(room) 52 | %{state | rooms: Map.put(state.rooms, room_id, room)} 53 | end) 54 | end 55 | 56 | def set_room_canonical_alias(pid, room_id, new_canonical_alias) do 57 | Agent.get_and_update(pid, fn state -> 58 | room = Map.get(state.rooms, room_id, @emptyroom) 59 | old_canonical_alias = room.canonical_alias 60 | room = %{room | canonical_alias: new_canonical_alias} 61 | 62 | remaining_callbacks = state.channel_sync_callbacks 63 | 64 | remaining_callbacks = 65 | if room.synced do 66 | {room_callbacks, remaining_callbacks} = 67 | Map.pop(remaining_callbacks, room.canonical_alias, []) 68 | 69 | room_callbacks |> Enum.map(fn cb -> cb.(room_id, room) end) 70 | remaining_callbacks 71 | else 72 | remaining_callbacks 73 | end 74 | 75 | {old_canonical_alias, 76 | %{ 77 | state 78 | | rooms: Map.put(state.rooms, room_id, room), 79 | channel_sync_callbacks: remaining_callbacks 80 | }} 81 | end) 82 | end 83 | 84 | def room_canonical_alias(pid, room_id) do 85 | Agent.get(pid, fn state -> Map.get(state.rooms, room_id, @emptyroom).canonical_alias end) 86 | end 87 | 88 | @doc """ 89 | Adds a member to the room and returns true iff it was already there 90 | 91 | `member` must be a `M51.Matrix.RoomMember` structure. 92 | """ 93 | def room_member_add(pid, room_id, userid, member) do 94 | Agent.get_and_update(pid, fn state -> 95 | room = Map.get(state.rooms, room_id, @emptyroom) 96 | 97 | if Map.has_key?(room.members, userid) do 98 | {true, state} 99 | else 100 | room = %{room | members: Map.put(room.members, userid, member)} 101 | {false, %{state | rooms: Map.put(state.rooms, room_id, room)}} 102 | end 103 | end) 104 | end 105 | 106 | @doc """ 107 | Removes a member from the room and returns true iff it was already there 108 | """ 109 | def room_member_del(pid, room_id, userid) do 110 | Agent.get_and_update(pid, fn state -> 111 | room = Map.get(state.rooms, room_id, @emptyroom) 112 | 113 | if Map.has_key?(room.members, userid) do 114 | room = %{room | members: Map.delete(room.members, userid)} 115 | {true, %{state | rooms: Map.put(state.rooms, room_id, room)}} 116 | else 117 | {false, state} 118 | end 119 | end) 120 | end 121 | 122 | @doc """ 123 | Returns [member: %{room_id => %M51.Matrix.RoomMember{...}}] 124 | """ 125 | def user(pid, user_id) do 126 | Agent.get(pid, fn state -> 127 | [ 128 | member: 129 | state.rooms 130 | |> Map.to_list() 131 | |> Enum.map(fn {room_id, room} -> {room_id, Map.get(room.members, user_id)} end) 132 | |> Enum.filter(fn {_room_id, member} -> member != nil end) 133 | |> Map.new() 134 | ] 135 | end) 136 | end 137 | 138 | @doc """ 139 | Returns %{user_id => %M51.Matrix.RoomMember{...}} 140 | """ 141 | def room_members(pid, room_id) do 142 | Agent.get(pid, fn state -> Map.get(state.rooms, room_id, @emptyroom).members end) 143 | end 144 | 145 | @doc """ 146 | Returns a M51.Matrix.RoomMember structure or nil 147 | """ 148 | def room_member(pid, room_id, user_id) do 149 | Agent.get(pid, fn state -> 150 | members = Map.get(state.rooms, room_id, @emptyroom).members 151 | Map.get(members, user_id) 152 | end) 153 | end 154 | 155 | def set_room_name(pid, room_id, name) do 156 | update_room(pid, room_id, fn room -> %{room | name: name} end) 157 | end 158 | 159 | def room_name(pid, room_id) do 160 | Agent.get(pid, fn state -> Map.get(state.rooms, room_id, @emptyroom).name end) 161 | end 162 | 163 | def set_room_topic(pid, room_id, topic) do 164 | update_room(pid, room_id, fn room -> %{room | topic: topic} end) 165 | end 166 | 167 | def room_topic(pid, room_id) do 168 | Agent.get(pid, fn state -> Map.get(state.rooms, room_id, @emptyroom).topic end) 169 | end 170 | 171 | @doc """ 172 | Returns the IRC channel name for the room 173 | """ 174 | def room_irc_channel(pid, room_id) do 175 | case room_canonical_alias(pid, room_id) do 176 | nil -> room_id 177 | canonical_alias -> canonical_alias 178 | end 179 | end 180 | 181 | @doc """ 182 | Returns the {room_id, room} corresponding the to given channel name, or nil. 183 | """ 184 | def room_from_irc_channel(pid, channel) do 185 | Agent.get(pid, fn state -> 186 | _room_from_irc_channel(state, channel) 187 | end) 188 | end 189 | 190 | defp _room_from_irc_channel(state, channel) do 191 | state.rooms 192 | |> Map.to_list() 193 | |> Enum.find_value(fn {room_id, room} -> 194 | if room.canonical_alias == channel || room_id == channel do 195 | {room_id, room} 196 | else 197 | nil 198 | end 199 | end) 200 | end 201 | 202 | @doc """ 203 | Takes a callback to run as soon as the room matching the given channel name 204 | is completely synced. 205 | """ 206 | def queue_on_channel_sync(pid, channel, callback) do 207 | Agent.update(pid, fn state -> 208 | case _room_from_irc_channel(state, channel) do 209 | {room_id, %M51.Matrix.RoomState{synced: true} = room} -> 210 | # We already have the room, call immediately 211 | callback.(room_id, room) 212 | state 213 | 214 | _ -> 215 | # We don't have the member list yet, queue it. 216 | %{ 217 | state 218 | | channel_sync_callbacks: 219 | Map.put(state.channel_sync_callbacks, channel, [ 220 | callback | Map.get(state.channel_sync_callbacks, channel, []) 221 | ]) 222 | } 223 | end 224 | end) 225 | end 226 | 227 | @doc """ 228 | Updates the state to mark a room is completely synced, and runs all callbacks 229 | that were waiting on it being synced. 230 | """ 231 | def mark_synced(pid, room_id) do 232 | Agent.update(pid, fn state -> 233 | room = Map.get(state.rooms, room_id, @emptyroom) 234 | room = %{room | synced: true} 235 | remaining_callbacks = state.channel_sync_callbacks 236 | 237 | # Run callbacks registered for the room_id itself 238 | {room_callbacks, remaining_callbacks} = Map.pop(remaining_callbacks, room_id, []) 239 | room_callbacks |> Enum.map(fn cb -> cb.(room_id, room) end) 240 | 241 | # Run callbacks registered for the canonical alias 242 | remaining_callbacks = 243 | case room.canonical_alias do 244 | nil -> 245 | remaining_callbacks 246 | 247 | _ -> 248 | {room_callbacks, remaining_callbacks} = 249 | Map.pop(remaining_callbacks, room.canonical_alias, []) 250 | 251 | room_callbacks |> Enum.map(fn cb -> cb.(room_id, room) end) 252 | remaining_callbacks 253 | end 254 | 255 | %{ 256 | state 257 | | rooms: Map.put(state.rooms, room_id, room), 258 | channel_sync_callbacks: remaining_callbacks 259 | } 260 | end) 261 | end 262 | 263 | def poll_since_marker(pid) do 264 | Agent.get(pid, fn state -> state.poll_since end) 265 | end 266 | 267 | def handled_events(pid, room_id) do 268 | Agent.get(pid, fn state -> Map.get(state.handled_events, room_id) || MapSet.new() end) 269 | end 270 | 271 | @doc """ 272 | Updates the 'since' marker, and resets the 'handled_events' set. 273 | """ 274 | def update_poll_since_marker(pid, new_since_marker) do 275 | Agent.update(pid, fn state -> 276 | %{state | poll_since: new_since_marker, handled_events: Map.new()} 277 | end) 278 | end 279 | 280 | def mark_handled_event(pid, room_id, event_id) do 281 | if event_id != nil do 282 | Agent.update(pid, fn state -> 283 | handled_events = 284 | Map.update(state.handled_events, room_id, nil, fn event_ids -> 285 | MapSet.put(event_ids || MapSet.new(), event_id) 286 | end) 287 | 288 | %{state | handled_events: handled_events} 289 | end) 290 | end 291 | end 292 | end 293 | -------------------------------------------------------------------------------- /test/irc/command_test.exs: -------------------------------------------------------------------------------- 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.Irc.CommandTest do 18 | use ExUnit.Case 19 | doctest M51.Irc.Command 20 | 21 | test "default values" do 22 | assert M51.Irc.Command.parse("PRIVMSG #chan :hello\r\n") == 23 | {:ok, 24 | %M51.Irc.Command{ 25 | tags: %{}, 26 | source: nil, 27 | command: "PRIVMSG", 28 | params: ["#chan", "hello"] 29 | }} 30 | end 31 | 32 | test "parse leniently" do 33 | assert M51.Irc.Command.parse("@msgid=foo :nick!user@host privMSG #chan :hello\n") == 34 | {:ok, 35 | %M51.Irc.Command{ 36 | tags: %{"msgid" => "foo"}, 37 | source: "nick!user@host", 38 | command: "PRIVMSG", 39 | params: ["#chan", "hello"] 40 | }} 41 | end 42 | 43 | test "parse numeric" do 44 | assert M51.Irc.Command.parse("001 welcome\r\n") == 45 | {:ok, 46 | %M51.Irc.Command{ 47 | command: "001", 48 | params: ["welcome"] 49 | }} 50 | end 51 | 52 | test "format numeric" do 53 | assert M51.Irc.Command.format(%M51.Irc.Command{ 54 | command: "001", 55 | params: ["welcome"] 56 | }) == "001 :welcome\r\n" 57 | end 58 | 59 | test "format invalid characters" do 60 | assert M51.Irc.Command.format(%M51.Irc.Command{ 61 | source: "foo\0bar\rbaz\nqux:example.org!foo\\0bar\\rbaz\\nqux@example.org", 62 | command: "PRIVMSG", 63 | params: ["#room:example.org", "hi there"] 64 | }) == 65 | ":foo\\0bar\\rbaz\\nqux:example.org!foo\\0bar\\rbaz\\nqux@example.org PRIVMSG #room:example.org :hi there\r\n" 66 | 67 | assert M51.Irc.Command.format(%M51.Irc.Command{ 68 | source: "foo bar:example.org!foo bar@example.org", 69 | command: "PRIVMSG", 70 | params: ["#bad room:example.org", "hi there"] 71 | }) == 72 | ":foo\\sbar:example.org!foo\\sbar@example.org PRIVMSG #bad\\sroom:example.org :hi there\r\n" 73 | end 74 | 75 | test "escape message tags" do 76 | assert M51.Irc.Command.format(%M51.Irc.Command{ 77 | tags: %{"foo" => "semi;space backslash\\cr\rlf\ndone", "bar" => "baz"}, 78 | command: "TAGMSG", 79 | params: ["#chan"] 80 | }) == "@bar=baz;foo=semi\\:space\\sbackslash\\\\cr\\rlf\\ndone TAGMSG :#chan\r\n" 81 | end 82 | 83 | test "unescape message tags" do 84 | assert M51.Irc.Command.parse( 85 | "@bar=baz;foo=semi\\:space\\sbackslash\\\\cr\\rlf\\ndone TAGMSG :#chan\r\n" 86 | ) == 87 | {:ok, 88 | %M51.Irc.Command{ 89 | tags: %{"foo" => "semi;space backslash\\cr\rlf\ndone", "bar" => "baz"}, 90 | command: "TAGMSG", 91 | params: ["#chan"] 92 | }} 93 | end 94 | 95 | test "parse message tags with no value and vendored key" do 96 | assert M51.Irc.Command.parse("@msgid=foo;+example.org/tag TAGMSG #chan\r\n") == 97 | {:ok, 98 | %M51.Irc.Command{ 99 | tags: %{"msgid" => "foo", "+example.org/tag" => ""}, 100 | command: "TAGMSG", 101 | params: ["#chan"] 102 | }} 103 | end 104 | 105 | test "downgrade noop" do 106 | assert M51.Irc.Command.downgrade( 107 | %M51.Irc.Command{ 108 | command: "001", 109 | params: ["welcome"] 110 | }, 111 | [] 112 | ) == %M51.Irc.Command{ 113 | command: "001", 114 | params: ["welcome"] 115 | } 116 | end 117 | 118 | test "downgrade label" do 119 | cmd = %M51.Irc.Command{ 120 | tags: %{"label" => "abcd"}, 121 | command: "PONG", 122 | params: ["foo"] 123 | } 124 | 125 | assert M51.Irc.Command.downgrade(cmd, []) == %M51.Irc.Command{ 126 | command: "PONG", 127 | params: ["foo"] 128 | } 129 | 130 | assert M51.Irc.Command.downgrade(cmd, [:labeled_response]) == cmd 131 | end 132 | 133 | test "downgrade ack" do 134 | cmd = %M51.Irc.Command{ 135 | tags: %{"label" => "abcd"}, 136 | command: "ACK", 137 | params: [] 138 | } 139 | 140 | assert M51.Irc.Command.downgrade(cmd, []) == nil 141 | 142 | assert M51.Irc.Command.downgrade(cmd, [:labeled_response]) == cmd 143 | end 144 | 145 | test "drop ack without label" do 146 | cmd = %M51.Irc.Command{ 147 | command: "ACK", 148 | params: [] 149 | } 150 | 151 | assert M51.Irc.Command.downgrade(cmd, []) == nil 152 | 153 | assert M51.Irc.Command.downgrade(cmd, [:labeled_response]) == nil 154 | end 155 | 156 | test "downgrade account-tag" do 157 | cmd = %M51.Irc.Command{ 158 | tags: %{"account" => "abcd"}, 159 | command: "PRIVMSG", 160 | params: ["#foo", "bar"] 161 | } 162 | 163 | assert M51.Irc.Command.downgrade(cmd, []) == %M51.Irc.Command{ 164 | command: "PRIVMSG", 165 | params: ["#foo", "bar"] 166 | } 167 | 168 | assert M51.Irc.Command.downgrade(cmd, [:account_tag]) == cmd 169 | end 170 | 171 | test "downgrade client tags" do 172 | cmd = %M51.Irc.Command{ 173 | tags: %{"+foo" => "bar"}, 174 | source: "nick", 175 | command: "PRIVMSG", 176 | params: ["#foo", "hi"] 177 | } 178 | 179 | assert M51.Irc.Command.downgrade(cmd, []) == %M51.Irc.Command{ 180 | source: "nick", 181 | command: "PRIVMSG", 182 | params: ["#foo", "hi"] 183 | } 184 | 185 | assert M51.Irc.Command.downgrade(cmd, [:message_tags]) == cmd 186 | end 187 | 188 | test "downgrade TAGMSG" do 189 | cmd = %M51.Irc.Command{ 190 | tags: %{"+foo" => "bar"}, 191 | source: "nick", 192 | command: "TAGMSG", 193 | params: ["#foo"] 194 | } 195 | 196 | assert M51.Irc.Command.downgrade(cmd, []) == nil 197 | 198 | assert M51.Irc.Command.downgrade(cmd, [:message_tags]) == cmd 199 | end 200 | 201 | test "downgrade extended-join" do 202 | cmd = %M51.Irc.Command{ 203 | source: "nick", 204 | command: "JOIN", 205 | params: ["#foo", "account", "realname"] 206 | } 207 | 208 | assert M51.Irc.Command.downgrade(cmd, []) == %M51.Irc.Command{ 209 | source: "nick", 210 | command: "JOIN", 211 | params: ["#foo"] 212 | } 213 | 214 | assert M51.Irc.Command.downgrade(cmd, [:extended_join]) == cmd 215 | end 216 | 217 | test "downgrade extended-join and/or account-tag" do 218 | cmd = %M51.Irc.Command{ 219 | tags: %{"account" => "abcd"}, 220 | command: "JOIN", 221 | params: ["#foo", "account", "realname"] 222 | } 223 | 224 | assert M51.Irc.Command.downgrade(cmd, []) == %M51.Irc.Command{ 225 | tags: %{}, 226 | command: "JOIN", 227 | params: ["#foo"] 228 | } 229 | 230 | assert M51.Irc.Command.downgrade(cmd, [:extended_join]) == %M51.Irc.Command{ 231 | tags: %{}, 232 | command: "JOIN", 233 | params: ["#foo", "account", "realname"] 234 | } 235 | 236 | assert M51.Irc.Command.downgrade(cmd, [:account_tag]) == %M51.Irc.Command{ 237 | tags: %{"account" => "abcd"}, 238 | command: "JOIN", 239 | params: ["#foo"] 240 | } 241 | 242 | assert M51.Irc.Command.downgrade(cmd, [:account_tag, :extended_join]) == cmd 243 | assert M51.Irc.Command.downgrade(cmd, [:extended_join, :account_tag]) == cmd 244 | end 245 | 246 | test "downgrade echo-message and/or label" do 247 | cmd = %M51.Irc.Command{ 248 | tags: %{"label" => "abcd"}, 249 | command: "PRIVMSG", 250 | params: ["#foo", "bar"], 251 | is_echo: true 252 | } 253 | 254 | assert M51.Irc.Command.downgrade(cmd, []) == nil 255 | assert M51.Irc.Command.downgrade(cmd, [:labeled_response]) == nil 256 | 257 | assert M51.Irc.Command.downgrade(cmd, [:echo_message]) == %M51.Irc.Command{ 258 | tags: %{}, 259 | command: "PRIVMSG", 260 | params: ["#foo", "bar"], 261 | is_echo: true 262 | } 263 | 264 | assert M51.Irc.Command.downgrade(cmd, [:labeled_response, :echo_message]) == cmd 265 | assert M51.Irc.Command.downgrade(cmd, [:echo_message, :labeled_response]) == cmd 266 | end 267 | 268 | test "downgrade echo-message without label" do 269 | cmd = %M51.Irc.Command{ 270 | command: "PRIVMSG", 271 | params: ["#foo", "bar"], 272 | is_echo: true 273 | } 274 | 275 | assert M51.Irc.Command.downgrade(cmd, []) == nil 276 | assert M51.Irc.Command.downgrade(cmd, [:labeled_response]) == nil 277 | 278 | assert M51.Irc.Command.downgrade(cmd, [:echo_message]) == %M51.Irc.Command{ 279 | tags: %{}, 280 | command: "PRIVMSG", 281 | params: ["#foo", "bar"], 282 | is_echo: true 283 | } 284 | 285 | assert M51.Irc.Command.downgrade(cmd, [:labeled_response, :echo_message]) == cmd 286 | assert M51.Irc.Command.downgrade(cmd, [:echo_message, :labeled_response]) == cmd 287 | end 288 | 289 | test "downgrade userhost-in-names" do 290 | cmd = %M51.Irc.Command{ 291 | source: "server", 292 | # RPL_NAMREPLY 293 | command: "353", 294 | params: [ 295 | "nick", 296 | "=", 297 | "#foo", 298 | "nick:example.org!nick@example.org nick2:example.org!nick2@example.org" 299 | ] 300 | } 301 | 302 | assert M51.Irc.Command.downgrade(cmd, []) == %M51.Irc.Command{ 303 | source: "server", 304 | command: "353", 305 | params: ["nick", "=", "#foo", "nick:example.org nick2:example.org"] 306 | } 307 | 308 | assert M51.Irc.Command.downgrade(cmd, [:userhost_in_names]) == cmd 309 | end 310 | 311 | test "linewrap" do 312 | assert M51.Irc.Command.linewrap( 313 | %M51.Irc.Command{ 314 | command: "PRIVMSG", 315 | params: ["#chan", "hello world"] 316 | }, 317 | 25 318 | ) == [ 319 | %M51.Irc.Command{ 320 | tags: %{}, 321 | source: nil, 322 | command: "PRIVMSG", 323 | params: ["#chan", "hello "] 324 | }, 325 | %M51.Irc.Command{ 326 | tags: %{"draft/multiline-concat" => nil}, 327 | source: nil, 328 | command: "PRIVMSG", 329 | params: ["#chan", "world"] 330 | } 331 | ] 332 | end 333 | end 334 | -------------------------------------------------------------------------------- /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([~c""]) 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"" | [[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, [~c"" | [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, [~c"" | [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/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 | -------------------------------------------------------------------------------- /test/format/common_test.exs: -------------------------------------------------------------------------------- 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.FormatTest do 18 | use ExUnit.Case 19 | doctest M51.Format 20 | 21 | import Mox 22 | setup :set_mox_from_context 23 | setup :verify_on_exit! 24 | 25 | test "simple Matrix to IRC" do 26 | assert M51.Format.matrix2irc("foo") == "foo" 27 | assert M51.Format.matrix2irc("foo") == "\x02foo\x02" 28 | assert M51.Format.matrix2irc("foo") == "\x1dfoo\x1d" 29 | assert M51.Format.matrix2irc("foo") == "\x11foo\x11" 30 | assert M51.Format.matrix2irc("
foo
") == "\x11foo\x11" 31 | assert M51.Format.matrix2irc("foo bar baz") == "\x02foo \x1dbar\x1d baz\x02" 32 | 33 | assert M51.Format.matrix2irc("foo
bar") == "foo\nbar" 34 | assert M51.Format.matrix2irc("foo

bar") == "foo\n\nbar" 35 | 36 | assert M51.Format.matrix2irc("
foo
bar
") == "\x11foo\nbar\x11" 37 | assert M51.Format.matrix2irc("
foo

bar
") == "\x11foo\n\nbar\x11" 38 | end 39 | 40 | test "simple IRC to Matrix" do 41 | assert M51.Format.irc2matrix("foo") == {"foo", "foo"} 42 | assert M51.Format.irc2matrix("\x02foo\x02") == {"*foo*", "foo"} 43 | assert M51.Format.irc2matrix("\x02foo\x0f") == {"*foo*", "foo"} 44 | assert M51.Format.irc2matrix("\x02foo") == {"*foo*", "foo"} 45 | assert M51.Format.irc2matrix("\x1dfoo\x1d") == {"/foo/", "foo"} 46 | assert M51.Format.irc2matrix("\x1dfoo") == {"/foo/", "foo"} 47 | assert M51.Format.irc2matrix("\x11foo\x11") == {"`foo`", "foo"} 48 | 49 | assert M51.Format.irc2matrix("\x02foo \x1dbar\x1d baz\x02") == 50 | {"*foo /bar/ baz*", "foo bar baz"} 51 | end 52 | 53 | test "interleaved IRC to Matrix" do 54 | assert M51.Format.irc2matrix("\x02foo \x1dbar\x0f baz") == 55 | {"*foo /bar*/ baz", "foo bar baz"} 56 | 57 | assert M51.Format.irc2matrix("\x02foo \x1dbar\x02 baz\x1d qux") == 58 | {"*foo /bar* baz/ qux", "foo bar baz qux"} 59 | 60 | assert M51.Format.irc2matrix("\x1dfoo \x02bar\x0f baz") == 61 | {"/foo *bar*/ baz", "foo bar baz"} 62 | end 63 | 64 | test "Matrix colors to IRC" do 65 | assert M51.Format.matrix2irc(~s(foo)) == 66 | "\x04FF0000foo\x0399,99" 67 | 68 | assert M51.Format.matrix2irc(~s(foo)) == 69 | "\x04FF0000foo\x0399,99" 70 | 71 | assert M51.Format.matrix2irc( 72 | ~s(foo) 73 | ) == "\x04FF0000,00FF00foo\x0399,99" 74 | 75 | assert M51.Format.matrix2irc(~s(foo)) == 76 | "\x04000000,00FF00\x0399foo\x0399,99" 77 | 78 | assert M51.Format.matrix2irc( 79 | ~s(foo) <> 80 | ~s(bar) <> 81 | ~s() 82 | ) == "\x04FF0000,00FF00foo\x0400FF00,0000FFbar\x04FF0000,00FF00\x0399,99" 83 | end 84 | 85 | test "IRC basic colors to Matrix" do 86 | assert M51.Format.irc2matrix("\x034foo") == 87 | {"foo", ~s(foo)} 88 | 89 | assert M51.Format.irc2matrix("\x0304foo") == 90 | {"foo", ~s(foo)} 91 | 92 | assert M51.Format.irc2matrix("\x0304foo \x0303bar") == 93 | {"foo bar", 94 | ~s(foo ) <> 95 | ~s(bar)} 96 | 97 | assert M51.Format.irc2matrix("\x0304,03foo") == 98 | {"foo", ~s(foo)} 99 | end 100 | 101 | test "IRC hex colors to Matrix" do 102 | assert M51.Format.irc2matrix("\x04FF0000,foo\x0399,99") == 103 | {"foo", ~s(foo)} 104 | 105 | assert M51.Format.irc2matrix("\x04FF0000,00FF00foo\x0399,99") == 106 | {"foo", ~s(foo)} 107 | 108 | assert M51.Format.irc2matrix("\x04FF0000,00FF00foo\x0400FF00,0000FFbar\x04FF0000,00FF00") == 109 | {"foobar", 110 | ~s(foo) <> 111 | ~s(bar)} 112 | 113 | assert M51.Format.irc2matrix( 114 | "\x04FF0000,00FF00foo\x0400FF00,0000FFbar\x04FF0000,00FF00\x0399,99" 115 | ) == 116 | {"foobar", 117 | ~s(foo) <> 118 | ~s(bar)} 119 | end 120 | 121 | test "Matrix link to IRC" do 122 | MockHTTPoison 123 | |> expect(:get, 4, fn url -> 124 | assert url == "https://example.org/.well-known/matrix/client" 125 | 126 | {:ok, 127 | %HTTPoison.Response{ 128 | status_code: 200, 129 | body: ~s({"m.homeserver": {"base_url": "https://api.example.org"}}) 130 | }} 131 | end) 132 | |> expect(:get, 1, fn url -> 133 | assert url == "https://homeserver.org/.well-known/matrix/client" 134 | 135 | {:ok, 136 | %HTTPoison.Response{ 137 | status_code: 200, 138 | body: ~s({"m.homeserver": {"base_url": "https://api.homeserver.org"}}) 139 | }} 140 | end) 141 | 142 | assert M51.Format.matrix2irc(~s(foo)) == 143 | "foo " 144 | 145 | assert M51.Format.matrix2irc(~s(https://example.org)) == 146 | "https://example.org" 147 | 148 | assert M51.Format.matrix2irc(~s()) == "https://example.org" 149 | 150 | assert M51.Format.matrix2irc(~s()) == 151 | "https://api.example.org/_matrix/media/r0/download/example.org/foo" 152 | 153 | assert M51.Format.matrix2irc(~s(image.png)) == 154 | "https://api.example.org/_matrix/media/r0/download/example.org/foo" 155 | 156 | assert M51.Format.matrix2irc(~s()) == 157 | "an image " 158 | 159 | assert M51.Format.matrix2irc( 160 | ~s(an image) 161 | ) == 162 | "an image " 163 | 164 | assert M51.Format.matrix2irc( 165 | ~s(an image), 166 | "homeserver.org" 167 | ) == 168 | "an image " 169 | 170 | assert M51.Format.matrix2irc(~s()) == 171 | "" 172 | 173 | assert M51.Format.matrix2irc(~s()) == 174 | "an image" 175 | 176 | assert M51.Format.matrix2irc(~s(an image)) == 177 | "an image" 178 | 179 | assert M51.Format.matrix2irc(~s(foo)) == "foo" 180 | 181 | assert M51.Format.matrix2irc(~s()) == "" 182 | end 183 | 184 | test "Matrix link to IRC (404 on well-known)" do 185 | MockHTTPoison 186 | |> expect(:get, 1, fn url -> 187 | assert url == "https://example.org/.well-known/matrix/client" 188 | 189 | {:ok, 190 | %HTTPoison.Response{ 191 | status_code: 404, 192 | body: ~s(this is not JSON) 193 | }} 194 | end) 195 | 196 | assert M51.Format.matrix2irc(~s()) == 197 | "https://example.org/_matrix/media/r0/download/example.org/foo" 198 | end 199 | 200 | test "Matrix link to IRC (connection error on well-known)" do 201 | MockHTTPoison 202 | |> expect(:get, 1, fn url -> 203 | assert url == "https://example.org/.well-known/matrix/client" 204 | {:error, %HTTPoison.Error{reason: :connrefused}} 205 | end) 206 | 207 | # can log "failed with connection error [connrefused]" warning 208 | Logger.remove_backend(:console) 209 | 210 | assert M51.Format.matrix2irc(~s()) == 211 | "https://example.org/_matrix/media/r0/download/example.org/foo" 212 | 213 | Logger.add_backend(:console) 214 | end 215 | 216 | test "IRC link to Matrix" do 217 | assert M51.Format.irc2matrix("foo https://example.org") == 218 | {"foo https://example.org", 219 | ~s(foo https://example.org)} 220 | end 221 | 222 | test "Matrix list to IRC" do 223 | assert M51.Format.matrix2irc("foo
  • bar
  • baz
qux") == 224 | "foo\n* bar\n* baz\nqux" 225 | 226 | assert M51.Format.matrix2irc("foo
  1. bar
  2. baz
qux") == 227 | "foo\n* bar\n* baz\nqux" 228 | end 229 | 230 | test "Matrix newline to IRC" do 231 | assert M51.Format.matrix2irc("foo
bar") == "foo\nbar" 232 | assert M51.Format.matrix2irc("foo
bar") == "foo\nbar" 233 | assert M51.Format.matrix2irc("foo

bar") == "foo\n\nbar" 234 | assert M51.Format.matrix2irc("

foo

bar") == "foo\nbar" 235 | assert M51.Format.matrix2irc("foo\nbar") == "foo bar" 236 | assert M51.Format.matrix2irc("foo\n \nbar") == "foo bar" 237 | assert M51.Format.matrix2irc("

foo

\n

bar

") == "foo\nbar" 238 | assert M51.Format.matrix2irc("

foo

\n

bar

\n

baz

") == "foo\nbar\nbaz" 239 | end 240 | 241 | test "IRC newline to Matrix" do 242 | assert M51.Format.irc2matrix("foo\nbar") == {"foo\nbar", "foo
bar"} 243 | end 244 | 245 | test "mx-reply to IRC" do 246 | assert M51.Format.matrix2irc( 247 | "
In reply to @nick:example.org
first message
second message" 248 | ) == "second message" 249 | end 250 | 251 | test "Matrix mentions to IRC" do 252 | # Format emitted by Element and many other apps: 253 | assert M51.Format.matrix2irc( 254 | "user: mention" 255 | ) == "user:example.org: mention" 256 | 257 | assert M51.Format.matrix2irc( 258 | "mentioning user" 259 | ) == "mentioning user:example.org" 260 | 261 | # Fails because mochiweb_html drops the space, see: 262 | # https://github.com/mochi/mochiweb/issues/166 263 | # assert M51.Format.matrix2irc( 264 | # "mentioning user1 user2" 265 | # ) == "mentioning user1:example.org user2:example.org" 266 | 267 | # Correct format according to the spec: 268 | assert M51.Format.matrix2irc( 269 | "mentioning correctly encoded user" 270 | ) == "mentioning correctlyencoded:example.org" 271 | end 272 | 273 | test "IRC mentions to Matrix" do 274 | assert M51.Format.irc2matrix("user:example.org: mention", ["foo"]) == 275 | {"user:example.org: mention", "user:example.org: mention"} 276 | 277 | assert M51.Format.irc2matrix("user:example.org: mention", ["foo", "user:example.org"]) == 278 | {"user:example.org: mention", 279 | "user: mention"} 280 | 281 | assert M51.Format.irc2matrix("mentioning user:example.org", ["foo"]) == 282 | {"mentioning user:example.org", "mentioning user:example.org"} 283 | 284 | assert M51.Format.irc2matrix("user:example.org: mention", ["foo", "user:example.org"]) == 285 | {"user:example.org: mention", 286 | "user: mention"} 287 | 288 | assert M51.Format.irc2matrix("mentioning user:example.org", ["foo", "user:example.org"]) == 289 | {"mentioning user:example.org", 290 | "mentioning user"} 291 | 292 | assert M51.Format.irc2matrix("mentioning EarlyAdopter:example.org", [ 293 | "foo", 294 | "EarlyAdopter:example.org" 295 | ]) == 296 | {"mentioning EarlyAdopter:example.org", 297 | "mentioning EarlyAdopter"} 298 | end 299 | 300 | test "Matrix room mentions to IRC" do 301 | assert M51.Format.matrix2irc( 302 | "join #room" 303 | ) == "join #room:example.org" 304 | 305 | assert M51.Format.matrix2irc( 306 | "join #room" 307 | ) == "join #room:example.org" 308 | 309 | assert M51.Format.matrix2irc( 310 | "join #room" 311 | ) == "join !room:example.org" 312 | 313 | assert M51.Format.matrix2irc( 314 | "join #room" 315 | ) == "join !room:example.org" 316 | 317 | assert M51.Format.matrix2irc( 318 | "join #room" 319 | ) == "join #room:example.org" 320 | 321 | assert M51.Format.matrix2irc( 322 | "join #room" 323 | ) == "join #room:example.org" 324 | 325 | assert M51.Format.matrix2irc( 326 | "join #room" 327 | ) == "join #room:example.org" 328 | 329 | assert M51.Format.matrix2irc( 330 | "join #room" 331 | ) == "join #room:example.org" 332 | end 333 | 334 | test "Corrupt matrix.to link" do 335 | assert M51.Format.matrix2irc("join oh no") == 336 | "join oh no " 337 | end 338 | 339 | test "HTML comment" do 340 | assert M51.Format.matrix2irc("foo baz") == "foo baz" 341 | end 342 | end 343 | -------------------------------------------------------------------------------- /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, proxy}, _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 = proxy || 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-(?