async-matrix-bridge 2.0.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a39da8f7af838f517e3a60456691bf8dbcf5f69945a3f1f196bc2383ba5d3c07
4
- data.tar.gz: fa6179dba22657736ba68f793838df46afcf72e861c25a9291e1677534910681
3
+ metadata.gz: 69434c46fa54930571a05c7cedf4797cdc1e608494f0c785c6c8c87a3dfe7e7d
4
+ data.tar.gz: e708c9852ea6a8baf87ce6651f09edb5a314da5fe3fb2394968654d95aec8808
5
5
  SHA512:
6
- metadata.gz: 730ea22e8169a98f4e7a60da0db81af72769253a7fa9d0d288d3f33303323e2c1a09f8a00b2ff3187e86dfe757fee4b5baaba637d6dbe5e7c8cde4f96997c4ac
7
- data.tar.gz: 30aa42aeff5abcee92b2707538e081797f89a81bf5f2dea2188d9948d7dfc4774dcda309c6dc46f7a793e3e4bda33d446e5ec78bbebec69ee3f4eb2e30fe2e9d
6
+ metadata.gz: 9d567fef4705acd06c3e50daad570ce07770236c864a9b606afd90c90cb594beee718c2d2fcc978bb16a1495e81461ad34beb053ccd18a5a8664679135196fa5
7
+ data.tar.gz: a0545367381d298e5f66a82cf3ffdb485fced43adc30bd66fd2580878d7364673cd9262558e4941389ab46ee007d5a3f0a0d8f7fc10b62635b62610a025dc1b4
data/CHANGELOG.md ADDED
@@ -0,0 +1,161 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+
11
+ ## [3.0.0] - 2026-10-13
12
+
13
+ A second ground-up pass, this time with async-rest as the only reference. The
14
+ gem is five classes, the server side is middleware rather than a per-request
15
+ object, and the events are async-matrix's own event classes rather than Hashes.
16
+
17
+ ### Added
18
+
19
+ - `Bridge::Service` — a `Protocol::HTTP::Middleware` answering the application
20
+ service API, so it is served by an `Async::HTTP::Server` with no Rack
21
+ anywhere. One `case` on method and path, authentication by constant-time
22
+ compare of a Bearer token or the legacy `?access_token=`, and a `404` for
23
+ anything outside `/_matrix/app/v1` decided before authentication, so a
24
+ `/healthz` beside it is a `404` rather than a `403`. The user, room and
25
+ third-party queries are three blocks rather than three routes.
26
+
27
+ - `Bridge::Transaction` — one pushed transaction as an
28
+ `Async::Matrix::Representation`, parsed with async-rest's own JSON parser and
29
+ typed through async-matrix's event registry. `each_room` groups a
30
+ transaction's events by room, in the order they arrived.
31
+
32
+ - `Bridge::Puppet` — one user the service may act as, with `client`, `on` for
33
+ a device and `register!` / `device!` for provisioning it. A localpart outside
34
+ the registration's namespaces is refused locally.
35
+
36
+ - `Registration#as` — identity assertion, as a resource derived with `#with`:
37
+ `user_id`, `device_id` (v1.17) and `ts` (v1.3) are parameters on the request
38
+ rather than a login. `bot` and `puppet` name the two identities a service
39
+ has.
40
+
41
+ - `examples/bridge.rb` — a working two-way bridge between Matrix and a line
42
+ protocol, encrypted rooms included, and `examples/puppeting.rb` for identity
43
+ assertion on its own. The suite runs both against the homeserver in
44
+ `synapse/`, and asks the bridge whether each side heard the other.
45
+
46
+ ### Changed
47
+
48
+ - `Registration.new(value, homeserver:)` and `Registration.load(path,
49
+ homeserver:)` take the homeserver URL alone. `domain:` is gone, derived from
50
+ it with `URI.parse` so the two cannot disagree.
51
+
52
+ - `Registration#homeserver` is the URL string rather than a `Homeserver`
53
+ struct; `#domain` is the derived server name. The `Homeserver` and
54
+ `Appservice` structs are gone, so `registration.homeserver.address`,
55
+ `.homeserver.domain` and `registration.appservice.*` become
56
+ `registration.homeserver`, `.domain` and the readers on `Registration`
57
+ itself.
58
+
59
+ - `Registration#raw` → `#value`, `Registration#bot_mxid` → `#sender` returning
60
+ the full MXID, `Registration#receive_ephemeral?` → `#ephemeral?`.
61
+
62
+ - Namespace regexes match the whole of a name rather than part of one. A
63
+ service asks `covers?` to decide which senders are its own, and unanchored,
64
+ `@_bridge_.*` claimed `@notours_bridge_x:localhost` as well.
65
+
66
+ - `async-matrix` is now `~> 4.0`, and `protocol-http` is a declared dependency
67
+ since a `Service` is one of its middlewares.
68
+
69
+ ### Removed
70
+
71
+ - `Bridge::Endpoint`, and with it Rack, the Rack env input, the `concurrency:`
72
+ semaphore and the symbol-keyed Hashes. Events are objects a pattern reads by
73
+ class and field together.
74
+
75
+ - `json_schemer` and the vendored registration schemas, and with them
76
+ `Registration.schema` and `Registration.validate!`. A registration is
77
+ validated by what it is asked for: the four required fields are checked at
78
+ construction, anything else is reached through `#value`.
79
+
80
+ - `Registration#url`, `Registration#protocols`, `Registration#rate_limited?`
81
+ and `Registration#sender_localpart`. Readers for fields this service does not
82
+ act on, which the file still carries and `#value` still exposes.
83
+
84
+
85
+ ## [2.0.0] - 2026-10-09
86
+
87
+ A ground-up replacement of the application service API. Everything is now one
88
+ class, `Bridge::Endpoint`, driven by Ruby pattern matching, plus
89
+ `Bridge::Registration` for configuration. The Grape-and-handler-objects stack
90
+ is gone.
91
+
92
+ ### Added
93
+
94
+ - `Bridge::Endpoint` — the whole AS wire protocol in one class. Built per
95
+ request from a Rack env *or* a `Protocol::HTTP::Request`, returning the
96
+ matching response type, so it serves a Rack stack and a bare
97
+ `Async::HTTP::Server` equally. It also defines `to_ary` so it can be
98
+ returned straight from a Rack router block, and `handled?` so it works as a
99
+ router's fall-through. Events reach the matcher block as symbol-keyed
100
+ Hashes, because Ruby hash patterns match Symbol keys only — which is what
101
+ lets `case/in` destructure nested `content:` with no `deconstruct_keys`
102
+ anywhere.
103
+
104
+ - Concurrent dispatch. The events of a transaction run in their own fibers
105
+ under a bounded `Async::Semaphore` (`concurrency:`, default 8) and are all
106
+ awaited before the response, instead of running sequentially inline. Outside
107
+ a reactor dispatch falls back to sequential so the endpoint still works
108
+ under `Rack::MockRequest`.
109
+
110
+ - `Bridge::Registration` — the registration file, validated against
111
+ `registration.json` and `namespace_list.json` vendored verbatim from
112
+ matrix-org/matrix-spec (`data/api/application-service/definitions/`). Those
113
+ are already JSON Schema draft 2020-12. Synapse ships no schema for this file
114
+ — it validates by hand in `synapse/config/appservice.py` — so the spec is
115
+ the only machine-readable source. Takes `homeserver:` and `domain:` as
116
+ required arguments and exposes `covers?`/`exclusive?` for namespaces.
117
+
118
+ ### Changed
119
+
120
+ - **Namespace flattened.** `Async::Matrix::Bridge::ApplicationService::*` →
121
+ `Async::Matrix::Bridge::*`. The three classes sit directly on `Bridge`:
122
+ `Bridge::Endpoint`, `Bridge::Registration`, `Bridge::TransactionStore`.
123
+ There is no sub-namespace because there is nothing to distinguish it from
124
+ — the gem is the appservice protocol and nothing else. Bridge *domain*
125
+ concepts (portals, ghosts, provisioning) would be new peers if they ever
126
+ arrive, and the remote-network half of a bridge is inherently
127
+ application-specific.
128
+
129
+ ### Removed
130
+
131
+ - `Server`, `Server::Grape`, `Bot`, `Bot::Handler`, `Bot::Context`,
132
+ `Dispatcher`, `TransactionHandler` and `Transaction`. `Bridge::Endpoint`
133
+ replaces all of them. The `#event_types`/`#call` handler duck-type, the
134
+ `on` DSL with its `msgtype:`/`not_from:` filters, handler registration and
135
+ the `thirdparty` duck-type are all gone: filtering is now `case/in` with
136
+ guards, and the long-standing `Dispatcher`/`TransactionHandler`
137
+ double-naming is resolved by deleting both.
138
+
139
+ - `Config` and its 17-file mautrix bridgev2 JSON Schema suite.
140
+ `Bridge::Registration` replaces it, validating the application service
141
+ registration file against the Matrix spec's own schema. Configuration a
142
+ registration does not describe — `database`, `encryption`, `permissions`,
143
+ `backfill` — is no longer modelled here; `Config` remains available at the
144
+ `v1.0.0` tag.
145
+
146
+ - Grape is no longer a dependency. `Bridge::Endpoint` speaks Rack and
147
+ Protocol::HTTP directly.
148
+
149
+ ### Fixed
150
+
151
+ - **Transactions were silently dropped.** `json_body` re-read `rack.input`
152
+ after Grape's formatter middleware had already consumed it, and that
153
+ middleware only rewinds when the input responds to `#rewind` — which rack 3
154
+ streaming input (protocol-rack, so falcon and `Async::HTTP`) does not. Every
155
+ transaction parsed as `{}` and dispatched nothing while answering `200`. It
156
+ went unnoticed because `Rack::MockRequest` supplies a rewindable `StringIO`.
157
+
158
+ - **A failing handler lost its events permanently.** The dispatcher logged
159
+ the exception, recorded the transaction id and returned `200`, so the
160
+ homeserver's retry was deduplicated away. `Bridge::Endpoint` answers `500`
161
+ and does not record the id, leaving the transaction retryable.
data/README.md CHANGED
@@ -5,190 +5,127 @@
5
5
  [![License](https://img.shields.io/github/license/general-intelligence-systems/async-matrix-bridge)](https://github.com/general-intelligence-systems/async-matrix-bridge/blob/main/LICENSE)
6
6
  [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.3-red)](https://www.ruby-lang.org)
7
7
 
8
- Async-native [Matrix](https://matrix.org) Application Service SDK for Ruby -- the server side of a bridge or bot. Built on [async-matrix](https://github.com/general-intelligence-systems/async-matrix) and the [Socketry](https://github.com/socketry) ecosystem (`async`, `async-http`). No threads, no callbacks -- just fibers.
8
+ The application service side of [Matrix](https://matrix.org), for Ruby: the half of a bridge the homeserver pushes to, built on [async-matrix](https://github.com/general-intelligence-systems/async-matrix) and so on [async-rest](https://github.com/socketry/async-rest).
9
9
 
10
- Your homeserver `PUT`s transactions of events at your service; this gem authenticates them with a constant-time token compare, deduplicates them by transaction ID, and hands each event to a block you pattern match on -- concurrently, in its own fiber. Calls back to the homeserver go through async-matrix's `Client`.
11
-
12
- ## Install
10
+ A homeserver `PUT`s a transaction of events at your service. This gem authenticates it, drops a repeat of one it has already answered, types every event with async-matrix's own event classes and hands the transaction to your block. Everything the service says back to the homeserver it says through an async-matrix `Client`, as the user it is pretending to be.
13
11
 
14
12
  ```ruby
15
13
  gem "async-matrix-bridge"
16
14
  ```
17
15
 
18
- An `Endpoint` is not a Rack app and needs no Rack server. Add one only if you
19
- want to run it in a Rack stack:
16
+ ## The gem
20
17
 
21
- ```ruby
22
- gem "ratalada", "~> 4.0"
23
- gem "protocol-rack"
24
- ```
18
+ Five classes, and nothing else:
25
19
 
26
- ## Quick Start
20
+ | Class | What it is |
21
+ | --- | --- |
22
+ | `Registration` | the registration file both ends read, and the identities it permits |
23
+ | `Puppet` | one user the service may act as, with its own device |
24
+ | `Service` | a `Protocol::HTTP::Middleware` answering the application service API |
25
+ | `Transaction` | one pushed transaction, as a representation of typed events |
26
+ | `TransactionStore` | which transaction IDs have been answered already |
27
27
 
28
- The whole protocol is one class, and the events reach a block you pattern match
29
- on. There is no handler to register, no filter DSL and no base class.
28
+ ## Serving
30
29
 
31
- ```ruby
30
+ `Service` is middleware rather than a Rack app, so it is the thing an `Async::HTTP::Server` serves. The block is handed a `Transaction`, and the events in it are async-matrix event objects: `case/in` reads them by class and by field together.
31
+
32
+ ``` ruby
32
33
  require "async/matrix/bridge"
33
- require "ratalada/async"
34
-
35
- Bridge = Async::Matrix::Bridge
36
-
37
- config = Bridge::Registration.load("/config/registration.yaml",
38
- homeserver: "http://synapse:8008", domain: "localhost")
39
-
40
- client = Async::Matrix::Client.new(config)
41
- store = Bridge::TransactionStore.new
42
- bot = config.bot_mxid
43
-
44
- Server.run(host: "0.0.0.0", port: 9000) do |request|
45
- Bridge::Endpoint.new(request, config: config, store: store) do |message|
46
- case message
47
- in {type: "m.room.member", content: {membership: "invite"}, room_id:, state_key: ^bot}
48
- client.join_room(room_id)
49
- in {type: "m.room.message", content: {msgtype: "m.text", body:}, room_id:, sender:} if sender != bot
50
- client.send_notice(room_id, "Echo: #{body}")
51
- else
52
- nil
34
+
35
+ registration = Async::Matrix::Bridge::Registration.load("registration.yaml", homeserver: "http://localhost:8008")
36
+
37
+ service = Async::Matrix::Bridge::Service.new(registration) do |transaction|
38
+ transaction.each_room do |room_id, events|
39
+ events.each do |event|
40
+ case event
41
+ in {sender:, content: Async::Matrix::Events::TextMessage(body:)}
42
+ puts "<#{sender}> #{body}"
43
+ else
44
+ nil
45
+ end
53
46
  end
54
- end.response
47
+ end
48
+ end
49
+
50
+ Async do
51
+ Async::HTTP::Server.new(service, Async::HTTP::Endpoint.parse("http://0.0.0.0:9000")).run
55
52
  end
56
53
  ```
57
54
 
58
- `Endpoint` also accepts a Rack env, so the same block works under any Rack
59
- server — pass `request.env` there instead.
55
+ `each_room` groups a transaction's events by the room they belong to, in the order they arrived. `events` and `ephemeral` are the two lists the specification puts in a transaction; there is no third, which matters for encryption below.
60
56
 
61
- Working stacks with Synapse and Docker Compose live in
62
- [`examples/`](https://github.com/general-intelligence-systems/async-matrix-bridge/tree/main/examples).
57
+ A homeserver pushes over HTTP/1.1 and will not do h2c with prior knowledge, so a listener pinned to HTTP/2 never receives anything.
63
58
 
64
- ## Pattern matching
59
+ The queries are three blocks rather than three routes. Each answers whether the service has something, and `nil` is `M_NOT_FOUND`:
65
60
 
66
- The block is handed plain **symbol-keyed** Hashes. That is deliberate: Ruby's
67
- hash patterns match Symbol keys only, so `in {type: "m.room.message"}` would
68
- never match a `{"type" => ...}` parsed from JSON. Parsing with
69
- `symbolize_names` is what makes `case/in` destructure nested content with no
70
- `deconstruct_keys` on anything.
61
+ ``` ruby
62
+ service.query_user{|user_id| registration.covers?("users", user_id)}
63
+ service.query_room{|room_alias| room_alias == "#_bridge_lobby:localhost"}
64
+ ```
71
65
 
72
- The filters that used to be DSL options are now ordinary pattern syntax -- a
73
- pin for "addressed to me", a guard for "not from me":
66
+ A path outside `/_matrix/app/v1` is a `404` before anything is authenticated, so the service composes as the fall-through of a router which has its own routes.
74
67
 
75
- ```ruby
76
- in {type: "m.room.member", content: {membership: "invite"}, state_key: ^bot}
77
- in {type: "m.room.message", content: {msgtype: "m.text", body:}, sender:} if sender != bot
78
- ```
68
+ If your block raises, the response is `500` and the transaction ID is **not** recorded, so the homeserver retries it. Recording it would make one failed handler drop those events for good.
79
69
 
80
- Third-party protocol, location and user queries reach the same block as a
81
- `:query` Hash, so one `case` covers the whole protocol. Whatever it returns is
82
- the JSON body; `nil` is `M_NOT_FOUND`.
70
+ ## Acting as somebody
83
71
 
84
- ```ruby
85
- in {query: :protocol, protocol:}
86
- {instances: instances_for(protocol)}
87
- ```
72
+ A service does not log in. It asserts an identity: its own token says which service, and the user is a parameter on the request. `Registration#as` is that, and `Puppet` is one user it may act as.
88
73
 
89
- ## Without Rack
74
+ ``` ruby
75
+ puppet = registration.puppet(client, "_bridge_ripley")
90
76
 
91
- `Endpoint` takes either a Rack env or a `Protocol::HTTP::Request`, and answers
92
- with whichever response type the source implies. So the same class drops into a
93
- bare `Async::HTTP::Server` with no Rack anywhere in the stack:
77
+ puppet.register!
78
+ puppet.device!("RIPLEY1", display_name: "ripley")
94
79
 
95
- ```ruby
96
- server = Async::HTTP::Server.for(endpoint) do |request|
97
- Bridge::Endpoint.new(request, config: config, store: store) do |message|
98
- case message
99
- in {type: "m.room.message", content: {body:}, room_id:}
100
- client.send_notice(room_id, "Echo: #{body}")
101
- else
102
- nil
103
- end
104
- end.response
105
- end
80
+ puppet.client.join_room(room_id)
81
+ puppet.on("RIPLEY1").room(room_id).send_message(content.type, content.to_h)
106
82
  ```
107
83
 
108
- Do not pin the server to HTTP/2: homeservers push transactions over HTTP/1.1
109
- and will not do h2c with prior knowledge, so a forced-HTTP2 listener never
110
- receives an event.
84
+ `puppet.client` is the user; `puppet.on(device_id)` is the user at one of its devices, which is what keys belong to. Both take `ts:`, which is how a bridge gives a message the time it was really said. A localpart outside the registration's namespaces is refused here rather than at the homeserver.
111
85
 
112
- ## Your own routes
86
+ Every one of those is an ordinary async-matrix call on a resource derived with `#with`. This gem adds no helpers of its own for talking to the homeserver, and it has no opinion about which calls a bridge makes.
113
87
 
114
- An `Endpoint` answers `404` for any path outside `/_matrix/app/v1`, so it works
115
- as the fall-through of a router that matches its own routes first. Ask
116
- `handled?` if you would rather compose explicitly.
88
+ ## Encryption
117
89
 
118
- ```ruby
119
- Server.run do |request|
120
- case request
121
- in ["GET", "/healthz"] then "ok\n"
122
- else Bridge::Endpoint.new(request, config: config, store: store) { |message| ... }.response
123
- end
124
- end
125
- ```
90
+ A transaction carries `events` and `ephemeral`, and no keys at all. So nothing pushed to a service can be decrypted by anything that push told it — and the specification is explicit about the way in: a service which wants `/sync` syncs as one of its own users.
126
91
 
127
- ## Concurrency and retries
128
-
129
- The events of a transaction are dispatched in their own fibers, bounded by
130
- `concurrency:` (default 8), and all of them are awaited before the response.
131
-
132
- If the block raises, the response is `500` and the transaction id is **not**
133
- recorded, so the homeserver retries it. Recording it and answering `200` would
134
- make a failed handler drop its events permanently, since the retry would be
135
- deduplicated away.
136
-
137
- ## Configuration
138
-
139
- `Registration` reads the application service registration file -- the one your
140
- homeserver already loads -- and validates it against the Matrix spec's own
141
- schema, vendored verbatim from
142
- [matrix-org/matrix-spec](https://github.com/matrix-org/matrix-spec/tree/main/data/api/application-service/definitions).
143
- Nothing is restated: the tokens, the sender localpart and the namespaces are
144
- read from the file the homeserver reads.
145
-
146
- ```yaml
147
- # registration.yaml -- give this to your homeserver
148
- id: agent
149
- url: "http://appservice:9000"
150
- as_token: "your-appservice-token"
151
- hs_token: "your-homeserver-token"
152
- sender_localpart: agent
153
- namespaces:
154
- users:
155
- - exclusive: false
156
- regex: "@.*:localhost"
157
- aliases: []
158
- rooms: []
159
- ```
92
+ So an encrypted bridge has two inputs which meet in one machine's store. The ciphertext is pushed, the keys are pulled, and neither input knows about the other:
160
93
 
161
- ```ruby
162
- config = Bridge::Registration.load("registration.yaml",
163
- homeserver: "http://synapse:8008", domain: "localhost")
94
+ - the `Service` block hands the ciphertext to the machine, which defers what it cannot read yet,
95
+ - a sync loop, running as a puppet, pulls the to-device traffic the keys arrive in, and the key releases the deferred event.
164
96
 
165
- config.bot_mxid # => "@agent:localhost"
166
- config.covers?(:users, "@alice:localhost") # => true
167
- config.exclusive?(:users, "@alice:localhost") # => false
168
- ```
97
+ Synapse answers `/sync` for the service's own user with a `500`, so the user which reads is a puppet rather than the sender localpart. [`examples/bridge.rb`](examples/bridge.rb) is that, whole and working, in one file.
98
+
99
+ ## The examples
100
+
101
+ Both examples run against the homeserver in `synapse/`, and the suite runs them:
102
+
103
+ - [`examples/bridge.rb`](examples/bridge.rb) — a bridge, both ways, between Matrix and a line protocol. A nick on the other network gets a user and a device of its own; an encrypted room is encrypted to, by claiming a key from every device and sharing a Megolm session over Olm; what Matrix says comes back out of the socket.
104
+ - [`examples/puppeting.rb`](examples/puppeting.rb) — identity assertion on its own: the service's own user, a puppet, a device, published keys, cross-signing, timestamp massaging, and the refusal of a user outside the namespaces.
169
105
 
170
- `homeserver:` and `domain:` are separate arguments because a registration
171
- describes the service *to* the homeserver and so cannot carry the server's own
172
- address. `domain` is required rather than inferred from the URL: a
173
- `server_name` need not match the host it is served from, and a wrong guess
174
- yields plausible, broken MXIDs.
106
+ ``` bash
107
+ REGISTRATION=./synapse/appservice.yaml HOMESERVER=http://localhost:7400 CHANNEL=lobby ENCRYPTED=true ruby examples/bridge.rb
108
+
109
+ printf 'ripley\nhello from the network\n' | nc localhost 9100
110
+ ```
175
111
 
176
112
  ## Development
177
113
 
178
- ```bash
179
- bin/test # scampi, co-located `__END__` specs
114
+ ``` bash
115
+ bin/test # brings the homeserver up, then runs sus
180
116
  bin/rubocop
181
117
  ```
182
118
 
183
- async-matrix is an ordinary RubyGems dependency -- it resolves from RubyGems like any other gem, including inside the Nix devshell. To try a change to the core gem against this one, publish a prerelease of it, or `bundle config local` a git checkout; do not add a `path:` entry to the Gemfile. `bundlerEnv` cannot resolve a relative path source pointing outside the flake, so a path gem breaks `nix develop` outright.
119
+ `bin/test` recreates the container rather than reusing it: its database is in tmpfs, and a transaction which failed once puts the homeserver's scheduler into a backoff reaching 512 seconds, which an example started afterwards would be waiting on rather than on itself.
120
+
121
+ async-matrix is an ordinary RubyGems dependency. To try a change to it against this gem, publish a prerelease or `bundle config local` a git checkout; do not add a `path:` entry to the Gemfile, because `bundlerEnv` cannot resolve a path source pointing outside the flake and `nix develop` then fails outright.
184
122
 
185
- ## Built With
123
+ ## Built with
186
124
 
187
- - [async-matrix](https://github.com/general-intelligence-systems/async-matrix) -- the Matrix protocol layer (client, events, media, E2EE)
188
- - [async](https://github.com/socketry/async) -- fiber-based concurrency framework
189
- - [protocol-http](https://github.com/socketry/protocol-http) -- the HTTP semantics `Endpoint` answers in
190
- - [json_schemer](https://github.com/davishmcclurg/json_schemer) -- JSON Schema validation
191
- - [scampi](https://github.com/general-intelligence-systems/scampi) -- inline co-located test framework
125
+ - [async-matrix](https://github.com/general-intelligence-systems/async-matrix) — the Matrix protocol layer: clients, events, E2EE
126
+ - [async-rest](https://github.com/socketry/async-rest) — resources and representations, which this gem's are
127
+ - [protocol-http](https://github.com/socketry/protocol-http) — the middleware a `Service` is
128
+ - [async](https://github.com/socketry/async) — fibers
192
129
 
193
130
  ## License
194
131
 
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the Apache License, Version 2.0.
4
+ # Copyright, 2026, by General Intelligence Systems.
5
+
6
+ module Async
7
+ module Matrix
8
+ module Bridge
9
+ # One of the service's own users, and what it takes to bring them into existence.
10
+ #
11
+ # A puppet is made twice over. Registering makes the user, and a homeserver refuses to act as
12
+ # one it was never told about. Creating a device makes something which can hold keys, which
13
+ # from v1.17 a service does itself rather than logging in for.
14
+ class Puppet
15
+ # The login type which says a service is asking, rather than a person with a password.
16
+ LOGIN_TYPE = "m.login.application_service"
17
+
18
+ # What a homeserver answers when the user is already there, which is not a failure here.
19
+ IN_USE = "M_USER_IN_USE"
20
+
21
+ # @parameter registration [Registration] the service this user belongs to.
22
+ # @parameter client [Async::Matrix::ClientServer::Client] an unauthenticated client.
23
+ # @parameter localpart [String] the part of the ID which names them, without the sigil.
24
+ def initialize(registration, client, localpart)
25
+ @registration = registration
26
+ @client = client
27
+ @localpart = localpart
28
+
29
+ # Refused here rather than by the homeserver: the namespaces say which users this service
30
+ # may act as, so a localpart outside them is a mistake in the caller rather than a request.
31
+ unless registration.covers?("users", self.user_id)
32
+ raise ArgumentError, "#{self.user_id} is outside this service's namespaces!"
33
+ end
34
+ end
35
+
36
+ # @attribute [String] the part of the ID which names them.
37
+ attr :localpart
38
+
39
+ # @returns [String] the fully qualified ID of this user.
40
+ def user_id = "@#{@localpart}:#{@registration.domain}"
41
+
42
+ # A client which acts as this user, on no particular device.
43
+ #
44
+ # @parameter ts [Integer | nil] when what is sent through it happened, in milliseconds.
45
+ # @returns [Async::Matrix::ClientServer::Client] the client.
46
+ def client(ts: nil) = @registration.as(@client, self.user_id, ts: ts)
47
+
48
+ # A client which acts as this user on one of their devices, which is what encryption needs.
49
+ #
50
+ # @parameter device_id [String] the device to act as.
51
+ # @parameter ts [Integer | nil] when what is sent through it happened, in milliseconds.
52
+ # @returns [Async::Matrix::ClientServer::Client] the client.
53
+ def on(device_id, ts: nil) = @registration.as(@client, self.user_id, device_id: device_id, ts: ts)
54
+
55
+ # Make the user, which is what lets the service act as them at all.
56
+ #
57
+ # Asking twice is not an error: a service does not remember which of its users it has made,
58
+ # and a homeserver which already has one says so rather than failing.
59
+ #
60
+ # @returns [Boolean] whether this call is what made them.
61
+ def register!
62
+ @registration.as(@client).registration.register(username: @localpart, type: LOGIN_TYPE)
63
+
64
+ true
65
+ rescue Async::Matrix::RequestError => error
66
+ unless error.errcode == IN_USE
67
+ raise
68
+ end
69
+
70
+ false
71
+ end
72
+
73
+ # Make one of their devices, so there is something to hold keys and to act as.
74
+ #
75
+ # Asked as the user rather than as the device: acting as a device the homeserver has not got
76
+ # is what this call is here to fix, so it cannot be how the call is made.
77
+ #
78
+ # @parameter device_id [String] the device to make.
79
+ # @parameter display_name [String] what a user of the other network would recognise.
80
+ # @returns [Async::Matrix::Representation] the empty response.
81
+ def device!(device_id, display_name: @localpart)
82
+ self.client.devices.update_device(device_id, display_name: display_name)
83
+ end
84
+
85
+ def inspect = "\#<#{self.class} #{self.user_id}>"
86
+ end
87
+ end
88
+ end
89
+ end