fleetmesh-node 0.2.0__py3-none-any.whl

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.
Files changed (338) hide show
  1. fleetmesh_node-0.2.0.dist-info/METADATA +57 -0
  2. fleetmesh_node-0.2.0.dist-info/RECORD +338 -0
  3. fleetmesh_node-0.2.0.dist-info/WHEEL +5 -0
  4. fleetmesh_node-0.2.0.dist-info/entry_points.txt +2 -0
  5. fleetmesh_node-0.2.0.dist-info/licenses/LICENSE +121 -0
  6. fleetmesh_node-0.2.0.dist-info/top_level.txt +2 -0
  7. node/RELEASE +1 -0
  8. node/__init__.py +35 -0
  9. node/app/__init__.py +49 -0
  10. node/app/commands.py +265 -0
  11. node/app/config.py +131 -0
  12. node/app/control.py +670 -0
  13. node/app/core.py +867 -0
  14. node/app/plugins.py +76 -0
  15. node/app/router.py +83 -0
  16. node/app/socket.py +431 -0
  17. node/app/systemd.py +36 -0
  18. node/app/views.py +204 -0
  19. node/channels/__init__.py +378 -0
  20. node/channels/bus.py +331 -0
  21. node/channels/journal.py +46 -0
  22. node/commands/__init__.py +140 -0
  23. node/commands/adapter.py +210 -0
  24. node/commands/base.py +278 -0
  25. node/commands/cluster_cmds.py +183 -0
  26. node/commands/compute_cmds.py +401 -0
  27. node/commands/core.py +474 -0
  28. node/commands/gossip_cmds.py +106 -0
  29. node/commands/nsite_cmds.py +141 -0
  30. node/commands/peering.py +402 -0
  31. node/commands/plugin_cmds.py +135 -0
  32. node/commands/sidecar_cmds.py +120 -0
  33. node/commands/sync.py +388 -0
  34. node/commands/transport_cmds.py +451 -0
  35. node/commands/video_cmds.py +99 -0
  36. node/compartment/__init__.py +124 -0
  37. node/compartment/base.py +868 -0
  38. node/compartment/command.py +458 -0
  39. node/compartment/context.py +163 -0
  40. node/compartment/every.py +108 -0
  41. node/compartment/handler.py +131 -0
  42. node/compartment/lifecycle.py +460 -0
  43. node/compartment/lint.py +388 -0
  44. node/compartment/need.py +158 -0
  45. node/compartment/problem.py +521 -0
  46. node/compartment/refusals.py +120 -0
  47. node/compartment/registry.py +790 -0
  48. node/compartment/schema.py +290 -0
  49. node/compartment/sidecar.py +499 -0
  50. node/compartment/stores.py +662 -0
  51. node/compartment/wire.py +225 -0
  52. node/compartment/worker.py +61 -0
  53. node/config/__init__.py +43 -0
  54. node/config/base.py +116 -0
  55. node/config/change.py +441 -0
  56. node/config/changestore.py +201 -0
  57. node/config/compartments.py +1144 -0
  58. node/config/node.py +318 -0
  59. node/config/parameter.py +563 -0
  60. node/config/protocols.py +274 -0
  61. node/config/records.py +171 -0
  62. node/examples/__init__.py +1 -0
  63. node/examples/demo.py +226 -0
  64. node/examples/follower.py +46 -0
  65. node/interfaces.py +235 -0
  66. node/keystore/__init__.py +27 -0
  67. node/keystore/core.py +103 -0
  68. node/keystore/enclave.py +688 -0
  69. node/meshnode.py +111 -0
  70. node/metrics/__init__.py +396 -0
  71. node/metrics/adapters.py +102 -0
  72. node/metrics/face.py +110 -0
  73. node/planes/__init__.py +21 -0
  74. node/planes/identity/__init__.py +20 -0
  75. node/planes/identity/announce/__init__.py +9 -0
  76. node/planes/identity/announce/compartment.py +447 -0
  77. node/planes/identity/announce/types.py +26 -0
  78. node/planes/identity/discovery/__init__.py +9 -0
  79. node/planes/identity/discovery/compartment.py +522 -0
  80. node/planes/identity/discovery/types.py +13 -0
  81. node/planes/identity/linked/__init__.py +1 -0
  82. node/planes/identity/linked/compartment.py +270 -0
  83. node/planes/identity/linked/types.py +25 -0
  84. node/planes/identity/presence/__init__.py +7 -0
  85. node/planes/identity/presence/compartment.py +409 -0
  86. node/planes/identity/presence/types.py +14 -0
  87. node/planes/operations/__init__.py +13 -0
  88. node/planes/operations/scheduler/__init__.py +9 -0
  89. node/planes/operations/scheduler/compartment.py +353 -0
  90. node/planes/operations/scheduler/types.py +21 -0
  91. node/planes/operations/updates/__init__.py +17 -0
  92. node/planes/operations/updates/core.py +310 -0
  93. node/planes/operations/updates/store.py +79 -0
  94. node/planes/operations/updates/types.py +20 -0
  95. node/planes/peering/__init__.py +28 -0
  96. node/planes/peering/budget/__init__.py +43 -0
  97. node/planes/peering/budget/chain.py +43 -0
  98. node/planes/peering/budget/core.py +537 -0
  99. node/planes/peering/budget/evaluation.py +80 -0
  100. node/planes/peering/budget/meter.py +100 -0
  101. node/planes/peering/budget/receipts.py +439 -0
  102. node/planes/peering/budget/shape.py +101 -0
  103. node/planes/peering/budget/tick.py +110 -0
  104. node/planes/peering/budget/types.py +67 -0
  105. node/planes/peering/budget/windows.py +346 -0
  106. node/planes/peering/capacity/__init__.py +7 -0
  107. node/planes/peering/capacity/compartment.py +342 -0
  108. node/planes/peering/capacity/types.py +24 -0
  109. node/planes/peering/peering/__init__.py +25 -0
  110. node/planes/peering/peering/core.py +464 -0
  111. node/planes/peering/peering/envelope.py +142 -0
  112. node/planes/peering/peering/handshake.py +198 -0
  113. node/planes/peering/peering/shape.py +80 -0
  114. node/planes/peering/peering/types.py +39 -0
  115. node/planes/peering/reporter/__init__.py +42 -0
  116. node/planes/peering/reporter/constants.py +29 -0
  117. node/planes/peering/reporter/core.py +299 -0
  118. node/planes/peering/reporter/finding.py +74 -0
  119. node/planes/peering/reporter/findings.py +171 -0
  120. node/planes/peering/reporter/publish.py +285 -0
  121. node/planes/peering/reporter/reading.py +193 -0
  122. node/planes/peering/reporter/shape.py +86 -0
  123. node/planes/peering/reporter/types.py +50 -0
  124. node/planes/peering/strain/__init__.py +9 -0
  125. node/planes/peering/strain/compartment.py +204 -0
  126. node/planes/peering/strain/types.py +21 -0
  127. node/planes/peering/trust/__init__.py +9 -0
  128. node/planes/peering/trust/compartment.py +239 -0
  129. node/planes/peering/trust/types.py +30 -0
  130. node/planes/services/__init__.py +33 -0
  131. node/planes/services/compute/__init__.py +160 -0
  132. node/planes/services/compute/common.py +134 -0
  133. node/planes/services/compute/compartment.py +681 -0
  134. node/planes/services/compute/executors/__init__.py +121 -0
  135. node/planes/services/compute/executors/agent.py +840 -0
  136. node/planes/services/compute/executors/bacalhau.py +605 -0
  137. node/planes/services/compute/executors/base.py +127 -0
  138. node/planes/services/compute/executors/harness.py +439 -0
  139. node/planes/services/compute/executors/mcp.py +454 -0
  140. node/planes/services/compute/executors/media.py +898 -0
  141. node/planes/services/compute/executors/nondet.py +283 -0
  142. node/planes/services/compute/executors/providers.py +499 -0
  143. node/planes/services/compute/executors/registry.py +140 -0
  144. node/planes/services/compute/executors/wasm.py +244 -0
  145. node/planes/services/compute/executors/zk.py +604 -0
  146. node/planes/services/compute/jobs.py +227 -0
  147. node/planes/services/compute/load.py +180 -0
  148. node/planes/services/compute/market.py +433 -0
  149. node/planes/services/compute/sandbox.py +282 -0
  150. node/planes/services/compute/types.py +84 -0
  151. node/planes/services/compute_wire/__init__.py +58 -0
  152. node/planes/services/compute_wire/compartment.py +256 -0
  153. node/planes/services/compute_wire/messages.py +320 -0
  154. node/planes/services/compute_wire/sealing.py +147 -0
  155. node/planes/services/compute_wire/session.py +572 -0
  156. node/planes/services/compute_wire/types.py +49 -0
  157. node/planes/services/compute_wire/verify.py +331 -0
  158. node/planes/services/dvm/__init__.py +24 -0
  159. node/planes/services/dvm/gateway.py +526 -0
  160. node/planes/services/dvm/protocol.py +35 -0
  161. node/planes/services/dvm/translate.py +15 -0
  162. node/planes/services/dvm/types.py +25 -0
  163. node/planes/services/gateway/__init__.py +40 -0
  164. node/planes/services/gateway/settle.py +279 -0
  165. node/planes/services/gateway/translate.py +644 -0
  166. node/planes/services/gossip/__init__.py +22 -0
  167. node/planes/services/gossip/core.py +929 -0
  168. node/planes/services/gossip/envelope.py +154 -0
  169. node/planes/services/gossip/mesh.py +127 -0
  170. node/planes/services/gossip/types.py +50 -0
  171. node/planes/services/nsite/__init__.py +7 -0
  172. node/planes/services/nsite/client.py +62 -0
  173. node/planes/services/nsite/compartment.py +752 -0
  174. node/planes/services/nsite/types.py +44 -0
  175. node/planes/services/paidfetch/__init__.py +7 -0
  176. node/planes/services/paidfetch/compartment.py +862 -0
  177. node/planes/services/paidfetch/types.py +99 -0
  178. node/planes/services/settle/__init__.py +27 -0
  179. node/planes/services/settle/compartment.py +384 -0
  180. node/planes/services/settle/engine.py +456 -0
  181. node/planes/services/settle/gateways.py +541 -0
  182. node/planes/services/settle/payment.py +512 -0
  183. node/planes/services/settle/store.py +74 -0
  184. node/planes/services/settle/types.py +58 -0
  185. node/planes/services/sidecars/__init__.py +8 -0
  186. node/planes/services/sidecars/compartment.py +461 -0
  187. node/planes/services/sidecars/manifest.py +304 -0
  188. node/planes/services/sidecars/manifests/10-grasp.json +37 -0
  189. node/planes/services/sidecars/manifests/20-ncontainer.json +31 -0
  190. node/planes/services/sidecars/manifests/30-magpie.json +28 -0
  191. node/planes/services/sidecars/types.py +43 -0
  192. node/planes/services/video/__init__.py +106 -0
  193. node/planes/services/video/barter.py +407 -0
  194. node/planes/services/video/cli.py +158 -0
  195. node/planes/services/video/compartment.py +932 -0
  196. node/planes/services/video/ingest.py +524 -0
  197. node/planes/services/video/ledger.py +705 -0
  198. node/planes/services/video/packager.py +847 -0
  199. node/planes/services/video/player.py +456 -0
  200. node/planes/services/video/timed_segments.py +124 -0
  201. node/planes/services/video/transit.py +478 -0
  202. node/planes/store/__init__.py +24 -0
  203. node/planes/store/blob_replication/__init__.py +7 -0
  204. node/planes/store/blob_replication/compartment.py +496 -0
  205. node/planes/store/blob_replication/types.py +35 -0
  206. node/planes/store/blob_wire/__init__.py +48 -0
  207. node/planes/store/blob_wire/client.py +57 -0
  208. node/planes/store/blob_wire/core.py +359 -0
  209. node/planes/store/blob_wire/intake.py +74 -0
  210. node/planes/store/blob_wire/messages.py +85 -0
  211. node/planes/store/projected/__init__.py +7 -0
  212. node/planes/store/projected/compartment.py +258 -0
  213. node/planes/store/projected/types.py +29 -0
  214. node/planes/store/retention/__init__.py +7 -0
  215. node/planes/store/retention/compartment.py +214 -0
  216. node/planes/store/retention/types.py +12 -0
  217. node/planes/store/sync/__init__.py +35 -0
  218. node/planes/store/sync/core.py +311 -0
  219. node/planes/store/sync/envelope.py +106 -0
  220. node/planes/store/sync/handover.py +206 -0
  221. node/planes/store/sync/initiator.py +128 -0
  222. node/planes/store/sync/responder.py +114 -0
  223. node/planes/store/sync/rules.py +179 -0
  224. node/planes/store/sync/shape.py +80 -0
  225. node/planes/store/sync/types.py +51 -0
  226. node/planes/transport/__init__.py +23 -0
  227. node/planes/transport/bridge/__init__.py +13 -0
  228. node/planes/transport/bridge/compartment.py +785 -0
  229. node/planes/transport/bridge/ratelimit.py +44 -0
  230. node/planes/transport/bridge/types.py +43 -0
  231. node/planes/transport/lan/__init__.py +9 -0
  232. node/planes/transport/lan/compartment.py +446 -0
  233. node/planes/transport/lan/types.py +25 -0
  234. node/planes/transport/link/__init__.py +7 -0
  235. node/planes/transport/link/compartment.py +257 -0
  236. node/planes/transport/link/types.py +27 -0
  237. node/planes/transport/mediation/__init__.py +45 -0
  238. node/planes/transport/mediation/compartment.py +238 -0
  239. node/planes/transport/mediation/leaf.py +107 -0
  240. node/planes/transport/mediation/messages.py +302 -0
  241. node/planes/transport/mediation/service.py +233 -0
  242. node/planes/transport/mediation/store.py +37 -0
  243. node/planes/transport/mediation/types.py +20 -0
  244. node/planes/transport/p2p/__init__.py +7 -0
  245. node/planes/transport/p2p/compartment.py +349 -0
  246. node/planes/transport/p2p/types.py +22 -0
  247. node/protocols/__init__.py +277 -0
  248. node/protocols/activitypub/__init__.py +56 -0
  249. node/protocols/activitypub/actor.py +230 -0
  250. node/protocols/activitypub/federation.py +490 -0
  251. node/protocols/activitypub/media.py +145 -0
  252. node/protocols/activitypub/models.py +286 -0
  253. node/protocols/activitypub/package.py +271 -0
  254. node/protocols/activitypub/store.py +564 -0
  255. node/protocols/arcade/__init__.py +26 -0
  256. node/protocols/atproto/__init__.py +13 -0
  257. node/protocols/atproto/firehose.py +126 -0
  258. node/protocols/atproto/metering.py +165 -0
  259. node/protocols/atproto/package.py +155 -0
  260. node/protocols/atproto/repo.py +250 -0
  261. node/protocols/atproto/xrpc.py +285 -0
  262. node/protocols/base.py +109 -0
  263. node/protocols/circles/__init__.py +29 -0
  264. node/protocols/dm/__init__.py +50 -0
  265. node/protocols/grasp/__init__.py +22 -0
  266. node/protocols/isotime.py +15 -0
  267. node/protocols/nostr/__init__.py +51 -0
  268. node/protocols/nostr/blobstore.py +383 -0
  269. node/protocols/nostr/blossom.py +627 -0
  270. node/protocols/nostr/client.py +377 -0
  271. node/protocols/nostr/descriptor.py +211 -0
  272. node/protocols/nostr/eventstore.py +304 -0
  273. node/protocols/nostr/package.py +182 -0
  274. node/protocols/nostr/peers.py +109 -0
  275. node/protocols/nostr/relayserver.py +656 -0
  276. node/protocols/p2panda/__init__.py +22 -0
  277. node/protocols/p2panda/identity.py +19 -0
  278. node/protocols/p2panda/package.py +236 -0
  279. node/protocols/p2panda/rung.py +152 -0
  280. node/protocols/p2panda/store.py +273 -0
  281. node/protocols/rhio/__init__.py +33 -0
  282. node/protocols/rhio/package.py +275 -0
  283. node/protocols/rhio/rung.py +136 -0
  284. node/protocols/rhio/store.py +1423 -0
  285. node/protocols/ssb/muxrpc.py +256 -0
  286. node/protocols/ssb/package.py +267 -0
  287. node/protocols/textile/__init__.py +111 -0
  288. node/protocols/textile/transducers.py +259 -0
  289. node/release.py +119 -0
  290. node/storage/__init__.py +88 -0
  291. node/storage/accounts.py +305 -0
  292. node/storage/adapter.py +161 -0
  293. node/storage/base.py +385 -0
  294. node/storage/dialect.py +209 -0
  295. node/storage/grants.py +317 -0
  296. node/storage/locator.py +150 -0
  297. node/storage/postgres.py +294 -0
  298. node/storage/projected.py +953 -0
  299. node/storage/sharding.py +448 -0
  300. node/storage/standing.py +568 -0
  301. node/testnet/__init__.py +9 -0
  302. node/testnet/cluster.py +731 -0
  303. node/testnet/fips_daemon.py +230 -0
  304. node/transport/__init__.py +94 -0
  305. node/transport/const.py +110 -0
  306. node/transport/didcomm_inbound.py +251 -0
  307. node/transport/dnstxt.py +169 -0
  308. node/transport/fips_rung/__init__.py +67 -0
  309. node/transport/fips_rung/core.py +666 -0
  310. node/transport/fips_rung/listener.py +96 -0
  311. node/transport/fips_rung/types.py +12 -0
  312. node/transport/host.py +334 -0
  313. node/transport/iroh_rung/__init__.py +29 -0
  314. node/transport/iroh_rung/core.py +477 -0
  315. node/transport/iroh_rung/quic.py +942 -0
  316. node/transport/iroh_rung/types.py +22 -0
  317. node/transport/ladder.py +145 -0
  318. node/transport/lineproto.py +49 -0
  319. node/transport/mdns.py +303 -0
  320. node/transport/netfetch.py +283 -0
  321. node/transport/relay_rung/__init__.py +23 -0
  322. node/transport/relay_rung/cooldown.py +87 -0
  323. node/transport/relay_rung/core.py +138 -0
  324. node/transport/relay_rung/inbound.py +161 -0
  325. node/transport/relay_rung/ledger.py +104 -0
  326. node/transport/relay_rung/listen.py +121 -0
  327. node/transport/relay_rung/outbound.py +181 -0
  328. node/transport/relay_rung/shape.py +71 -0
  329. node/transport/relay_rung/types.py +58 -0
  330. node/transport/relay_rung/wire.py +63 -0
  331. node/transport/router.py +761 -0
  332. node/transport/rung.py +276 -0
  333. node/transport/ssb_rung.py +166 -0
  334. node/transport/ws.py +502 -0
  335. sidecar/fleetmesh_announce.py +338 -0
  336. sidecar/fleetmesh_p2p.py +373 -0
  337. sidecar/fleetmesh_rhio.py +352 -0
  338. sidecar/fleetmesh_ssb.py +603 -0
@@ -0,0 +1,785 @@
1
+ """Universal Nostr Relay Bridge for fleetmesh.
2
+
3
+ Bridges external standard Nostr relays (strfry, nostr-rs-relay, khatru, coracle, damus)
4
+ with the local fleetmesh node:
5
+ 1. Ingress Engine: Connects to designated external relays over WebSocket (via Relay/ws),
6
+ subscribes to shard topics (#t), mesh control kinds (11801, 11803, 30803, etc.) or
7
+ user content kinds (0, 1, 3, 7, 1059, etc.), cryptographically verifies BIP-340 signatures,
8
+ enforces future timestamp and expiry limits, deduplicates, and ingests into the local
9
+ NodeStore with origin recorded.
10
+ 2. Egress Engine: Queues and publishes verified events (produced locally or across the mesh)
11
+ out to designated external relays, tracking acceptance, handling NIP-42 auth challenges,
12
+ and retrying on transient errors.
13
+ 3. Flow Control & Capacity: Sliding-window rate limiting per relay to protect node
14
+ memory and disk from upstream relay flooding, integrated with the Compartment refusal
15
+ and trouble reporting interfaces.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import threading
21
+ import time
22
+ from typing import Any, Callable, cast, Mapping, Optional, Sequence, Union
23
+ from fleetmesh_core.storage import store # noqa: E402
24
+ from node import channels as ch_mod # noqa: E402
25
+ from node import compartment as compartment_mod # noqa: E402
26
+ from node.compartment import Command, Compartment, Need # noqa: E402
27
+ from node.config.parameter import Parameter # noqa: E402
28
+ from node import metrics as mx_mod # noqa: E402
29
+ from node.protocols.nostr import client as relay # noqa: E402
30
+ from fleetmesh_core.crypto import giftwrap # noqa: E402
31
+ from fleetmesh_core.wire.interfaces import NostrEvent # noqa: E402
32
+ from node.config import BridgeConfig # noqa: E402
33
+ from node.config import compartments as cfgblocks # noqa: E402
34
+ from node.compartment import stores as st # noqa: E402
35
+ from node.planes.transport.bridge.ratelimit import RateLimiter # noqa: E402 (shared, audit D4)
36
+
37
+ # The defaults live on the config block (node/config/compartments.py) so the
38
+ # check and the compartment cannot disagree; re-exported here for callers.
39
+ DEFAULT_INGRESS_KINDS = cfgblocks.DEFAULT_INGRESS_KINDS
40
+ DEFAULT_EGRESS_KINDS = cfgblocks.DEFAULT_EGRESS_KINDS
41
+ DEFAULT_INGRESS_FILTERS = cfgblocks.DEFAULT_INGRESS_FILTERS
42
+ DEFAULT_RATE_LIMIT_PER_MIN = cfgblocks.DEFAULT_RATE_LIMIT_PER_MIN
43
+ DEFAULT_MAX_FUTURE_SECONDS = cfgblocks.DEFAULT_MAX_FUTURE_SECONDS
44
+ DEFAULT_MAX_EVENT_BYTES = cfgblocks.DEFAULT_MAX_EVENT_BYTES
45
+
46
+
47
+ from .types import (
48
+ RelayWorkerStats,
49
+ BridgeStateReport,
50
+ )
51
+
52
+ class BridgeError(Exception):
53
+ pass
54
+
55
+
56
+ class RelayBridgeWorker:
57
+ """Manages an active subscription and streaming connection to a single external Nostr relay."""
58
+
59
+ def __init__(
60
+ self,
61
+ url: str,
62
+ filters: Sequence[Mapping[str, Any]],
63
+ on_event: Callable[[NostrEvent, str], Any],
64
+ auth_signer: Optional[Callable[[str, str], Optional[Mapping[str, Any]]]] = None,
65
+ timeout: float = 15.0,
66
+ stop_event: Optional[threading.Event] = None,
67
+ ) -> None:
68
+ self.url: str = url
69
+ self.filters: list[Mapping[str, Any]] = list(filters)
70
+ self.on_event: Callable[[NostrEvent, str], Any] = on_event
71
+ self.auth_signer: Optional[Callable[[str, str], Optional[Mapping[str, Any]]]] = auth_signer
72
+ self.timeout: float = timeout
73
+
74
+ self.connected: bool = False
75
+ self.last_connected_at: Optional[int] = None
76
+ self.ingress_count: int = 0
77
+ self.egress_count: int = 0
78
+ self.errors_count: int = 0
79
+ self.last_error: Optional[str] = None
80
+ # Exceptions raised by on_event; the subscription outlives them.
81
+ self.handler_errors: int = 0
82
+ self.last_handler_error: Optional[str] = None
83
+ self.last_event_at: Optional[int] = None
84
+
85
+ self._stop_event: threading.Event = threading.Event()
86
+ self._bridge_stop_event: Optional[threading.Event] = stop_event
87
+ self._thread: Optional[threading.Thread] = None
88
+ self._relay: Optional[relay.Relay] = None
89
+ self._lock: threading.Lock = threading.Lock()
90
+
91
+ def _is_stopped(self) -> bool:
92
+ return self._stop_event.is_set() or (self._bridge_stop_event is not None and self._bridge_stop_event.is_set())
93
+
94
+ def start(self) -> None:
95
+ with self._lock:
96
+ if self._thread is not None and self._thread.is_alive():
97
+ return
98
+ self._stop_event.clear()
99
+ self._thread = threading.Thread(target=self._run, name=f'relay-bridge-{self.url}', daemon=True)
100
+ self._thread.start()
101
+
102
+ def stop(self) -> None:
103
+ self._stop_event.set()
104
+ with self._lock:
105
+ if self._relay is not None:
106
+ try:
107
+ self._relay.close()
108
+ except Exception:
109
+ pass
110
+ self._relay = None
111
+ self.connected = False
112
+ if self._thread is not None and self._thread.is_alive():
113
+ self._thread.join(timeout=1.0)
114
+ self._thread = None
115
+
116
+ def _run(self) -> None:
117
+ consecutive_errors = 0
118
+ while not self._is_stopped():
119
+ try:
120
+ r = relay.Relay(self.url, timeout=self.timeout, auth_signer=self.auth_signer)
121
+ r.connect()
122
+ with self._lock:
123
+ self._relay = r
124
+ self.connected = True
125
+ self.last_connected_at = int(time.time())
126
+ self.last_error = None
127
+ consecutive_errors = 0
128
+
129
+ sid = r.subscribe(self.filters, ready=False)
130
+ for ev in r.events(sid):
131
+ if self._is_stopped():
132
+ break
133
+ self.ingress_count += 1
134
+ self.last_event_at = int(time.time())
135
+ try:
136
+ self.on_event(ev, self.url)
137
+ except Exception as e:
138
+ # A handler bug (or one poisoned event) costs that
139
+ # event, not the subscription: tearing the socket
140
+ # down and reconnecting would replay the same
141
+ # event and fail the same way, forever.
142
+ self.handler_errors += 1
143
+ self.last_handler_error = '%s: %s' % (type(e).__name__, str(e)[:200])
144
+
145
+ except Exception as e:
146
+ consecutive_errors += 1
147
+ self.errors_count += 1
148
+ self.last_error = f'{type(e).__name__}: {e}'
149
+ with self._lock:
150
+ self.connected = False
151
+ if self._relay is not None:
152
+ try:
153
+ self._relay.close()
154
+ except Exception:
155
+ pass
156
+ self._relay = None
157
+ finally:
158
+ with self._lock:
159
+ self.connected = False
160
+ if self._relay is not None:
161
+ try:
162
+ self._relay.close()
163
+ except Exception:
164
+ pass
165
+ self._relay = None
166
+
167
+ if not self._is_stopped():
168
+ backoff = min(30.0, 1.0 * (2 ** min(consecutive_errors, 5)))
169
+ self._stop_event.wait(backoff)
170
+
171
+ def publish(self, ev: NostrEvent) -> tuple[bool, str]:
172
+ """Publish one event directly to this relay, returning (accepted, reason)."""
173
+ pub_client = relay.Relay(self.url, timeout=self.timeout, auth_signer=self.auth_signer)
174
+ try:
175
+ pub_client.connect()
176
+ accepted, reason = pub_client.publish(ev)
177
+ if accepted:
178
+ self.egress_count += 1
179
+ return accepted, reason
180
+ except Exception as e:
181
+ self.errors_count += 1
182
+ self.last_error = f'publish: {type(e).__name__}: {e}'
183
+ return False, str(e)
184
+ finally:
185
+ pub_client.close()
186
+
187
+ def stats(self) -> RelayWorkerStats:
188
+ return {
189
+ 'url': self.url,
190
+ 'connected': self.connected,
191
+ 'last_connected_at': self.last_connected_at,
192
+ 'ingress_count': self.ingress_count,
193
+ 'egress_count': self.egress_count,
194
+ 'errors_count': self.errors_count,
195
+ 'last_error': self.last_error,
196
+ 'handler_errors': self.handler_errors,
197
+ 'last_handler_error': self.last_handler_error,
198
+ 'last_event_at': self.last_event_at,
199
+ }
200
+
201
+
202
+ class RelayBridge(Compartment):
203
+ optional: bool = True
204
+ enabled_by: Optional[str] = 'bridge'
205
+ """The Universal Nostr Relay Bridge compartment."""
206
+
207
+ name: str = 'bridge'
208
+ uses: tuple[str, ...] = ('p2p',) # external ingress is re-broadcast on the P2P gossip when it is on
209
+ reads: tuple[str, ...] = ('bridge', 'relays')
210
+ config_cls = BridgeConfig
211
+ live: tuple[str, ...] = ('bridge',)
212
+ NEEDS = (
213
+ Need('store_backend', 'stores.events'),
214
+ Need('face', 'face'),
215
+ Need('relays', 'relays'),
216
+ Need('cfg', block=BridgeConfig),
217
+ Need('p2p', 'p2p'),
218
+ )
219
+ STORES = (
220
+ st.Store(
221
+ key='egress_queue',
222
+ description='events queued for broadcast to external relays',
223
+ kind=st.RING,
224
+ bound=1000,
225
+ ),
226
+ )
227
+ egress_queue: st.Ring[tuple[Any, ...]]
228
+
229
+ CHANNELS = (
230
+ ch_mod.Channel('ingress_stored', 'An event received from an external relay was verified and stored',
231
+ level=ch_mod.NOTE, say='bridge stored event {id} from {relay}',
232
+ tags=('bridge', 'ingress')),
233
+ ch_mod.Channel('ingress_refused', 'An event received from an external relay was refused',
234
+ level=ch_mod.TROUBLE, say='bridge refused event from {relay}: {reason}',
235
+ tags=('bridge', 'ingress', 'refused')),
236
+ ch_mod.Channel('egress_sent', 'An event was published to external relays',
237
+ level=ch_mod.NOTE, say='bridge published event {id} to {relay}',
238
+ tags=('bridge', 'egress')),
239
+ ch_mod.Channel('egress_failed', 'Failed to publish event to an external relay',
240
+ level=ch_mod.TROUBLE, say='bridge failed to publish to {relay}: {reason}',
241
+ tags=('bridge', 'egress', 'failed')),
242
+ )
243
+ METRICS = (
244
+ mx_mod.Metric(name='bridge_ingress_stored', description='Events received from external relays and stored.',
245
+ kind=mx_mod.COUNTER, counts='ingress_stored', tags=('bridge', 'ingress')),
246
+ mx_mod.Metric(name='bridge_ingress_refused', description='Events received from external relays and refused.',
247
+ kind=mx_mod.COUNTER, counts='ingress_refused', tags=('bridge', 'ingress')),
248
+ mx_mod.Metric(name='bridge_egress_sent', description='Events published to external relays.',
249
+ kind=mx_mod.COUNTER, counts='egress_sent', tags=('bridge', 'egress')),
250
+ mx_mod.Metric(name='bridge_egress_failed', description='Events that failed publication to external relays.',
251
+ kind=mx_mod.COUNTER, counts='egress_failed', tags=('bridge', 'egress')),
252
+ )
253
+ COMMANDS = (
254
+ Command(
255
+ key='show',
256
+ name='Bridge Status',
257
+ description='display bridge status and connected relays',
258
+ tags=('bridge', 'status'),
259
+ permission='bridge/show',
260
+ run='state',
261
+ reply='dict',
262
+ ),
263
+ Command(
264
+ key='publish',
265
+ name='Publish Event',
266
+ description='publish a nostr event to external relays',
267
+ tags=('bridge', 'publish'),
268
+ parameters=(
269
+ Parameter(key='event', name='event', description='nostr event object', type=dict, default=dict, required=True),
270
+ Parameter(key='relays', name='relays', description='target relays list', type=list, default=None, required=False),
271
+ ),
272
+ permission='bridge/publish',
273
+ run='publish_cmd',
274
+ reply='dict',
275
+ ),
276
+ Command(
277
+ key='sync',
278
+ name='Sync Query',
279
+ description='query external relays for matching events',
280
+ tags=('bridge', 'sync'),
281
+ parameters=(
282
+ Parameter(key='filter', name='filter', description='subscription filter', type=object, default=None, required=False),
283
+ Parameter(key='relay', name='relay', description='target relay URL', type=str, default=None, required=False),
284
+ ),
285
+ permission='bridge/sync',
286
+ run='sync_cmd',
287
+ reply='dict',
288
+ ),
289
+ Command(
290
+ key='reconcile',
291
+ name='Reconcile With Relay',
292
+ description='reconcile events with a target relay',
293
+ tags=('bridge', 'reconcile'),
294
+ parameters=(
295
+ Parameter(key='relay', name='relay', description='target relay URL', type=str, default=None, required=False),
296
+ Parameter(key='filter', name='filter', description='subscription filter', type=object, default=None, required=False),
297
+ Parameter(key='since', name='since', description='start timestamp', type=int, default=None, required=False),
298
+ Parameter(key='until', name='until', description='end timestamp', type=int, default=None, required=False),
299
+ Parameter(key='limit', name='limit', description='max events', type=int, default=500, required=False),
300
+ ),
301
+ permission='bridge/reconcile',
302
+ run='reconcile_cmd',
303
+ reply='dict',
304
+ ),
305
+ Command(
306
+ key='add_relay',
307
+ name='Add Relay',
308
+ description='add an external relay URL to bridge',
309
+ tags=('bridge', 'relay'),
310
+ parameters=(
311
+ Parameter(key='url', name='url', description='relay ws:// or wss:// URL', type=str, default='', required=True),
312
+ ),
313
+ permission='bridge/add_relay',
314
+ run='add_relay_cmd',
315
+ reply='dict',
316
+ ),
317
+ Command(
318
+ key='remove_relay',
319
+ name='Remove Relay',
320
+ description='remove an external relay URL from bridge',
321
+ tags=('bridge', 'relay'),
322
+ parameters=(
323
+ Parameter(key='url', name='url', description='relay URL to remove', type=str, default='', required=True),
324
+ ),
325
+ permission='bridge/remove_relay',
326
+ run='remove_relay_cmd',
327
+ reply='dict',
328
+ ),
329
+ Command(
330
+ key='cursor',
331
+ name='Bridge Cursors',
332
+ description='query persistent relay checkpoint cursors',
333
+ tags=('bridge', 'cursor'),
334
+ permission='bridge/cursor',
335
+ run='cursor_cmd',
336
+ reply='list',
337
+ ),
338
+ Command(
339
+ key='cursors',
340
+ name='Bridge Cursors Alias',
341
+ description='query persistent relay checkpoint cursors (alias)',
342
+ tags=('bridge', 'cursor'),
343
+ permission='bridge/cursor',
344
+ run='cursor_cmd',
345
+ reply='list',
346
+ ),
347
+ )
348
+
349
+ TROUBLE_WINDOW: int = 1800
350
+
351
+ def __init__(
352
+ self,
353
+ store_backend: Any,
354
+ face: Optional[Any] = None,
355
+ relays: Optional[Sequence[str]] = None,
356
+ cfg: Optional[Union[BridgeConfig, Mapping[str, Any]]] = None,
357
+ clock: Optional[Callable[[], int]] = None,
358
+ node: Optional[Any] = None,
359
+ p2p: Optional[Any] = None,
360
+ ) -> None:
361
+ super().__init__(node, clock)
362
+ self.store: Any = store_backend
363
+ self.face: Optional[Any] = face
364
+ # The P2P compartment when the node built it (declared in `uses`,
365
+ # resolved once by from_context); None on a node without p2p.
366
+ self.p2p: Optional[Any] = p2p
367
+
368
+ # Tests hand a dict; the node hands the typed block. The block is the shape.
369
+ c = cfg if isinstance(cfg, BridgeConfig) else BridgeConfig.from_dict(cfg or {})
370
+ self.enabled: bool = c.enabled
371
+ self.relays: list[str] = [str(u) for u in c.relays] if c.relays is not None else list(relays or []) # not a store: the configured relays
372
+
373
+ self.ingress_enabled: bool = c.ingress.enabled
374
+ self.ingress_filters: list[Mapping[str, Any]] = list(c.ingress.filters) # not a store: ingress filters from config
375
+ self.rate_limit_per_min: int = c.ingress.rate_limit
376
+ self.future_max_seconds: int = c.ingress.max_future_seconds
377
+ self.max_event_bytes: int = c.ingress.max_bytes
378
+
379
+ self.egress_enabled: bool = c.egress.enabled
380
+ self.egress_kinds: set[int] = set(c.egress.kinds) # not a store: egress kinds from config
381
+ self.auto_egress: bool = c.egress.auto
382
+
383
+ self.workers: dict[str, RelayBridgeWorker] = {} # not a store: active worker threads per relay
384
+ self.rate_limiters: dict[str, RateLimiter] = {} # not a store: rate limiters per relay
385
+ self._global_rate_limiter: RateLimiter = RateLimiter(self.rate_limit_per_min * max(1, len(self.relays)))
386
+ self._egress_queue = self.egress_queue
387
+ self._lock: threading.Lock = threading.Lock()
388
+
389
+ # Operational metrics
390
+ self.ingress_stored: int = 0
391
+ self.ingress_duplicates: int = 0
392
+ self.p2p_forward_failures: int = 0
393
+ self.ingress_refused: int = 0
394
+ self.egress_sent: int = 0
395
+ self.egress_failed: int = 0
396
+ self.last_ingress_at: Optional[int] = None
397
+ self.last_egress_at: Optional[int] = None
398
+
399
+ # Wire auto-egress from local face if available
400
+ if self.face is not None and hasattr(self.face, 'on_event_handlers'):
401
+ self.face.on_event_handlers.append(self._on_local_face_event)
402
+
403
+ # Initialize workers
404
+ for r_url in self.relays:
405
+ self._add_worker(r_url)
406
+ # The strfry relay bridge is a plugin now (fleetmesh-strfry,
407
+ # `fleetmesh_strfry.plugin:StrfryBridgePlugin`), not a part of this one.
408
+
409
+ def _add_worker(self, url: str) -> RelayBridgeWorker:
410
+ if url in self.workers:
411
+ return self.workers[url]
412
+ # No NIP-42 signer: the bridge speaks to the external relays
413
+ # unauthenticated. The node has no `auth_signer`, so the previous
414
+ # `getattr(self.node, 'auth_signer')` was always None; a bridge that
415
+ # authenticates is a feature, declared here when it exists.
416
+ worker = RelayBridgeWorker(
417
+ url=url,
418
+ filters=self.ingress_filters,
419
+ on_event=self.handle_ingress_event,
420
+ stop_event=self._stop_event,
421
+ )
422
+ self.workers[url] = worker
423
+ self.rate_limiters[url] = RateLimiter(self.rate_limit_per_min)
424
+ if self.enabled and self.ingress_enabled:
425
+ worker.start()
426
+ return worker
427
+
428
+ def _remove_worker(self, url: str) -> None:
429
+ worker = self.workers.pop(url, None)
430
+ self.rate_limiters.pop(url, None)
431
+ if worker is not None:
432
+ worker.stop()
433
+
434
+ def _on_local_face_event(self, ev: NostrEvent) -> None:
435
+ """Triggered whenever the local node's relay face accepts an event."""
436
+ if not self.enabled or not self.egress_enabled or not self.auto_egress:
437
+ return
438
+ if ev.get('kind') in self.egress_kinds:
439
+ self.enqueue_egress(ev)
440
+
441
+ def handle_ingress_event(self, ev: Any, relay_url: str) -> str:
442
+ """Ingest, validate, and store an event received from an external relay.
443
+
444
+ Returns one of: stored, duplicate, rate_limited, invalid, future_skew, expired.
445
+ """
446
+ now = self.clock()
447
+ eid = ev.get('id', '') if isinstance(ev, dict) else ''
448
+
449
+ # Rate limiting: relay and global
450
+ relay_limiter = self.rate_limiters.setdefault(relay_url, RateLimiter(self.rate_limit_per_min))
451
+ if not relay_limiter.allow(now) or not self._global_rate_limiter.allow(now):
452
+ self.ingress_refused += 1
453
+ self.say('ingress_refused', relay=relay_url, reason='rate limit exceeded')
454
+ self.refusals.refuse('ingress', 'rate limit exceeded', subject=eid[:16] or None,
455
+ detail={'relay': relay_url, 'rate_limit': self.rate_limit_per_min})
456
+ return 'rate_limited'
457
+
458
+ if not isinstance(ev, dict) or not isinstance(eid, str) or not eid:
459
+ self.ingress_refused += 1
460
+ self.say('ingress_refused', relay=relay_url, reason='malformed event')
461
+ self.refusals.refuse('ingress', 'malformed event object', subject=None,
462
+ detail={'relay': relay_url})
463
+ return 'invalid'
464
+
465
+ # Check payload size
466
+ raw_size = len(json.dumps(ev, separators=(',', ':')))
467
+ if raw_size > self.max_event_bytes:
468
+ self.ingress_refused += 1
469
+ self.say('ingress_refused', relay=relay_url, reason='event exceeds max size')
470
+ self.refusals.refuse('ingress', 'event exceeds max size', subject=eid[:16],
471
+ detail={'relay': relay_url, 'size': raw_size, 'limit': self.max_event_bytes})
472
+ return 'invalid'
473
+
474
+ # Check cryptographic signature & ID
475
+ if not giftwrap.verify_event(ev):
476
+ self.ingress_refused += 1
477
+ self.say('ingress_refused', relay=relay_url, reason='signature verification failed')
478
+ self.refusals.refuse('ingress', 'signature or id does not verify', subject=eid[:16],
479
+ detail={'relay': relay_url, 'pubkey': ev.get('pubkey')})
480
+ return 'invalid'
481
+
482
+ # Timestamp sanity check (clock skew)
483
+ created_at = ev.get('created_at')
484
+ if isinstance(created_at, int) and created_at > now + self.future_max_seconds:
485
+ self.ingress_refused += 1
486
+ self.say('ingress_refused', relay=relay_url, reason='future timestamp')
487
+ self.refusals.refuse('ingress', 'created_at too far in future', subject=eid[:16],
488
+ detail={'relay': relay_url, 'skew': created_at - now, 'limit': self.future_max_seconds})
489
+ return 'future_skew'
490
+
491
+ # Expiration check (NIP-40)
492
+ exp = store.expiration(ev)
493
+ if exp is not None and exp <= now:
494
+ return 'expired'
495
+
496
+ # Ephemeral check
497
+ if store.is_ephemeral(ev.get('kind', 0)):
498
+ if self.face is not None and hasattr(self.face, '_fanout'):
499
+ self.face._fanout(ev)
500
+ return 'ephemeral'
501
+
502
+ # Deduplication check
503
+ if hasattr(self.store, 'has_event') and self.store.has_event(eid):
504
+ self.ingress_duplicates += 1
505
+ return 'duplicate'
506
+
507
+ # Insert into store
508
+ result = self.store.insert_event(ev, now)
509
+ if result == 'stored':
510
+ self.ingress_stored += 1
511
+ self.last_ingress_at = now
512
+ self.say('ingress_stored', id=eid[:16], relay=relay_url)
513
+ if hasattr(self.store, 'note_origin'):
514
+ self.store.note_origin(eid, getattr(self.store, 'LOCAL', 'local'))
515
+ if self.face is not None and hasattr(self.face, '_fanout'):
516
+ self.face._fanout(ev)
517
+ # P2P cross-forwarding: external ingress goes out on the local
518
+ # gossip too. A failure there is the p2p compartment's to report;
519
+ # here it is counted so the bridge's own status says it happened.
520
+ if self.p2p is not None:
521
+ try:
522
+ self.p2p.broadcast(ev)
523
+ except Exception as e:
524
+ self.p2p_forward_failures += 1
525
+ self.refusals.refuse('p2p forward', 'raised', subject=ev.get('id'), detail=compartment_mod.why_detail(e))
526
+ # Persistent checkpoint tracking
527
+ if hasattr(self.store, 'set_bridge_cursor') and relay_url:
528
+ try:
529
+ self.store.set_bridge_cursor(relay_url, last_event_at=ev.get('created_at', now), count_increment=1)
530
+ except Exception:
531
+ pass
532
+ elif result == 'duplicate':
533
+ self.ingress_duplicates += 1
534
+
535
+ return result
536
+
537
+ def publish(
538
+ self,
539
+ ev: NostrEvent,
540
+ target_relays: Optional[Sequence[str]] = None,
541
+ ) -> dict[str, tuple[bool, str]]:
542
+ """Publish an event to external relays.
543
+
544
+ Returns {relay_url: (accepted, reason)}.
545
+ """
546
+ targets = list(target_relays) if target_relays else list(self.workers.keys())
547
+ out: dict[str, tuple[bool, str]] = {}
548
+ for r_url in targets:
549
+ worker = self.workers.get(r_url)
550
+ if worker is None:
551
+ worker = self._add_worker(r_url)
552
+ accepted, reason = worker.publish(ev)
553
+ out[r_url] = (accepted, reason)
554
+ if accepted:
555
+ self.egress_sent += 1
556
+ self.say('egress_sent', id=ev.get('id', '')[:16], relay=r_url)
557
+ else:
558
+ self.egress_failed += 1
559
+ self.say('egress_failed', relay=r_url, reason=reason[:32])
560
+ self.refusals.refuse('egress', f'relay rejected: {reason[:32]}',
561
+ subject=ev.get('id', '')[:16],
562
+ detail={'relay': r_url, 'reason': reason})
563
+ if out:
564
+ self.last_egress_at = self.clock()
565
+ return out
566
+
567
+ def enqueue_egress(self, ev: NostrEvent, target_relays: Optional[Sequence[str]] = None) -> None:
568
+ """Enqueue an event to be published out to external relays asynchronously."""
569
+ with self._lock:
570
+ targets = list(target_relays) if target_relays else None
571
+ self.egress_queue.append((ev, targets))
572
+
573
+ def flush_egress(self) -> int:
574
+ """Process and send all queued egress events. Returns count of processed items."""
575
+ items: list[tuple[NostrEvent, Optional[list[str]]]] = []
576
+ with self._lock:
577
+ items = list(self.egress_queue)
578
+ self.egress_queue.clear()
579
+ for ev, targets in items:
580
+ self.publish(ev, targets)
581
+ return len(items)
582
+
583
+ def sync_query(
584
+ self,
585
+ filters: Sequence[Mapping[str, Any]],
586
+ target_relays: Optional[Sequence[str]] = None,
587
+ timeout: float = 15.0,
588
+ ) -> Mapping[str, Any]:
589
+ """Perform an ad-hoc query against external relays and ingest matching events."""
590
+ targets = list(target_relays) if target_relays else list(self.workers.keys())
591
+ results = relay.query_all(targets, filters, timeout=timeout)
592
+ ingested = 0
593
+ duplicates = 0
594
+ for ev in results:
595
+ outcome = self.handle_ingress_event(ev, 'query_sync')
596
+ if outcome == 'stored':
597
+ ingested += 1
598
+ elif outcome == 'duplicate':
599
+ duplicates += 1
600
+ return {
601
+ 'queried_relays': targets,
602
+ 'events_found': len(results),
603
+ 'ingested': ingested,
604
+ 'duplicates': duplicates,
605
+ }
606
+
607
+ def reconcile_with_relay(
608
+ self,
609
+ relay_url: str,
610
+ filter_spec: Optional[Mapping[str, Any]] = None,
611
+ since: Optional[int] = None,
612
+ until: Optional[int] = None,
613
+ limit: int = 500,
614
+ ) -> Mapping[str, Any]:
615
+ """Perform a range-bounded reconciliation against an external relay to backfill historical events."""
616
+ base_filter = dict(filter_spec or {})
617
+ if 'kinds' not in base_filter:
618
+ base_filter['kinds'] = list(DEFAULT_INGRESS_KINDS)
619
+ if since is None and hasattr(self.store, 'get_bridge_cursor'):
620
+ try:
621
+ cur = self.store.get_bridge_cursor(relay_url)
622
+ if cur and cur.get('last_reconciled_at'):
623
+ since = cur['last_reconciled_at']
624
+ except Exception:
625
+ pass
626
+ if since is not None:
627
+ base_filter['since'] = since
628
+ if until is not None:
629
+ base_filter['until'] = until
630
+ base_filter['limit'] = limit
631
+
632
+ results = relay.query_all([relay_url], [base_filter], timeout=15.0)
633
+
634
+ examined = len(results)
635
+ stored_count = 0
636
+ duplicate_count = 0
637
+ for ev in results:
638
+ outcome = self.handle_ingress_event(ev, relay_url)
639
+ if outcome == 'stored':
640
+ stored_count += 1
641
+ elif outcome == 'duplicate':
642
+ duplicate_count += 1
643
+
644
+ target_until = until if until is not None else self.clock()
645
+ if hasattr(self.store, 'set_bridge_cursor'):
646
+ try:
647
+ self.store.set_bridge_cursor(relay_url, last_reconciled_at=target_until, count_increment=0)
648
+ except Exception:
649
+ pass
650
+
651
+ return {
652
+ 'relay': relay_url,
653
+ 'filter': base_filter,
654
+ 'examined': examined,
655
+ 'stored': stored_count,
656
+ 'duplicates': duplicate_count,
657
+ 'cursor': target_until,
658
+ }
659
+
660
+ # ---- Compartment lifecycle & control -------------------------------------------
661
+ def start(self) -> None:
662
+ super().start()
663
+ if not self.enabled:
664
+ return
665
+ for worker in self.workers.values():
666
+ if self.ingress_enabled:
667
+ worker.start()
668
+
669
+ def stop(self) -> list[tuple[str, str]]:
670
+ super().stop()
671
+ for worker in list(self.workers.values()):
672
+ worker.stop()
673
+ self.flush_egress()
674
+ return [('stopped bridge', f'{len(self.workers)} workers stopped')]
675
+
676
+ def tick(self, now: int) -> list[tuple[Any, ...]]:
677
+ did: list[tuple[Any, ...]] = []
678
+ if not self.enabled:
679
+ return did
680
+
681
+ # Health check on workers
682
+ connected_count = sum(1 for w in self.workers.values() if w.connected)
683
+ for url, worker in list(self.workers.items()):
684
+ if self.ingress_enabled and not worker.connected:
685
+ # Ensure thread is alive or restart
686
+ worker.start()
687
+
688
+ # Flush any queued egress items
689
+ sent_count = self.flush_egress()
690
+ if sent_count > 0:
691
+ did.append(('bridge', f'flushed {sent_count} egress events'))
692
+
693
+ did.append(('bridge', f'{connected_count}/{len(self.workers)} relays connected',
694
+ f'ingress={self.ingress_stored} egress={self.egress_sent}'))
695
+ return did
696
+
697
+ def state(self) -> BridgeStateReport:
698
+ with self._lock:
699
+ pending_egress = len(self.egress_queue)
700
+ res: BridgeStateReport = {
701
+ 'enabled': self.enabled,
702
+ 'relays_configured': len(self.relays),
703
+ 'relays_connected': sum(1 for w in self.workers.values() if w.connected),
704
+ 'ingress': {
705
+ 'enabled': self.ingress_enabled,
706
+ 'stored': self.ingress_stored,
707
+ 'duplicates': self.ingress_duplicates,
708
+ 'p2p_forward_failures': self.p2p_forward_failures,
709
+ 'refused': self.ingress_refused,
710
+ 'last_event_at': self.last_ingress_at,
711
+ },
712
+ 'egress': {
713
+ 'enabled': self.egress_enabled,
714
+ 'sent': self.egress_sent,
715
+ 'failed': self.egress_failed,
716
+ 'pending': pending_egress,
717
+ 'last_event_at': self.last_egress_at,
718
+ },
719
+ 'relays': {
720
+ url: w.stats() for url, w in self.workers.items()
721
+ },
722
+ }
723
+ return res
724
+
725
+ def trouble(self) -> list[str]:
726
+ out: list[str] = []
727
+ if not self.enabled:
728
+ return out
729
+
730
+ now = self.clock()
731
+ if self.relays and not any(w.connected for w in self.workers.values()):
732
+ out.append(f'all {len(self.relays)} bridge relays are disconnected')
733
+
734
+ recent_refusals = self.refusals.within(self.TROUBLE_WINDOW, now, kind='ingress')
735
+ if len(recent_refusals) > 20:
736
+ out.append(f'{len(recent_refusals)} ingress events refused in the last 30 minutes')
737
+
738
+ return out
739
+
740
+ def publish_cmd(self, args: Mapping[str, Any]) -> Any:
741
+ ev = args.get('event')
742
+ if not isinstance(ev, dict) or not isinstance(ev.get('id'), str):
743
+ raise BridgeError('event must be a valid nostr event object')
744
+ relays = args.get('relays')
745
+ return self.publish(cast(NostrEvent, ev), target_relays=relays)
746
+
747
+ def sync_cmd(self, args: Mapping[str, Any]) -> Any:
748
+ filt = args.get('filter')
749
+ filters = [filt] if isinstance(filt, dict) else (filt if isinstance(filt, list) else self.ingress_filters)
750
+ target_relay = args.get('relay')
751
+ targets = [str(target_relay)] if target_relay else None
752
+ return self.sync_query(filters, target_relays=targets)
753
+
754
+ def reconcile_cmd(self, args: Mapping[str, Any]) -> Any:
755
+ url = args.get('relay') or (self.relays[0] if self.relays else None)
756
+ if not url:
757
+ raise BridgeError('no target relay specified for reconcile')
758
+ filter_spec = args.get('filter')
759
+ since = args.get('since')
760
+ until = args.get('until')
761
+ limit = args.get('limit') or 500
762
+ return self.reconcile_with_relay(str(url), filter_spec=filter_spec, since=since, until=until, limit=int(limit))
763
+
764
+ def add_relay_cmd(self, args: Mapping[str, Any]) -> Mapping[str, Any]:
765
+ url = args.get('url')
766
+ if not isinstance(url, str) or not url.startswith(('ws://', 'wss://')):
767
+ raise BridgeError('url must be a ws:// or wss:// URL')
768
+ if url not in self.relays:
769
+ self.relays.append(url)
770
+ worker = self._add_worker(url)
771
+ return {'added': url, 'connected': worker.connected}
772
+
773
+ def remove_relay_cmd(self, args: Mapping[str, Any]) -> Mapping[str, Any]:
774
+ url = args.get('url')
775
+ if not isinstance(url, str):
776
+ raise BridgeError('url must be a string')
777
+ if url in self.relays:
778
+ self.relays.remove(url)
779
+ self._remove_worker(url)
780
+ return {'removed': url}
781
+
782
+ def cursor_cmd(self) -> Any:
783
+ if hasattr(self.store, 'all_bridge_cursors'):
784
+ return self.store.all_bridge_cursors()
785
+ return []