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.
- docketeer_atproto-0.0.17/.gitignore +12 -0
- docketeer_atproto-0.0.17/CLAUDE.md +18 -0
- docketeer_atproto-0.0.17/PKG-INFO +58 -0
- docketeer_atproto-0.0.17/README.md +40 -0
- docketeer_atproto-0.0.17/pyproject.toml +58 -0
- docketeer_atproto-0.0.17/src/docketeer_atproto/__init__.py +7 -0
- docketeer_atproto-0.0.17/src/docketeer_atproto/band.py +199 -0
- docketeer_atproto-0.0.17/tests/__init__.py +0 -0
- docketeer_atproto-0.0.17/tests/test_band.py +494 -0
|
@@ -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,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]
|