├── .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 | 
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 | 
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(
)) ==
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(
)
161 | ) ==
162 | "an image "
163 |
164 | assert M51.Format.matrix2irc(
165 | ~s(
),
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(
)) ==
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("fooqux") ==
224 | "foo\n* bar\n* baz\nqux"
225 |
226 | assert M51.Format.matrix2irc("foo- bar
- 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
\nbar
") == "foo\nbar"
238 | assert M51.Format.matrix2irc("foo
\nbar
\nbaz
") == "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-(?