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.
- muster_bridge-0.1.0/.gitignore +12 -0
- muster_bridge-0.1.0/LICENSE +21 -0
- muster_bridge-0.1.0/PKG-INFO +277 -0
- muster_bridge-0.1.0/README.md +245 -0
- muster_bridge-0.1.0/api/bridge.yaml +1317 -0
- muster_bridge-0.1.0/examples/memory_bridge.py +414 -0
- muster_bridge-0.1.0/pyproject.toml +122 -0
- muster_bridge-0.1.0/src/muster_bridge/__init__.py +112 -0
- muster_bridge-0.1.0/src/muster_bridge/app.py +900 -0
- muster_bridge-0.1.0/src/muster_bridge/config.py +335 -0
- muster_bridge-0.1.0/src/muster_bridge/events.py +287 -0
- muster_bridge-0.1.0/src/muster_bridge/mentions.py +73 -0
- muster_bridge-0.1.0/src/muster_bridge/messenger.py +144 -0
- muster_bridge-0.1.0/src/muster_bridge/models.py +443 -0
- muster_bridge-0.1.0/src/muster_bridge/problems.py +273 -0
- muster_bridge-0.1.0/src/muster_bridge/push.py +303 -0
- muster_bridge-0.1.0/src/muster_bridge/py.typed +0 -0
- muster_bridge-0.1.0/src/muster_bridge/store.py +286 -0
- muster_bridge-0.1.0/tests/conftest.py +653 -0
- muster_bridge-0.1.0/tests/test_app.py +978 -0
- muster_bridge-0.1.0/tests/test_config.py +414 -0
- muster_bridge-0.1.0/tests/test_events.py +560 -0
- muster_bridge-0.1.0/tests/test_mentions.py +131 -0
- muster_bridge-0.1.0/tests/test_problems.py +242 -0
- muster_bridge-0.1.0/tests/test_push.py +592 -0
- muster_bridge-0.1.0/tests/test_spec.py +409 -0
- muster_bridge-0.1.0/tests/test_store.py +225 -0
|
@@ -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.
|