Skip to content

Commit a637b44

Browse files
Jason SosaJason Sosa
authored andcommitted
feat: Docker setup, relay-status CLI, and integration tests (#3, #4, #5)
- Add Dockerfile and docker-compose.yml for L402 gateway + Phoenixd sidecar (#3) - Add 'lightning-memory relay-status' CLI command showing relay connectivity, last sync time, and push count (#4) - Add check_relay() and check_relays() to relay.py for probing relay health - Add tests/test_sync_integration.py with 8 integration tests using a mock Nostr relay server (websockets): roundtrip push/pull, sync log tracking, dedup, cursor updates, signing requirement, NIP-78 export validation (#5) - Add 3 unit tests for check_relay/check_relays to test_relay.py - Update README with Docker and relay-status docs Closes #3, closes #4, closes #5
1 parent e32011f commit a637b44

File tree

7 files changed

+474
-1
lines changed

7 files changed

+474
-1
lines changed

Dockerfile

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
FROM python:3.12-slim AS builder
2+
3+
WORKDIR /app
4+
COPY pyproject.toml README.md ./
5+
COPY lightning_memory/ lightning_memory/
6+
7+
RUN pip install --no-cache-dir ".[gateway]"
8+
9+
FROM python:3.12-slim
10+
11+
RUN useradd --create-home --shell /bin/bash lm
12+
WORKDIR /home/lm
13+
14+
COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
15+
COPY --from=builder /usr/local/bin/lightning-memory-gateway /usr/local/bin/lightning-memory-gateway
16+
17+
USER lm
18+
19+
EXPOSE 8402
20+
21+
CMD ["lightning-memory-gateway"]

README.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,16 @@ EOF
225225

226226
4. Start: `lightning-memory-gateway`
227227

228+
### Docker
229+
230+
Run the gateway and Phoenixd together:
231+
232+
```bash
233+
PHOENIXD_PASSWORD=your-password docker compose up
234+
```
235+
236+
This starts the L402 gateway on port 8402 with Phoenixd as a sidecar on port 9740. Lightning state is persisted in a Docker volume.
237+
228238
### Client Example
229239

230240
```bash
@@ -240,6 +250,25 @@ curl -H "Authorization: L402 <macaroon>:<preimage>" \
240250
# → 200 + relevant memories
241251
```
242252

253+
## CLI Commands
254+
255+
### `lightning-memory relay-status`
256+
257+
Check connection status for all configured Nostr relays:
258+
259+
```bash
260+
lightning-memory relay-status
261+
# Checking 3 relay(s)...
262+
#
263+
# [+] wss://relay.damus.io: OK
264+
# [+] wss://nos.lol: OK
265+
# [x] wss://relay.nostr.band: FAIL (timeout)
266+
#
267+
# 2/3 relays reachable (5.2s)
268+
# Last pull: 2026-03-09 04:30:12 UTC
269+
# Memories pushed: 42
270+
```
271+
243272
## How It Works
244273

245274
1. **First run**: A Nostr keypair is generated and stored at `~/.lightning-memory/keys/`

docker-compose.yml

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
services:
2+
phoenixd:
3+
image: acinq/phoenixd:latest
4+
ports:
5+
- "9740:9740"
6+
volumes:
7+
- phoenixd-data:/phoenix/.phoenix
8+
environment:
9+
- PHOENIX_HTTP_PASSWORD=${PHOENIXD_PASSWORD:-changeme}
10+
11+
gateway:
12+
build: .
13+
ports:
14+
- "8402:8402"
15+
depends_on:
16+
- phoenixd
17+
volumes:
18+
- gateway-config:/home/lm/.lightning-memory
19+
environment:
20+
- PHOENIXD_URL=http://phoenixd:9740
21+
- PHOENIXD_PASSWORD=${PHOENIXD_PASSWORD:-changeme}
22+
23+
volumes:
24+
phoenixd-data:
25+
gateway-config:

lightning_memory/relay.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,43 @@ async def publish_to_relays(
131131
return list(await asyncio.gather(*tasks))
132132

133133

134+
async def check_relay(relay_url: str, timeout: float = 5.0) -> RelayResponse:
135+
"""Check if a relay is reachable by opening a WebSocket connection.
136+
137+
Args:
138+
relay_url: WebSocket URL (wss://...)
139+
timeout: Connection timeout in seconds
140+
141+
Returns:
142+
RelayResponse with success=True if the relay accepted the connection
143+
"""
144+
if websockets is None:
145+
return RelayResponse(
146+
relay=relay_url, success=False,
147+
message="websockets not installed. pip install lightning-memory[sync]",
148+
)
149+
150+
try:
151+
async with websockets.connect(relay_url, close_timeout=2, open_timeout=timeout) as ws:
152+
# Send a REQ and immediately close to verify the relay speaks NIP-01
153+
sub_id = uuid.uuid4().hex[:8]
154+
await ws.send(json.dumps(["REQ", sub_id, {"kinds": [30078], "limit": 0}]))
155+
raw = await asyncio.wait_for(ws.recv(), timeout=timeout)
156+
await ws.send(json.dumps(["CLOSE", sub_id]))
157+
data = json.loads(raw)
158+
if isinstance(data, list) and data[0] in ("EOSE", "EVENT", "NOTICE"):
159+
return RelayResponse(relay=relay_url, success=True, message="connected")
160+
return RelayResponse(relay=relay_url, success=True, message=f"response: {data[0]}")
161+
except Exception as e:
162+
return RelayResponse(relay=relay_url, success=False, message=str(e))
163+
164+
165+
async def check_relays(relay_urls: list[str], timeout: float = 5.0) -> list[RelayResponse]:
166+
"""Check multiple relays concurrently."""
167+
tasks = [check_relay(url, timeout) for url in relay_urls]
168+
return list(await asyncio.gather(*tasks))
169+
170+
134171
async def fetch_from_relays(
135172
relay_urls: list[str],
136173
filters: dict[str, Any],

lightning_memory/server.py

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -298,8 +298,61 @@ def ln_budget_status() -> dict:
298298
}
299299

300300

301+
def _cmd_relay_status() -> None:
302+
"""Show connection status for configured Nostr relays."""
303+
import asyncio
304+
import time
305+
306+
from .config import load_config
307+
from .relay import check_relays
308+
309+
config = load_config()
310+
relays = config.relays
311+
print(f"Checking {len(relays)} relay(s)...\n")
312+
313+
start = time.monotonic()
314+
results = asyncio.run(check_relays(relays))
315+
elapsed = time.monotonic() - start
316+
317+
ok_count = 0
318+
for r in results:
319+
status = "OK" if r.success else "FAIL"
320+
icon = "+" if r.success else "x"
321+
msg = f" [{icon}] {r.relay}: {status}"
322+
if r.message and r.message != "connected":
323+
msg += f" ({r.message})"
324+
print(msg)
325+
if r.success:
326+
ok_count += 1
327+
328+
print(f"\n{ok_count}/{len(relays)} relays reachable ({elapsed:.1f}s)")
329+
330+
# Show last sync info if available
331+
try:
332+
engine = _get_engine()
333+
from .sync import _ensure_sync_schema, _get_cursor
334+
_ensure_sync_schema(engine.conn)
335+
last_pull = _get_cursor(engine.conn, "last_pull_timestamp")
336+
if last_pull:
337+
from datetime import datetime, timezone
338+
ts = datetime.fromtimestamp(float(last_pull), tz=timezone.utc)
339+
print(f"Last pull: {ts.strftime('%Y-%m-%d %H:%M:%S UTC')}")
340+
341+
synced = engine.conn.execute("SELECT COUNT(*) FROM sync_log").fetchone()[0]
342+
if synced:
343+
print(f"Memories pushed: {synced}")
344+
except Exception:
345+
pass
346+
347+
301348
def main():
302-
"""Run the MCP server."""
349+
"""Run the MCP server, or a CLI subcommand."""
350+
import sys
351+
352+
if len(sys.argv) > 1 and sys.argv[1] == "relay-status":
353+
_cmd_relay_status()
354+
return
355+
303356
mcp.run()
304357

305358

tests/test_relay.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88

99
from lightning_memory.relay import (
1010
RelayResponse,
11+
check_relay,
12+
check_relays,
1113
fetch_events,
1214
fetch_from_relays,
1315
publish_event,
@@ -141,6 +143,40 @@ def test_fetch_from_multiple(self):
141143
assert len(results) == 2
142144

143145

146+
class TestCheckRelay:
147+
def test_reachable_relay(self):
148+
eose_response = json.dumps(["EOSE", "sub1"])
149+
mock_connect = _mock_ws_connect([eose_response])
150+
151+
with patch("lightning_memory.relay.websockets") as mock_ws:
152+
mock_ws.connect = MagicMock(return_value=mock_connect)
153+
154+
result = asyncio.run(check_relay("wss://test.relay"))
155+
156+
assert result.success is True
157+
assert result.relay == "wss://test.relay"
158+
159+
def test_unreachable_relay(self):
160+
with patch("lightning_memory.relay.websockets") as mock_ws:
161+
mock_ws.connect = MagicMock(side_effect=ConnectionRefusedError("refused"))
162+
163+
result = asyncio.run(check_relay("wss://bad.relay"))
164+
165+
assert result.success is False
166+
assert "refused" in result.message
167+
168+
def test_check_multiple_relays(self):
169+
eose_response = json.dumps(["EOSE", "sub1"])
170+
mock_connect = _mock_ws_connect([eose_response])
171+
172+
with patch("lightning_memory.relay.websockets") as mock_ws:
173+
mock_ws.connect = MagicMock(return_value=mock_connect)
174+
175+
results = asyncio.run(check_relays(["wss://r1", "wss://r2"]))
176+
177+
assert len(results) == 2
178+
179+
144180
class TestRelayResponse:
145181
def test_defaults(self):
146182
r = RelayResponse(relay="wss://test", success=True)

0 commit comments

Comments
 (0)