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