muster-bridge 0.1.0__tar.gz

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.
@@ -0,0 +1,12 @@
1
+ # Build output, virtual environments, caches, coverage data and conformance runs
2
+ /bin/
3
+ /dist/
4
+ /.venv/
5
+ __pycache__/
6
+ .mypy_cache/
7
+ .ruff_cache/
8
+ .pytest_cache/
9
+ /.coverage
10
+ /.coverage.*
11
+ # Local, unpublished files of the maintainer
12
+ CLAUDE.local.md
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright The Muster Authors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,277 @@
1
+ Metadata-Version: 2.5
2
+ Name: muster-bridge
3
+ Version: 0.1.0
4
+ Summary: Serve Muster's Bridge protocol, so that a bridge to a messenger is only the calls to that messenger.
5
+ Project-URL: Homepage, https://github.com/muster-io/muster-bridge-python
6
+ Project-URL: Documentation, https://muster-io.github.io/muster/messengers/write-a-bridge/
7
+ Project-URL: Issues, https://github.com/muster-io/muster-bridge-python/issues
8
+ Project-URL: Changelog, https://github.com/muster-io/muster-bridge-python/blob/master/CHANGELOG.md
9
+ Author: The Muster Authors
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: alerting,asgi,bridge,chat,muster,on-call,starlette
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Framework :: AsyncIO
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3 :: Only
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Programming Language :: Python :: 3.14
23
+ Classifier: Programming Language :: Python :: 3.15
24
+ Classifier: Topic :: Communications :: Chat
25
+ Classifier: Topic :: System :: Monitoring
26
+ Classifier: Typing :: Typed
27
+ Requires-Python: >=3.11
28
+ Requires-Dist: httpx>=0.28
29
+ Requires-Dist: pydantic>=2.11
30
+ Requires-Dist: starlette>=1.0
31
+ Description-Content-Type: text/markdown
32
+
33
+ # muster-bridge-python
34
+
35
+ The Python library for writing a bridge between [Muster](https://github.com/muster-io/muster) and a messenger it does
36
+ not support natively: the library serves Muster's bridge protocol (`api/bridge.yaml`) as an asynchronous ASGI
37
+ application — tokens, validation, idempotency, events and signed push — and the bridge author writes only the calls to
38
+ their messenger. MIT licensed.
39
+
40
+ A bridge is a small HTTP service that Muster calls to post, edit and reply to messages, and that reports what people do
41
+ in the messenger — button presses, messages in Threads, Account link tokens — as events. Why the protocol exists and
42
+ how it is versioned is Muster's
43
+ [ADR-0017](https://github.com/muster-io/muster/blob/master/design/adr/0017-bridge-protocol-for-messengers-without-native-support.md);
44
+ the protocol itself is [`api/bridge.yaml`](https://github.com/muster-io/muster/blob/master/api/bridge.yaml), of which
45
+ this repository keeps a byte-for-byte copy.
46
+
47
+ ## Install
48
+
49
+ ```sh
50
+ pip install muster-bridge
51
+ ```
52
+
53
+ Python 3.11 or newer. The distribution is `muster-bridge`, the import package `muster_bridge`; it depends on
54
+ Starlette, httpx and pydantic 2 only. Versions stay `0.x` until Muster 1.0. Every `0.x` release speaks protocol
55
+ version 1; a breaking protocol change would be a new protocol version, not a new library version.
56
+
57
+ ## Quick start
58
+
59
+ [`examples/memory_bridge.py`](https://github.com/muster-io/muster-bridge-python/blob/master/examples/memory_bridge.py) is a whole bridge in one file: its messenger lives in memory
60
+ and writes every message the bot posts to the log. Run it with a token, the one a Bridge Connection in Muster would
61
+ hold. In a clone, with [uv](https://docs.astral.sh/uv/):
62
+
63
+ ```sh
64
+ printf example-token > /tmp/bt
65
+ uv run python examples/memory_bridge.py --token-file /tmp/bt
66
+ ```
67
+
68
+ or with the package installed beside uvicorn (`pip install muster-bridge uvicorn`), as
69
+ `python examples/memory_bridge.py --token-file /tmp/bt`.
70
+
71
+ It listens on `127.0.0.1:18095` (`--listen`), knows the channels `alerts` and `ops` (`--channels`) and the people
72
+ `alice` and `bob` (`--users`), and offers every feature of the protocol, or only those that `BRIDGE_FEATURES` lists:
73
+ `buttons`, `thread_messages`, `direct_messages` (which also turns on `user_lookup` `login`), `private_answers`,
74
+ `native_mentions`, `quiet_messages`, `status_color`, `deletion_events` and `delete`. With `--push-url` and
75
+ `--push-secret-file` it pushes its events as well. In another terminal:
76
+
77
+ ```sh
78
+ curl -s -o /dev/null -w '%{http_code}\n' 127.0.0.1:18095/v1/info # 401: no token
79
+ curl -s -H 'Authorization: Bearer example-token' 127.0.0.1:18095/v1/info \
80
+ | jq -c '{protocol_version, user_lookup}' # {"protocol_version":"1","user_lookup":"login"}
81
+ ```
82
+
83
+ Then let Muster's conformance check run every step of the protocol against it, from Muster's development image. The
84
+ check reaches the example on the host's loopback, which `--network host` gives only on Linux:
85
+
86
+ ```sh
87
+ docker run --rm --network host -v /tmp/bt:/token:ro ghcr.io/muster-io/muster:edge bridge check \
88
+ http://127.0.0.1:18095 --token-file /token --channel alerts --forbidden-channel nobot --handle alice \
89
+ --no-interactive
90
+ ```
91
+
92
+ ```text
93
+ PASS info
94
+ PASS channel_check
95
+ PASS post
96
+ PASS replayed_key
97
+ PASS buttons
98
+ PASS edit
99
+ PASS thread_reply
100
+ PASS thread_ref
101
+ PASS forbidden_channel
102
+ PASS user_lookup
103
+ PASS direct_message
104
+ PASS delete
105
+ PASS poll
106
+ SKIP press: --no-interactive
107
+ SKIP push: --no-interactive
108
+ PASS answer
109
+ ```
110
+
111
+ The heart of the example is a `Config`, a messenger and the application, mounted in a Starlette application beside a
112
+ route of its own and served by uvicorn:
113
+
114
+ ```python
115
+ config = Config(
116
+ name="muster-bridge-python memory example",
117
+ version=muster_bridge.__version__,
118
+ messenger_name="Memory example",
119
+ bots=[Bot(name="main", token=token)],
120
+ limits=Limits(max_body_length=4000, edit_max_age_seconds=None),
121
+ capabilities=Capabilities(
122
+ buttons=CapabilitiesButtons(
123
+ max_per_message=5,
124
+ max_per_row=5,
125
+ data_max_bytes=64,
126
+ styles=[ButtonStyle.primary, ButtonStyle.success, ButtonStyle.neutral, ButtonStyle.danger],
127
+ ),
128
+ thread_messages=CapabilitiesThreadMessages.all,
129
+ direct_messages=CapabilitiesDirectMessages(bot_can_start=True),
130
+ delete=CapabilitiesDelete(max_age_seconds=None),
131
+ ),
132
+ user_lookup="login",
133
+ )
134
+ bridge = BridgeApp(config, chat) # chat implements Messenger, UserFinder, DirectMessenger and Deleter
135
+
136
+ app = Starlette(
137
+ routes=[
138
+ # Outside the protocol: press the first button of a message as a person, for scripts.
139
+ Route("/demo/press", demo_press, methods=["POST"]),
140
+ Mount("/", app=bridge),
141
+ ],
142
+ lifespan=bridge.lifespan,
143
+ )
144
+ uvicorn.run(app, host="127.0.0.1", port=18095)
145
+ ```
146
+
147
+ `POST /demo/press` `{"message_id": "4f0c1a-m2", "user": "alice"}` presses the first button of message `4f0c1a-m2` as
148
+ `alice`, so that a script can play the person of an interactive check. It has no authentication of its own: keep it
149
+ on loopback. Message ids start with a prefix that is random for each start of the example, so that a restarted example
150
+ never reuses an id that Muster still holds.
151
+
152
+ ## Serving the bridge
153
+
154
+ `BridgeApp` is an ASGI application: serve it with uvicorn or another ASGI server, on its own or mounted under a path of
155
+ a Starlette or FastAPI application; the base URL of the Bridge Connection in Muster then ends with that path. Starlette
156
+ runs the lifespan of the top-level application only, so a host that mounts the bridge passes the bridge's on, which
157
+ stops the pushers on shutdown:
158
+
159
+ ```python
160
+ bridge = BridgeApp(config, messenger)
161
+ app = FastAPI(lifespan=bridge.lifespan)
162
+ app.mount("/bridge", bridge)
163
+ ```
164
+
165
+ A host with a lifespan of its own enters the bridge's in it:
166
+
167
+ ```python
168
+ @contextlib.asynccontextmanager
169
+ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
170
+ async with bridge.lifespan(app):
171
+ yield
172
+ ```
173
+
174
+ A `BridgeApp` has one lifespan: its end stops the pushers for good. A program or a test that starts the host again,
175
+ such as a second `with TestClient(app)`, makes a new `BridgeApp`. Without a lifespan, as with `TestClient(app)` without
176
+ `with`, the bridge runs on each request's event loop in turn, and a pusher starts on the next event.
177
+
178
+ `pollEvents` holds a request for up to 60 seconds. uvicorn sets no timeout that cuts such a request short; a proxy in
179
+ front of the bridge needs a read timeout above 75 seconds, or it cuts a poll off without an answer, so that its events
180
+ reach Muster late.
181
+
182
+ ## Writing a bridge
183
+
184
+ 1. Implement the protocol [`Messenger`](https://github.com/muster-io/muster-bridge-python/blob/master/src/muster_bridge/messenger.py): the coroutines `post_message`,
185
+ `edit_message`, `reply_in_thread`, `check_channel` and `answer_event`, each given the name of the bot whose token
186
+ Muster sent. Define them with `async def`; a method wrapped by a decorator that returns a coroutine is taken once the
187
+ wrapper is marked with `inspect.markcoroutinefunction` (Python 3.12 or newer). For the features you declare, also
188
+ `UserFinder` (`find_user`, for a `user_lookup` other than `none`), `DirectMessenger` (`send_direct_message`, for
189
+ `capabilities.direct_messages`) and `Deleter` (`delete_message`, for `capabilities.delete`). `BridgeApp` raises
190
+ `ConfigError` for a configuration whose features lack their methods, or whose `info` would not match the protocol,
191
+ naming every reason. The library validates every request against the models generated from the protocol before it
192
+ calls a method, and every answer before it sends it.
193
+ 2. Answer the messenger's failures by raising `Transient`, `RateLimited` (with the wait `retry_after` in seconds, which
194
+ it requires because Muster waits exactly that long, and `scope` `channel` or `bot`), `Gone` (with
195
+ `edit_window_closed=True` for an edit the messenger refuses only for the message's age), `Forbidden` or `Invalid`,
196
+ each with a `detail` that Muster shows the Admin: never put a secret in it. Each becomes its problem, with
197
+ `Retry-After` for `RateLimited`; a class the operation does not declare is mapped — `Gone` from `post_message`,
198
+ `send_direct_message` and `check_channel` and `Invalid` from `check_channel` answer `forbidden`, any other
199
+ `transient` — and logged. Any other exception is answered as `transient` with a generic detail that carries none of
200
+ its text, and logged without the token.
201
+ 3. Serve the application as _Serving the bridge_ above says.
202
+ 4. Report what the messenger tells you, from the event loop that serves the bridge: `await bridge.press(...)`,
203
+ `thread_message(...)`, `message_deleted(...)` and `direct_message(...)`. Each queues the event and returns at once,
204
+ so you confirm a press to your messenger within its deadline whatever Muster does; give the messenger's own update
205
+ id as `update_id`, so that an update it delivers twice is queued once, and in `chat` whatever you need to answer
206
+ the person, which `answer_event` gets back in `ReportedEvent.chat` and Muster never sees. A direct message that is
207
+ an Account link token becomes an `account_link` event; any other direct message is ignored. A report raises
208
+ `UnknownBotError`, `NotOfferedError` for a feature the bridge does not offer and `InvalidEventError` for an event
209
+ that would not match the protocol. From another thread, hand the coroutine to the loop with
210
+ `asyncio.run_coroutine_threadsafe`.
211
+ 5. Turn Mention markers into native mentions or plain names with `replace_mentions(body, mentions, render)`.
212
+ 6. For pushes, give a bot a `PushTarget` — the callback URL and the push secret that its Bridge Connection in Muster
213
+ shows — in `Bot(push=...)` or later with `bridge.set_push(bot, target)`, from the thread of the event loop that
214
+ serves the bridge (from another thread, `loop.call_soon_threadsafe(bridge.set_push, bot, target)`). The pushers
215
+ are tasks of the running event loop that post each event, signed as Standard Webhooks specifies, one at a time and
216
+ in order, retrying with back-off for at least an hour; `aclose()` or the end of `lifespan` stops them for good.
217
+ Events stay queued for polling either way.
218
+ 7. `MemoryStore`, the default `Store`, forgets Idempotency-Keys and queued events on a restart, and holds a day of
219
+ events and answers in memory; implement the protocol `Store` over your database to keep them. Long polls and pushes
220
+ are woken in the process that queued an event, so with several processes over one store, run each bot in one
221
+ process only.
222
+
223
+ A few more things to know:
224
+
225
+ - `Person` and the other models are pydantic models that validate on construction: cut a display name to 200
226
+ characters before you build the `Person` of a report.
227
+ - `Limits` needs `edit_max_age_seconds`, `None` when edits never expire, and `CapabilitiesDelete` needs
228
+ `max_age_seconds`, `None` for no limit.
229
+ - A repeated `Idempotency-Key` under the same token answers the first result without calling the messenger again. A
230
+ messenger with an idempotency mechanism of its own can be given the key: `idempotency_key()`, called inside
231
+ `post_message`, `reply_in_thread`, `send_direct_message` or `answer_event`, returns it, and `None` elsewhere.
232
+ - The library logs through `Config.logger`, by default the logger `muster_bridge`. Its own messages never carry a
233
+ token, a push secret or an Account link token, and it replaces the bots' tokens and push secrets in the text of
234
+ every exception it logs; keep anything else secret, such as an event's Account link token, out of the exceptions of
235
+ your messenger and your store.
236
+
237
+ ## Checking a bridge
238
+
239
+ `muster bridge check <url> --token-file <file> --channel <id>` runs every step of the protocol against a bridge and
240
+ prints `PASS`, `FAIL` or `SKIP` for each; `--forbidden-channel` and `--handle` enable the steps that need them. Without
241
+ `--no-interactive` it also waits for a person to press a button (`press`) and, for a bridge that pushes, receives the
242
+ push (`push`): it prints `PUSH <url>` once its listener (`--push-listen`) is up — point the bridge's push target there,
243
+ with the secret of `--push-secret-file` — and `WAIT press: …, message_id <id>` before it waits. `make conformance`
244
+ plays that person through the example's `POST /demo/press`; do the same in your bridge's tests with your messenger's
245
+ own means.
246
+
247
+ ## The protocol copy
248
+
249
+ [`api/bridge.yaml`](https://github.com/muster-io/muster-bridge-python/blob/master/api/bridge.yaml) is a copy of Muster's, never edited here:
250
+ [`src/muster_bridge/models.py`](https://github.com/muster-io/muster-bridge-python/blob/master/src/muster_bridge/models.py), the pydantic models the library validates requests with
251
+ and builds answers from, is generated from it by datamodel-code-generator. `make spec-check` compares it with the one on
252
+ Muster's `master` byte for byte, every day in CI; `make spec-sync` takes the new one and regenerates the models.
253
+
254
+ ## Development
255
+
256
+ Development needs [uv](https://docs.astral.sh/uv/) 0.13 or newer, which installs the Python of `.python-version` and
257
+ the tools locked in `uv.lock`, GNU make and curl; `make conformance` also needs Linux with Docker. The Makefile is the
258
+ only entry point; `make help` lists the targets.
259
+
260
+ | Target | Does |
261
+ |---|---|
262
+ | `make fmt` | formats the code and sorts the imports (ruff) |
263
+ | `make lint` | checks the licence headers, `uv.lock`, and the code with ruff (lint and format) and mypy in strict mode |
264
+ | `make test` | runs the tests with the coverage gate of 90 % of the package (`models.py` excluded) |
265
+ | `make generate`, `make generate-check` | generates `src/muster_bridge/models.py` from `api/bridge.yaml`; fails when it is stale |
266
+ | `make spec-check`, `make spec-sync` | compares `api/bridge.yaml` with Muster's; replaces it and regenerates |
267
+ | `make licenses` | fails on a runtime dependency outside MIT, MIT-0, BSD-2-Clause, BSD-3-Clause, Apache-2.0, ISC, PSF-2.0 and MPL-2.0 |
268
+ | `make build` | builds the sdist and the wheel into `dist/` |
269
+ | `make ci` | `lint`, `generate-check`, `spec-check`, `licenses`, `test` and `build`, as a pull request runs them |
270
+ | `make conformance` | installs the built wheel and uvicorn into a fresh virtual environment, runs the example from it with `python -I`, and runs `muster bridge check` from `ghcr.io/muster-io/muster:edge` (`MUSTER_IMAGE`) against it three times: every feature, only `thread_messages`, and interactively with push |
271
+ | `make clean` | removes `bin/`, `dist/`, `.venv/`, the caches and the coverage data |
272
+
273
+ ## Contributing, security and licence
274
+
275
+ [CONTRIBUTING.md](https://github.com/muster-io/muster-bridge-python/blob/master/CONTRIBUTING.md) says how to contribute, [AGENTS.md](https://github.com/muster-io/muster-bridge-python/blob/master/AGENTS.md) is the guide for everyone who
276
+ changes the code, and [SECURITY.md](https://github.com/muster-io/muster-bridge-python/blob/master/SECURITY.md) how to report a vulnerability privately. The library is under the
277
+ [MIT licence](https://github.com/muster-io/muster-bridge-python/blob/master/LICENSE); contributions are accepted under it, without a CLA.
@@ -0,0 +1,245 @@
1
+ # muster-bridge-python
2
+
3
+ The Python library for writing a bridge between [Muster](https://github.com/muster-io/muster) and a messenger it does
4
+ not support natively: the library serves Muster's bridge protocol (`api/bridge.yaml`) as an asynchronous ASGI
5
+ application — tokens, validation, idempotency, events and signed push — and the bridge author writes only the calls to
6
+ their messenger. MIT licensed.
7
+
8
+ A bridge is a small HTTP service that Muster calls to post, edit and reply to messages, and that reports what people do
9
+ in the messenger — button presses, messages in Threads, Account link tokens — as events. Why the protocol exists and
10
+ how it is versioned is Muster's
11
+ [ADR-0017](https://github.com/muster-io/muster/blob/master/design/adr/0017-bridge-protocol-for-messengers-without-native-support.md);
12
+ the protocol itself is [`api/bridge.yaml`](https://github.com/muster-io/muster/blob/master/api/bridge.yaml), of which
13
+ this repository keeps a byte-for-byte copy.
14
+
15
+ ## Install
16
+
17
+ ```sh
18
+ pip install muster-bridge
19
+ ```
20
+
21
+ Python 3.11 or newer. The distribution is `muster-bridge`, the import package `muster_bridge`; it depends on
22
+ Starlette, httpx and pydantic 2 only. Versions stay `0.x` until Muster 1.0. Every `0.x` release speaks protocol
23
+ version 1; a breaking protocol change would be a new protocol version, not a new library version.
24
+
25
+ ## Quick start
26
+
27
+ [`examples/memory_bridge.py`](https://github.com/muster-io/muster-bridge-python/blob/master/examples/memory_bridge.py) is a whole bridge in one file: its messenger lives in memory
28
+ and writes every message the bot posts to the log. Run it with a token, the one a Bridge Connection in Muster would
29
+ hold. In a clone, with [uv](https://docs.astral.sh/uv/):
30
+
31
+ ```sh
32
+ printf example-token > /tmp/bt
33
+ uv run python examples/memory_bridge.py --token-file /tmp/bt
34
+ ```
35
+
36
+ or with the package installed beside uvicorn (`pip install muster-bridge uvicorn`), as
37
+ `python examples/memory_bridge.py --token-file /tmp/bt`.
38
+
39
+ It listens on `127.0.0.1:18095` (`--listen`), knows the channels `alerts` and `ops` (`--channels`) and the people
40
+ `alice` and `bob` (`--users`), and offers every feature of the protocol, or only those that `BRIDGE_FEATURES` lists:
41
+ `buttons`, `thread_messages`, `direct_messages` (which also turns on `user_lookup` `login`), `private_answers`,
42
+ `native_mentions`, `quiet_messages`, `status_color`, `deletion_events` and `delete`. With `--push-url` and
43
+ `--push-secret-file` it pushes its events as well. In another terminal:
44
+
45
+ ```sh
46
+ curl -s -o /dev/null -w '%{http_code}\n' 127.0.0.1:18095/v1/info # 401: no token
47
+ curl -s -H 'Authorization: Bearer example-token' 127.0.0.1:18095/v1/info \
48
+ | jq -c '{protocol_version, user_lookup}' # {"protocol_version":"1","user_lookup":"login"}
49
+ ```
50
+
51
+ Then let Muster's conformance check run every step of the protocol against it, from Muster's development image. The
52
+ check reaches the example on the host's loopback, which `--network host` gives only on Linux:
53
+
54
+ ```sh
55
+ docker run --rm --network host -v /tmp/bt:/token:ro ghcr.io/muster-io/muster:edge bridge check \
56
+ http://127.0.0.1:18095 --token-file /token --channel alerts --forbidden-channel nobot --handle alice \
57
+ --no-interactive
58
+ ```
59
+
60
+ ```text
61
+ PASS info
62
+ PASS channel_check
63
+ PASS post
64
+ PASS replayed_key
65
+ PASS buttons
66
+ PASS edit
67
+ PASS thread_reply
68
+ PASS thread_ref
69
+ PASS forbidden_channel
70
+ PASS user_lookup
71
+ PASS direct_message
72
+ PASS delete
73
+ PASS poll
74
+ SKIP press: --no-interactive
75
+ SKIP push: --no-interactive
76
+ PASS answer
77
+ ```
78
+
79
+ The heart of the example is a `Config`, a messenger and the application, mounted in a Starlette application beside a
80
+ route of its own and served by uvicorn:
81
+
82
+ ```python
83
+ config = Config(
84
+ name="muster-bridge-python memory example",
85
+ version=muster_bridge.__version__,
86
+ messenger_name="Memory example",
87
+ bots=[Bot(name="main", token=token)],
88
+ limits=Limits(max_body_length=4000, edit_max_age_seconds=None),
89
+ capabilities=Capabilities(
90
+ buttons=CapabilitiesButtons(
91
+ max_per_message=5,
92
+ max_per_row=5,
93
+ data_max_bytes=64,
94
+ styles=[ButtonStyle.primary, ButtonStyle.success, ButtonStyle.neutral, ButtonStyle.danger],
95
+ ),
96
+ thread_messages=CapabilitiesThreadMessages.all,
97
+ direct_messages=CapabilitiesDirectMessages(bot_can_start=True),
98
+ delete=CapabilitiesDelete(max_age_seconds=None),
99
+ ),
100
+ user_lookup="login",
101
+ )
102
+ bridge = BridgeApp(config, chat) # chat implements Messenger, UserFinder, DirectMessenger and Deleter
103
+
104
+ app = Starlette(
105
+ routes=[
106
+ # Outside the protocol: press the first button of a message as a person, for scripts.
107
+ Route("/demo/press", demo_press, methods=["POST"]),
108
+ Mount("/", app=bridge),
109
+ ],
110
+ lifespan=bridge.lifespan,
111
+ )
112
+ uvicorn.run(app, host="127.0.0.1", port=18095)
113
+ ```
114
+
115
+ `POST /demo/press` `{"message_id": "4f0c1a-m2", "user": "alice"}` presses the first button of message `4f0c1a-m2` as
116
+ `alice`, so that a script can play the person of an interactive check. It has no authentication of its own: keep it
117
+ on loopback. Message ids start with a prefix that is random for each start of the example, so that a restarted example
118
+ never reuses an id that Muster still holds.
119
+
120
+ ## Serving the bridge
121
+
122
+ `BridgeApp` is an ASGI application: serve it with uvicorn or another ASGI server, on its own or mounted under a path of
123
+ a Starlette or FastAPI application; the base URL of the Bridge Connection in Muster then ends with that path. Starlette
124
+ runs the lifespan of the top-level application only, so a host that mounts the bridge passes the bridge's on, which
125
+ stops the pushers on shutdown:
126
+
127
+ ```python
128
+ bridge = BridgeApp(config, messenger)
129
+ app = FastAPI(lifespan=bridge.lifespan)
130
+ app.mount("/bridge", bridge)
131
+ ```
132
+
133
+ A host with a lifespan of its own enters the bridge's in it:
134
+
135
+ ```python
136
+ @contextlib.asynccontextmanager
137
+ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
138
+ async with bridge.lifespan(app):
139
+ yield
140
+ ```
141
+
142
+ A `BridgeApp` has one lifespan: its end stops the pushers for good. A program or a test that starts the host again,
143
+ such as a second `with TestClient(app)`, makes a new `BridgeApp`. Without a lifespan, as with `TestClient(app)` without
144
+ `with`, the bridge runs on each request's event loop in turn, and a pusher starts on the next event.
145
+
146
+ `pollEvents` holds a request for up to 60 seconds. uvicorn sets no timeout that cuts such a request short; a proxy in
147
+ front of the bridge needs a read timeout above 75 seconds, or it cuts a poll off without an answer, so that its events
148
+ reach Muster late.
149
+
150
+ ## Writing a bridge
151
+
152
+ 1. Implement the protocol [`Messenger`](https://github.com/muster-io/muster-bridge-python/blob/master/src/muster_bridge/messenger.py): the coroutines `post_message`,
153
+ `edit_message`, `reply_in_thread`, `check_channel` and `answer_event`, each given the name of the bot whose token
154
+ Muster sent. Define them with `async def`; a method wrapped by a decorator that returns a coroutine is taken once the
155
+ wrapper is marked with `inspect.markcoroutinefunction` (Python 3.12 or newer). For the features you declare, also
156
+ `UserFinder` (`find_user`, for a `user_lookup` other than `none`), `DirectMessenger` (`send_direct_message`, for
157
+ `capabilities.direct_messages`) and `Deleter` (`delete_message`, for `capabilities.delete`). `BridgeApp` raises
158
+ `ConfigError` for a configuration whose features lack their methods, or whose `info` would not match the protocol,
159
+ naming every reason. The library validates every request against the models generated from the protocol before it
160
+ calls a method, and every answer before it sends it.
161
+ 2. Answer the messenger's failures by raising `Transient`, `RateLimited` (with the wait `retry_after` in seconds, which
162
+ it requires because Muster waits exactly that long, and `scope` `channel` or `bot`), `Gone` (with
163
+ `edit_window_closed=True` for an edit the messenger refuses only for the message's age), `Forbidden` or `Invalid`,
164
+ each with a `detail` that Muster shows the Admin: never put a secret in it. Each becomes its problem, with
165
+ `Retry-After` for `RateLimited`; a class the operation does not declare is mapped — `Gone` from `post_message`,
166
+ `send_direct_message` and `check_channel` and `Invalid` from `check_channel` answer `forbidden`, any other
167
+ `transient` — and logged. Any other exception is answered as `transient` with a generic detail that carries none of
168
+ its text, and logged without the token.
169
+ 3. Serve the application as _Serving the bridge_ above says.
170
+ 4. Report what the messenger tells you, from the event loop that serves the bridge: `await bridge.press(...)`,
171
+ `thread_message(...)`, `message_deleted(...)` and `direct_message(...)`. Each queues the event and returns at once,
172
+ so you confirm a press to your messenger within its deadline whatever Muster does; give the messenger's own update
173
+ id as `update_id`, so that an update it delivers twice is queued once, and in `chat` whatever you need to answer
174
+ the person, which `answer_event` gets back in `ReportedEvent.chat` and Muster never sees. A direct message that is
175
+ an Account link token becomes an `account_link` event; any other direct message is ignored. A report raises
176
+ `UnknownBotError`, `NotOfferedError` for a feature the bridge does not offer and `InvalidEventError` for an event
177
+ that would not match the protocol. From another thread, hand the coroutine to the loop with
178
+ `asyncio.run_coroutine_threadsafe`.
179
+ 5. Turn Mention markers into native mentions or plain names with `replace_mentions(body, mentions, render)`.
180
+ 6. For pushes, give a bot a `PushTarget` — the callback URL and the push secret that its Bridge Connection in Muster
181
+ shows — in `Bot(push=...)` or later with `bridge.set_push(bot, target)`, from the thread of the event loop that
182
+ serves the bridge (from another thread, `loop.call_soon_threadsafe(bridge.set_push, bot, target)`). The pushers
183
+ are tasks of the running event loop that post each event, signed as Standard Webhooks specifies, one at a time and
184
+ in order, retrying with back-off for at least an hour; `aclose()` or the end of `lifespan` stops them for good.
185
+ Events stay queued for polling either way.
186
+ 7. `MemoryStore`, the default `Store`, forgets Idempotency-Keys and queued events on a restart, and holds a day of
187
+ events and answers in memory; implement the protocol `Store` over your database to keep them. Long polls and pushes
188
+ are woken in the process that queued an event, so with several processes over one store, run each bot in one
189
+ process only.
190
+
191
+ A few more things to know:
192
+
193
+ - `Person` and the other models are pydantic models that validate on construction: cut a display name to 200
194
+ characters before you build the `Person` of a report.
195
+ - `Limits` needs `edit_max_age_seconds`, `None` when edits never expire, and `CapabilitiesDelete` needs
196
+ `max_age_seconds`, `None` for no limit.
197
+ - A repeated `Idempotency-Key` under the same token answers the first result without calling the messenger again. A
198
+ messenger with an idempotency mechanism of its own can be given the key: `idempotency_key()`, called inside
199
+ `post_message`, `reply_in_thread`, `send_direct_message` or `answer_event`, returns it, and `None` elsewhere.
200
+ - The library logs through `Config.logger`, by default the logger `muster_bridge`. Its own messages never carry a
201
+ token, a push secret or an Account link token, and it replaces the bots' tokens and push secrets in the text of
202
+ every exception it logs; keep anything else secret, such as an event's Account link token, out of the exceptions of
203
+ your messenger and your store.
204
+
205
+ ## Checking a bridge
206
+
207
+ `muster bridge check <url> --token-file <file> --channel <id>` runs every step of the protocol against a bridge and
208
+ prints `PASS`, `FAIL` or `SKIP` for each; `--forbidden-channel` and `--handle` enable the steps that need them. Without
209
+ `--no-interactive` it also waits for a person to press a button (`press`) and, for a bridge that pushes, receives the
210
+ push (`push`): it prints `PUSH <url>` once its listener (`--push-listen`) is up — point the bridge's push target there,
211
+ with the secret of `--push-secret-file` — and `WAIT press: …, message_id <id>` before it waits. `make conformance`
212
+ plays that person through the example's `POST /demo/press`; do the same in your bridge's tests with your messenger's
213
+ own means.
214
+
215
+ ## The protocol copy
216
+
217
+ [`api/bridge.yaml`](https://github.com/muster-io/muster-bridge-python/blob/master/api/bridge.yaml) is a copy of Muster's, never edited here:
218
+ [`src/muster_bridge/models.py`](https://github.com/muster-io/muster-bridge-python/blob/master/src/muster_bridge/models.py), the pydantic models the library validates requests with
219
+ and builds answers from, is generated from it by datamodel-code-generator. `make spec-check` compares it with the one on
220
+ Muster's `master` byte for byte, every day in CI; `make spec-sync` takes the new one and regenerates the models.
221
+
222
+ ## Development
223
+
224
+ Development needs [uv](https://docs.astral.sh/uv/) 0.13 or newer, which installs the Python of `.python-version` and
225
+ the tools locked in `uv.lock`, GNU make and curl; `make conformance` also needs Linux with Docker. The Makefile is the
226
+ only entry point; `make help` lists the targets.
227
+
228
+ | Target | Does |
229
+ |---|---|
230
+ | `make fmt` | formats the code and sorts the imports (ruff) |
231
+ | `make lint` | checks the licence headers, `uv.lock`, and the code with ruff (lint and format) and mypy in strict mode |
232
+ | `make test` | runs the tests with the coverage gate of 90 % of the package (`models.py` excluded) |
233
+ | `make generate`, `make generate-check` | generates `src/muster_bridge/models.py` from `api/bridge.yaml`; fails when it is stale |
234
+ | `make spec-check`, `make spec-sync` | compares `api/bridge.yaml` with Muster's; replaces it and regenerates |
235
+ | `make licenses` | fails on a runtime dependency outside MIT, MIT-0, BSD-2-Clause, BSD-3-Clause, Apache-2.0, ISC, PSF-2.0 and MPL-2.0 |
236
+ | `make build` | builds the sdist and the wheel into `dist/` |
237
+ | `make ci` | `lint`, `generate-check`, `spec-check`, `licenses`, `test` and `build`, as a pull request runs them |
238
+ | `make conformance` | installs the built wheel and uvicorn into a fresh virtual environment, runs the example from it with `python -I`, and runs `muster bridge check` from `ghcr.io/muster-io/muster:edge` (`MUSTER_IMAGE`) against it three times: every feature, only `thread_messages`, and interactively with push |
239
+ | `make clean` | removes `bin/`, `dist/`, `.venv/`, the caches and the coverage data |
240
+
241
+ ## Contributing, security and licence
242
+
243
+ [CONTRIBUTING.md](https://github.com/muster-io/muster-bridge-python/blob/master/CONTRIBUTING.md) says how to contribute, [AGENTS.md](https://github.com/muster-io/muster-bridge-python/blob/master/AGENTS.md) is the guide for everyone who
244
+ changes the code, and [SECURITY.md](https://github.com/muster-io/muster-bridge-python/blob/master/SECURITY.md) how to report a vulnerability privately. The library is under the
245
+ [MIT licence](https://github.com/muster-io/muster-bridge-python/blob/master/LICENSE); contributions are accepted under it, without a CLA.