docketeer-atproto 0.0.17__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
+ .venv/
2
+ .envrc.private
3
+ .claude/settings.local.json
4
+ __pycache__/
5
+ *.pyc
6
+ *.egg-info/
7
+ dist/
8
+ build/
9
+ workspace/
10
+ .coverage
11
+ .loq_cache
12
+ .python-version
@@ -0,0 +1,18 @@
1
+ # docketeer-atproto
2
+
3
+ ATProto Jetstream band plugin. Registers a `docketeer.bands` entry point that
4
+ streams ATProto events from a Jetstream relay via WebSocket.
5
+
6
+ ## Structure
7
+
8
+ - **`band.py`** — `JetstreamBand` implementation. Connects to a Jetstream
9
+ relay's `/subscribe` WebSocket endpoint, parses JSON messages into `Signal`
10
+ objects. Handles all three Jetstream event types (commit, identity, account)
11
+ with per-type signal conversion. Supports server-side filtering via
12
+ `wantedCollections` and `wantedDids` query params.
13
+
14
+ ## Testing
15
+
16
+ WebSocket connections are mocked with a `FakeWebSocket` async iterable.
17
+ Tests verify URL construction, cursor handling, filter hints, and signal
18
+ parsing for all three event types without any network access.
@@ -0,0 +1,58 @@
1
+ Metadata-Version: 2.4
2
+ Name: docketeer-atproto
3
+ Version: 0.0.17
4
+ Summary: ATProto Jetstream band plugin for Docketeer
5
+ Project-URL: Homepage, https://github.com/chrisguidry/docketeer
6
+ Project-URL: Repository, https://github.com/chrisguidry/docketeer
7
+ Project-URL: Issues, https://github.com/chrisguidry/docketeer/issues
8
+ Author-email: Chris Guidry <guid@omg.lol>
9
+ License-Expression: MIT
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
14
+ Requires-Python: >=3.12
15
+ Requires-Dist: docketeer
16
+ Requires-Dist: websockets
17
+ Description-Content-Type: text/markdown
18
+
19
+ # docketeer-atproto
20
+
21
+ ATProto Jetstream band plugin for
22
+ [Docketeer](https://pypi.org/project/docketeer/). Streams real-time ATProto
23
+ events from a [Jetstream](https://docs.bsky.app/blog/jetstream) relay via
24
+ WebSocket and produces `Signal` objects for the antenna system.
25
+
26
+ Install `docketeer-atproto` alongside `docketeer` and the band is
27
+ automatically available.
28
+
29
+ ## Configuration
30
+
31
+ | Variable | Default | Description |
32
+ |--------------------------------|--------------------------------------|------------------------------------|
33
+ | `DOCKETEER_ATPROTO_RELAY_URL` | _(round-robin, see below)_ | Override with a single Jetstream relay URL. |
34
+
35
+ By default, the band round-robins between the two public Jetstream relays on
36
+ each reconnect:
37
+ - `wss://jetstream1.us-east.bsky.network/subscribe`
38
+ - `wss://jetstream2.us-east.bsky.network/subscribe`
39
+
40
+ Setting `DOCKETEER_ATPROTO_RELAY_URL` pins to a single relay.
41
+
42
+ ## Event types
43
+
44
+ The band handles all three Jetstream event types:
45
+
46
+ - **commit** — record creates, updates, and deletes. Topic is the collection
47
+ NSID (e.g. `app.bsky.feed.post`). This is the main event type for posts,
48
+ likes, follows, etc.
49
+ - **identity** — handle or DID document changes. Topic is `identity`.
50
+ - **account** — account status changes (activation, deactivation, takedown).
51
+ Topic is `account`.
52
+
53
+ ## Server-side filtering
54
+
55
+ The band pushes compatible filters to the relay as query parameters:
56
+
57
+ - `collection` with `eq` or `startswith` op maps to `wantedCollections`
58
+ - `did` with `eq` op maps to `wantedDids`
@@ -0,0 +1,40 @@
1
+ # docketeer-atproto
2
+
3
+ ATProto Jetstream band plugin for
4
+ [Docketeer](https://pypi.org/project/docketeer/). Streams real-time ATProto
5
+ events from a [Jetstream](https://docs.bsky.app/blog/jetstream) relay via
6
+ WebSocket and produces `Signal` objects for the antenna system.
7
+
8
+ Install `docketeer-atproto` alongside `docketeer` and the band is
9
+ automatically available.
10
+
11
+ ## Configuration
12
+
13
+ | Variable | Default | Description |
14
+ |--------------------------------|--------------------------------------|------------------------------------|
15
+ | `DOCKETEER_ATPROTO_RELAY_URL` | _(round-robin, see below)_ | Override with a single Jetstream relay URL. |
16
+
17
+ By default, the band round-robins between the two public Jetstream relays on
18
+ each reconnect:
19
+ - `wss://jetstream1.us-east.bsky.network/subscribe`
20
+ - `wss://jetstream2.us-east.bsky.network/subscribe`
21
+
22
+ Setting `DOCKETEER_ATPROTO_RELAY_URL` pins to a single relay.
23
+
24
+ ## Event types
25
+
26
+ The band handles all three Jetstream event types:
27
+
28
+ - **commit** — record creates, updates, and deletes. Topic is the collection
29
+ NSID (e.g. `app.bsky.feed.post`). This is the main event type for posts,
30
+ likes, follows, etc.
31
+ - **identity** — handle or DID document changes. Topic is `identity`.
32
+ - **account** — account status changes (activation, deactivation, takedown).
33
+ Topic is `account`.
34
+
35
+ ## Server-side filtering
36
+
37
+ The band pushes compatible filters to the relay as query parameters:
38
+
39
+ - `collection` with `eq` or `startswith` op maps to `wantedCollections`
40
+ - `did` with `eq` op maps to `wantedDids`
@@ -0,0 +1,58 @@
1
+ [project]
2
+ name = "docketeer-atproto"
3
+ dynamic = ["version"]
4
+ description = "ATProto Jetstream band plugin for Docketeer"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Chris Guidry", email = "guid@omg.lol" }
8
+ ]
9
+ license = "MIT"
10
+ requires-python = ">=3.12"
11
+ classifiers = [
12
+ "Development Status :: 3 - Alpha",
13
+ "Programming Language :: Python :: 3",
14
+ "License :: OSI Approved :: MIT License",
15
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
16
+ ]
17
+ dependencies = [
18
+ "docketeer",
19
+ "websockets",
20
+ ]
21
+
22
+ [project.urls]
23
+ Homepage = "https://github.com/chrisguidry/docketeer"
24
+ Repository = "https://github.com/chrisguidry/docketeer"
25
+ Issues = "https://github.com/chrisguidry/docketeer/issues"
26
+
27
+ [project.entry-points."docketeer.bands"]
28
+ atproto = "docketeer_atproto:create_band"
29
+
30
+ [build-system]
31
+ requires = ["hatchling", "hatch-vcs"]
32
+ build-backend = "hatchling.build"
33
+
34
+ [tool.hatch.version]
35
+ source = "vcs"
36
+ raw-options.root = ".."
37
+
38
+ [tool.hatch.build.targets.wheel]
39
+ packages = ["src/docketeer_atproto"]
40
+
41
+ [tool.uv.sources]
42
+ docketeer = { workspace = true }
43
+
44
+ [tool.pytest.ini_options]
45
+ minversion = "9.0"
46
+ timeout = 1
47
+ addopts = [
48
+ "--import-mode=importlib",
49
+ "--cov=docketeer_atproto",
50
+ "--cov=tests",
51
+ "--cov-branch",
52
+ "--cov-report=term-missing",
53
+ "--cov-fail-under=100",
54
+ ]
55
+ asyncio_mode = "auto"
56
+ filterwarnings = [
57
+ "error",
58
+ ]
@@ -0,0 +1,7 @@
1
+ """ATProto Jetstream band plugin for Docketeer."""
2
+
3
+ from docketeer_atproto.band import JetstreamBand
4
+
5
+
6
+ def create_band() -> JetstreamBand:
7
+ return JetstreamBand()
@@ -0,0 +1,199 @@
1
+ """ATProto Jetstream WebSocket band."""
2
+
3
+ import json
4
+ import logging
5
+ import os
6
+ from collections.abc import AsyncGenerator
7
+ from datetime import UTC, datetime
8
+ from typing import Any
9
+ from urllib.parse import urlencode
10
+
11
+ import websockets
12
+
13
+ from docketeer.antenna import Band, Signal, SignalFilter
14
+
15
+ log = logging.getLogger(__name__)
16
+
17
+ DEFAULT_RELAY_URLS = [
18
+ "wss://jetstream1.us-east.bsky.network/subscribe",
19
+ "wss://jetstream2.us-east.bsky.network/subscribe",
20
+ ]
21
+
22
+
23
+ class JetstreamBand(Band):
24
+ """Streams ATProto events from a Jetstream relay via WebSocket."""
25
+
26
+ name = "atproto"
27
+ description = (
28
+ "ATProto Jetstream — real-time Bluesky/AT Protocol events via WebSocket.\n"
29
+ "\n"
30
+ "topic: the collection NSID to subscribe to\n"
31
+ ' e.g. "app.bsky.feed.post" for posts,\n'
32
+ ' "app.bsky.feed.like" for likes,\n'
33
+ ' "app.bsky.graph.follow" for follows\n'
34
+ "\n"
35
+ "filters: narrow the stream by DID or collection\n"
36
+ ' {path: "did", op: "eq", value: "did:plc:..."} — single account\n'
37
+ ' {path: "collection", op: "startswith", value: "app.bsky.feed"}\n'
38
+ " did and collection eq/startswith filters are pushed server-side\n"
39
+ "\n"
40
+ "secrets: not used (Jetstream is a public firehose)\n"
41
+ "\n"
42
+ "Signals produced:\n"
43
+ " Three event kinds, distinguished by topic:\n"
44
+ " commit: topic = collection NSID\n"
45
+ " payload.did, payload.operation, payload.collection, payload.rkey\n"
46
+ " payload.record = the AT Protocol record (post text, like, follow, etc.)\n"
47
+ " For posts: payload.record.text contains the post text\n"
48
+ ' summary = "{did} {operation} {collection}: {post text}"\n'
49
+ ' identity: topic = "identity" (handle/DID changes)\n'
50
+ ' summary = "{did} is now @{handle}"\n'
51
+ ' account: topic = "account" (activation/deactivation)\n'
52
+ ' summary = "{did} account active/deactivated"\n'
53
+ "\n"
54
+ "Filtering tips:\n"
55
+ " To filter posts by content, use:\n"
56
+ ' {path: "payload.record.text", op: "icontains", value: "cat"}'
57
+ )
58
+
59
+ def __init__(self) -> None:
60
+ env_url = os.environ.get("DOCKETEER_ATPROTO_RELAY_URL")
61
+ if env_url:
62
+ self._relay_urls = [env_url]
63
+ else:
64
+ self._relay_urls = list(DEFAULT_RELAY_URLS)
65
+ self._next_relay = 0
66
+
67
+ def _pick_relay(self) -> str:
68
+ url = self._relay_urls[self._next_relay % len(self._relay_urls)]
69
+ self._next_relay += 1
70
+ return url
71
+
72
+ async def __aenter__(self) -> "JetstreamBand":
73
+ return self
74
+
75
+ async def __aexit__(self, *exc: object) -> None:
76
+ pass
77
+
78
+ async def listen(
79
+ self,
80
+ topic: str,
81
+ filters: list[SignalFilter],
82
+ last_signal_id: str = "",
83
+ secrets: dict[str, str] | None = None,
84
+ ) -> AsyncGenerator[Signal, None]:
85
+ params: dict[str, str] = {"wantedCollections": topic}
86
+
87
+ for hint in self.remote_filter_hints(filters):
88
+ if hint.path == "did" and hint.op == "eq":
89
+ params["wantedDids"] = hint.value
90
+
91
+ if last_signal_id:
92
+ params["cursor"] = last_signal_id
93
+
94
+ relay = self._pick_relay()
95
+ url = f"{relay}?{urlencode(params, doseq=True)}"
96
+ log.info("Connecting to Jetstream relay: %s", relay)
97
+
98
+ async with websockets.connect(url) as ws:
99
+ async for raw in ws:
100
+ message: dict[str, Any] = json.loads(raw)
101
+ signal = _message_to_signal(message)
102
+ yield signal
103
+
104
+ def remote_filter_hints(
105
+ self,
106
+ filters: list[SignalFilter],
107
+ ) -> list[SignalFilter]:
108
+ hints: list[SignalFilter] = []
109
+ for f in filters:
110
+ if (f.path == "collection" and f.op in ("eq", "startswith")) or (
111
+ f.path == "did" and f.op == "eq"
112
+ ):
113
+ hints.append(f)
114
+ return hints
115
+
116
+
117
+ def _message_to_signal(message: dict[str, Any]) -> Signal:
118
+ time_us = message.get("time_us", 0)
119
+ timestamp = datetime.fromtimestamp(time_us / 1_000_000, tz=UTC)
120
+ did = message.get("did", "")
121
+ kind = message.get("kind", "commit")
122
+
123
+ if kind == "commit":
124
+ return _commit_to_signal(message, did, timestamp)
125
+ if kind == "identity":
126
+ return _identity_to_signal(message, did, timestamp)
127
+ return _account_to_signal(message, did, timestamp)
128
+
129
+
130
+ def _commit_to_signal(
131
+ message: dict[str, Any],
132
+ did: str,
133
+ timestamp: datetime,
134
+ ) -> Signal:
135
+ commit = message.get("commit", {})
136
+ collection = commit.get("collection", "")
137
+ operation = commit.get("operation", "unknown")
138
+ record = commit.get("record", {})
139
+
140
+ summary = f"{did} {operation} {collection}"
141
+ text = record.get("text", "") if isinstance(record, dict) else ""
142
+ if text:
143
+ summary = f"{summary}: {text}"
144
+
145
+ payload: dict[str, Any] = {
146
+ "did": did,
147
+ "operation": operation,
148
+ "collection": collection,
149
+ "rkey": commit.get("rkey", ""),
150
+ "rev": commit.get("rev", ""),
151
+ }
152
+ if isinstance(record, dict):
153
+ payload["record"] = record
154
+
155
+ return Signal(
156
+ band="atproto",
157
+ signal_id=str(message.get("time_us", 0)),
158
+ timestamp=timestamp,
159
+ topic=collection,
160
+ payload=payload,
161
+ summary=summary,
162
+ )
163
+
164
+
165
+ def _identity_to_signal(
166
+ message: dict[str, Any],
167
+ did: str,
168
+ timestamp: datetime,
169
+ ) -> Signal:
170
+ identity = message.get("identity", {})
171
+ handle = identity.get("handle", "")
172
+
173
+ return Signal(
174
+ band="atproto",
175
+ signal_id=str(message.get("time_us", 0)),
176
+ timestamp=timestamp,
177
+ topic="identity",
178
+ payload=message,
179
+ summary=f"{did} is now @{handle}" if handle else f"{did} identity updated",
180
+ )
181
+
182
+
183
+ def _account_to_signal(
184
+ message: dict[str, Any],
185
+ did: str,
186
+ timestamp: datetime,
187
+ ) -> Signal:
188
+ account = message.get("account", {})
189
+ active = account.get("active", True)
190
+ status = "active" if active else "deactivated"
191
+
192
+ return Signal(
193
+ band="atproto",
194
+ signal_id=str(message.get("time_us", 0)),
195
+ timestamp=timestamp,
196
+ topic="account",
197
+ payload=message,
198
+ summary=f"{did} account {status}",
199
+ )
File without changes
@@ -0,0 +1,494 @@
1
+ """Tests for the ATProto Jetstream band."""
2
+
3
+ import json
4
+ from collections.abc import AsyncIterator
5
+ from contextlib import asynccontextmanager
6
+ from datetime import UTC, datetime
7
+ from typing import Any
8
+ from unittest.mock import patch
9
+
10
+ from docketeer.antenna import SignalFilter
11
+ from docketeer_atproto import create_band
12
+ from docketeer_atproto.band import (
13
+ DEFAULT_RELAY_URLS,
14
+ JetstreamBand,
15
+ _account_to_signal,
16
+ _commit_to_signal,
17
+ _identity_to_signal,
18
+ _message_to_signal,
19
+ )
20
+
21
+
22
+ def test_create_band() -> None:
23
+ band = create_band()
24
+ assert isinstance(band, JetstreamBand)
25
+ assert band.name == "atproto"
26
+
27
+
28
+ # --- Sample message builders ---
29
+
30
+
31
+ def _sample_commit(
32
+ *,
33
+ time_us: int = 1_700_000_000_000_000,
34
+ did: str = "did:plc:abc123",
35
+ collection: str = "app.bsky.feed.post",
36
+ operation: str = "create",
37
+ ) -> dict[str, Any]:
38
+ return {
39
+ "time_us": time_us,
40
+ "did": did,
41
+ "kind": "commit",
42
+ "commit": {
43
+ "collection": collection,
44
+ "operation": operation,
45
+ },
46
+ }
47
+
48
+
49
+ def _sample_identity(
50
+ *,
51
+ time_us: int = 1_700_000_000_000_000,
52
+ did: str = "did:plc:abc123",
53
+ handle: str = "alice.bsky.social",
54
+ ) -> dict[str, Any]:
55
+ return {
56
+ "time_us": time_us,
57
+ "did": did,
58
+ "kind": "identity",
59
+ "identity": {
60
+ "did": did,
61
+ "handle": handle,
62
+ "seq": 12345,
63
+ "time": "2023-11-14T22:13:20Z",
64
+ },
65
+ }
66
+
67
+
68
+ def _sample_account(
69
+ *,
70
+ time_us: int = 1_700_000_000_000_000,
71
+ did: str = "did:plc:abc123",
72
+ active: bool = False,
73
+ ) -> dict[str, Any]:
74
+ return {
75
+ "time_us": time_us,
76
+ "did": did,
77
+ "kind": "account",
78
+ "account": {
79
+ "active": active,
80
+ "did": did,
81
+ "seq": 12346,
82
+ "time": "2023-11-14T22:13:20Z",
83
+ },
84
+ }
85
+
86
+
87
+ # --- Remote filter hints ---
88
+
89
+
90
+ class TestRemoteFilterHints:
91
+ def test_collection_eq(self) -> None:
92
+ band = JetstreamBand()
93
+ filters = [SignalFilter(path="collection", op="eq", value="app.bsky.feed.post")]
94
+ hints = band.remote_filter_hints(filters)
95
+ assert hints == filters
96
+
97
+ def test_collection_startswith(self) -> None:
98
+ band = JetstreamBand()
99
+ filters = [
100
+ SignalFilter(path="collection", op="startswith", value="app.bsky.feed")
101
+ ]
102
+ hints = band.remote_filter_hints(filters)
103
+ assert hints == filters
104
+
105
+ def test_did_eq(self) -> None:
106
+ band = JetstreamBand()
107
+ filters = [SignalFilter(path="did", op="eq", value="did:plc:abc123")]
108
+ hints = band.remote_filter_hints(filters)
109
+ assert hints == filters
110
+
111
+ def test_excludes_non_matching(self) -> None:
112
+ band = JetstreamBand()
113
+ filters = [
114
+ SignalFilter(path="payload.action", op="eq", value="create"),
115
+ SignalFilter(path="did", op="contains", value="plc"),
116
+ SignalFilter(path="collection", op="ne", value="app.bsky.feed.post"),
117
+ ]
118
+ hints = band.remote_filter_hints(filters)
119
+ assert hints == []
120
+
121
+ def test_mixed_filters(self) -> None:
122
+ band = JetstreamBand()
123
+ collection_filter = SignalFilter(
124
+ path="collection", op="eq", value="app.bsky.feed.post"
125
+ )
126
+ did_filter = SignalFilter(path="did", op="eq", value="did:plc:abc123")
127
+ unrelated = SignalFilter(path="payload.action", op="eq", value="create")
128
+ hints = band.remote_filter_hints([collection_filter, did_filter, unrelated])
129
+ assert hints == [collection_filter, did_filter]
130
+
131
+
132
+ # --- Message to signal conversion ---
133
+
134
+
135
+ class TestCommitToSignal:
136
+ def test_basic_commit(self) -> None:
137
+ msg = _sample_commit()
138
+ signal = _commit_to_signal(
139
+ msg, "did:plc:abc123", datetime(2023, 11, 14, 22, 13, 20, tzinfo=UTC)
140
+ )
141
+
142
+ assert signal.band == "atproto"
143
+ assert signal.signal_id == "1700000000000000"
144
+ assert signal.topic == "app.bsky.feed.post"
145
+ assert signal.payload["did"] == "did:plc:abc123"
146
+ assert signal.payload["operation"] == "create"
147
+ assert signal.payload["collection"] == "app.bsky.feed.post"
148
+ assert signal.summary == "did:plc:abc123 create app.bsky.feed.post"
149
+
150
+ def test_commit_with_record_text(self) -> None:
151
+ msg = _sample_commit()
152
+ msg["commit"]["record"] = {"$type": "app.bsky.feed.post", "text": "hello world"}
153
+ signal = _commit_to_signal(
154
+ msg, "did:plc:abc123", datetime(2023, 11, 14, 22, 13, 20, tzinfo=UTC)
155
+ )
156
+
157
+ assert "hello world" in signal.summary
158
+ assert signal.payload["record"]["text"] == "hello world"
159
+
160
+ def test_commit_with_non_dict_record(self) -> None:
161
+ msg = _sample_commit()
162
+ msg["commit"]["record"] = "just a string"
163
+ signal = _commit_to_signal(
164
+ msg, "did:plc:abc123", datetime(2023, 11, 14, 22, 13, 20, tzinfo=UTC)
165
+ )
166
+
167
+ assert "record" not in signal.payload
168
+ assert "just a string" not in signal.summary
169
+
170
+ def test_missing_commit_fields(self) -> None:
171
+ msg: dict[str, Any] = {"kind": "commit"}
172
+ signal = _commit_to_signal(msg, "", datetime(1970, 1, 1, tzinfo=UTC))
173
+
174
+ assert signal.signal_id == "0"
175
+ assert signal.topic == ""
176
+ assert signal.summary == " unknown "
177
+ assert signal.payload["rkey"] == ""
178
+ assert signal.payload["rev"] == ""
179
+
180
+
181
+ class TestIdentityToSignal:
182
+ def test_handle_change(self) -> None:
183
+ msg = _sample_identity(handle="alice.newdomain.com")
184
+ signal = _identity_to_signal(
185
+ msg, "did:plc:abc123", datetime(2023, 11, 14, 22, 13, 20, tzinfo=UTC)
186
+ )
187
+
188
+ assert signal.topic == "identity"
189
+ assert signal.summary == "did:plc:abc123 is now @alice.newdomain.com"
190
+ assert signal.payload == msg
191
+
192
+ def test_missing_handle(self) -> None:
193
+ msg = _sample_identity()
194
+ msg["identity"] = {"did": "did:plc:abc123", "seq": 1, "time": ""}
195
+ signal = _identity_to_signal(
196
+ msg, "did:plc:abc123", datetime(2023, 11, 14, 22, 13, 20, tzinfo=UTC)
197
+ )
198
+
199
+ assert signal.summary == "did:plc:abc123 identity updated"
200
+
201
+ def test_missing_identity_block(self) -> None:
202
+ msg: dict[str, Any] = {"kind": "identity", "time_us": 0}
203
+ signal = _identity_to_signal(msg, "", datetime(1970, 1, 1, tzinfo=UTC))
204
+
205
+ assert signal.topic == "identity"
206
+ assert signal.summary == " identity updated"
207
+
208
+
209
+ class TestAccountToSignal:
210
+ def test_deactivated(self) -> None:
211
+ msg = _sample_account(active=False)
212
+ signal = _account_to_signal(
213
+ msg, "did:plc:abc123", datetime(2023, 11, 14, 22, 13, 20, tzinfo=UTC)
214
+ )
215
+
216
+ assert signal.topic == "account"
217
+ assert signal.summary == "did:plc:abc123 account deactivated"
218
+ assert signal.payload == msg
219
+
220
+ def test_active(self) -> None:
221
+ msg = _sample_account(active=True)
222
+ signal = _account_to_signal(
223
+ msg, "did:plc:abc123", datetime(2023, 11, 14, 22, 13, 20, tzinfo=UTC)
224
+ )
225
+
226
+ assert signal.summary == "did:plc:abc123 account active"
227
+
228
+ def test_missing_account_block(self) -> None:
229
+ msg: dict[str, Any] = {"kind": "account", "time_us": 0}
230
+ signal = _account_to_signal(msg, "", datetime(1970, 1, 1, tzinfo=UTC))
231
+
232
+ assert signal.topic == "account"
233
+ assert signal.summary == " account active"
234
+
235
+
236
+ class TestMessageToSignal:
237
+ def test_dispatches_commit(self) -> None:
238
+ msg = _sample_commit()
239
+ signal = _message_to_signal(msg)
240
+ assert signal.topic == "app.bsky.feed.post"
241
+
242
+ def test_dispatches_identity(self) -> None:
243
+ msg = _sample_identity()
244
+ signal = _message_to_signal(msg)
245
+ assert signal.topic == "identity"
246
+ assert "alice.bsky.social" in signal.summary
247
+
248
+ def test_dispatches_account(self) -> None:
249
+ msg = _sample_account()
250
+ signal = _message_to_signal(msg)
251
+ assert signal.topic == "account"
252
+ assert "deactivated" in signal.summary
253
+
254
+ def test_unknown_kind_treated_as_account(self) -> None:
255
+ msg: dict[str, Any] = {"kind": "unknown_future_type", "time_us": 0, "did": ""}
256
+ signal = _message_to_signal(msg)
257
+ assert signal.topic == "account"
258
+
259
+ def test_missing_kind_defaults_to_commit(self) -> None:
260
+ msg: dict[str, Any] = {"time_us": 0}
261
+ signal = _message_to_signal(msg)
262
+ assert signal.topic == ""
263
+ assert "unknown" in signal.summary
264
+
265
+ def test_timestamp_parsing(self) -> None:
266
+ msg = _sample_commit(time_us=1_700_000_000_000_000)
267
+ signal = _message_to_signal(msg)
268
+ assert signal.timestamp == datetime(2023, 11, 14, 22, 13, 20, tzinfo=UTC)
269
+
270
+
271
+ # --- Context manager ---
272
+
273
+
274
+ class TestContextManager:
275
+ async def test_aenter_aexit(self) -> None:
276
+ band = JetstreamBand()
277
+ async with band as b:
278
+ assert b is band
279
+
280
+
281
+ # --- WebSocket listen ---
282
+
283
+
284
+ class FakeWebSocket:
285
+ """Simulates an async iterable WebSocket connection."""
286
+
287
+ def __init__(self, messages: list[dict[str, Any]]) -> None:
288
+ self._messages = messages
289
+
290
+ def __aiter__(self) -> AsyncIterator[str]:
291
+ return self._iter()
292
+
293
+ async def _iter(self) -> AsyncIterator[str]:
294
+ for msg in self._messages:
295
+ yield json.dumps(msg)
296
+
297
+
298
+ @asynccontextmanager
299
+ async def fake_connect(
300
+ url: str,
301
+ messages: list[dict[str, Any]] | None = None,
302
+ ) -> AsyncIterator[FakeWebSocket]:
303
+ """Drop-in replacement for websockets.connect as an async context manager."""
304
+ yield FakeWebSocket(messages or [])
305
+
306
+
307
+ class TestListen:
308
+ async def test_yields_commit_signals(self) -> None:
309
+ messages = [_sample_commit(), _sample_commit(operation="delete")]
310
+
311
+ with patch(
312
+ "docketeer_atproto.band.websockets.connect",
313
+ return_value=fake_connect("unused", messages),
314
+ ):
315
+ band = JetstreamBand()
316
+ signals = []
317
+ async for signal in band.listen("app.bsky.feed.post", []):
318
+ signals.append(signal)
319
+
320
+ assert len(signals) == 2
321
+ assert signals[0].summary == "did:plc:abc123 create app.bsky.feed.post"
322
+ assert signals[1].summary == "did:plc:abc123 delete app.bsky.feed.post"
323
+
324
+ async def test_yields_mixed_event_types(self) -> None:
325
+ messages = [
326
+ _sample_commit(),
327
+ _sample_identity(),
328
+ _sample_account(),
329
+ ]
330
+
331
+ with patch(
332
+ "docketeer_atproto.band.websockets.connect",
333
+ return_value=fake_connect("unused", messages),
334
+ ):
335
+ band = JetstreamBand()
336
+ signals = []
337
+ async for signal in band.listen("app.bsky.feed.post", []):
338
+ signals.append(signal)
339
+
340
+ assert len(signals) == 3
341
+ assert signals[0].topic == "app.bsky.feed.post"
342
+ assert signals[1].topic == "identity"
343
+ assert signals[2].topic == "account"
344
+
345
+ async def test_cursor_from_last_signal_id(self) -> None:
346
+ captured_urls: list[str] = []
347
+
348
+ original_fake = fake_connect
349
+
350
+ def capturing_connect(url: str) -> Any:
351
+ captured_urls.append(url)
352
+ return original_fake(url, [])
353
+
354
+ with patch(
355
+ "docketeer_atproto.band.websockets.connect",
356
+ side_effect=capturing_connect,
357
+ ):
358
+ band = JetstreamBand()
359
+ async for _ in band.listen(
360
+ "app.bsky.feed.post",
361
+ [],
362
+ last_signal_id="1700000000000000",
363
+ ):
364
+ pass # pragma: no cover
365
+
366
+ assert len(captured_urls) == 1
367
+ assert "cursor=1700000000000000" in captured_urls[0]
368
+
369
+ async def test_did_filter_in_url(self) -> None:
370
+ captured_urls: list[str] = []
371
+
372
+ original_fake = fake_connect
373
+
374
+ def capturing_connect(url: str) -> Any:
375
+ captured_urls.append(url)
376
+ return original_fake(url, [])
377
+
378
+ band = JetstreamBand()
379
+ filters = [
380
+ SignalFilter(path="did", op="eq", value="did:plc:abc123"),
381
+ SignalFilter(path="collection", op="eq", value="app.bsky.feed.post"),
382
+ ]
383
+ with patch(
384
+ "docketeer_atproto.band.websockets.connect",
385
+ side_effect=capturing_connect,
386
+ ):
387
+ async for _ in band.listen("app.bsky.feed.post", filters):
388
+ pass # pragma: no cover
389
+
390
+ assert len(captured_urls) == 1
391
+ assert "wantedDids=did%3Aplc%3Aabc123" in captured_urls[0]
392
+
393
+ async def test_collection_filter_skipped_in_url(self) -> None:
394
+ captured_urls: list[str] = []
395
+
396
+ original_fake = fake_connect
397
+
398
+ def capturing_connect(url: str) -> Any:
399
+ captured_urls.append(url)
400
+ return original_fake(url, [])
401
+
402
+ band = JetstreamBand()
403
+ filters = [SignalFilter(path="collection", op="eq", value="app.bsky.feed.post")]
404
+ with patch(
405
+ "docketeer_atproto.band.websockets.connect",
406
+ side_effect=capturing_connect,
407
+ ):
408
+ async for _ in band.listen("app.bsky.feed.post", filters):
409
+ pass # pragma: no cover
410
+
411
+ assert len(captured_urls) == 1
412
+ assert captured_urls[0].count("wantedCollections") == 1
413
+
414
+ async def test_no_cursor_without_last_signal_id(self) -> None:
415
+ captured_urls: list[str] = []
416
+
417
+ original_fake = fake_connect
418
+
419
+ def capturing_connect(url: str) -> Any:
420
+ captured_urls.append(url)
421
+ return original_fake(url, [])
422
+
423
+ band = JetstreamBand()
424
+ with patch(
425
+ "docketeer_atproto.band.websockets.connect",
426
+ side_effect=capturing_connect,
427
+ ):
428
+ async for _ in band.listen("app.bsky.feed.post", []):
429
+ pass # pragma: no cover
430
+
431
+ assert len(captured_urls) == 1
432
+ assert "cursor" not in captured_urls[0]
433
+
434
+
435
+ # --- Relay URL config ---
436
+
437
+
438
+ class TestRelayUrlConfig:
439
+ def test_default_relay_urls(self) -> None:
440
+ band = JetstreamBand()
441
+ assert len(band._relay_urls) == 2
442
+ assert "jetstream1" in band._relay_urls[0]
443
+ assert "jetstream2" in band._relay_urls[1]
444
+
445
+ def test_custom_relay_url(self) -> None:
446
+ with patch.dict(
447
+ "os.environ",
448
+ {"DOCKETEER_ATPROTO_RELAY_URL": "wss://custom.example.com/subscribe"},
449
+ ):
450
+ band = JetstreamBand()
451
+ assert band._relay_urls == ["wss://custom.example.com/subscribe"]
452
+
453
+ def test_round_robin_across_calls(self) -> None:
454
+ band = JetstreamBand()
455
+ first = band._pick_relay()
456
+ second = band._pick_relay()
457
+ third = band._pick_relay()
458
+
459
+ assert first == DEFAULT_RELAY_URLS[0]
460
+ assert second == DEFAULT_RELAY_URLS[1]
461
+ assert third == DEFAULT_RELAY_URLS[0]
462
+
463
+ def test_round_robin_single_relay(self) -> None:
464
+ with patch.dict(
465
+ "os.environ",
466
+ {"DOCKETEER_ATPROTO_RELAY_URL": "wss://only.one/subscribe"},
467
+ ):
468
+ band = JetstreamBand()
469
+ assert band._pick_relay() == "wss://only.one/subscribe"
470
+ assert band._pick_relay() == "wss://only.one/subscribe"
471
+
472
+ async def test_listen_uses_picked_relay(self) -> None:
473
+ captured_urls: list[str] = []
474
+
475
+ original_fake = fake_connect
476
+
477
+ def capturing_connect(url: str) -> Any:
478
+ captured_urls.append(url)
479
+ return original_fake(url, [])
480
+
481
+ band = JetstreamBand()
482
+ with patch(
483
+ "docketeer_atproto.band.websockets.connect",
484
+ side_effect=capturing_connect,
485
+ ):
486
+ async for _ in band.listen("app.bsky.feed.post", []):
487
+ pass # pragma: no cover
488
+
489
+ async for _ in band.listen("app.bsky.feed.post", []):
490
+ pass # pragma: no cover
491
+
492
+ assert len(captured_urls) == 2
493
+ assert "jetstream1" in captured_urls[0]
494
+ assert "jetstream2" in captured_urls[1]