willows-grove 0.9.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 (54) hide show
  1. bridge/__init__.py +1 -0
  2. bridge/__main__.py +112 -0
  3. bridge/app.py +311 -0
  4. bridge/matrix.py +109 -0
  5. bridge/registration.yaml +24 -0
  6. bridge/store.py +69 -0
  7. grove/__init__.py +1 -0
  8. grove/envelope_reader.py +235 -0
  9. grove/errors.py +63 -0
  10. grove/fleet_presence.py +107 -0
  11. grove/journal_reader.py +311 -0
  12. grove/journal_writer.py +211 -0
  13. grove/kart_reader.py +303 -0
  14. grove/mcp_auth.py +454 -0
  15. grove/mcp_local.py +1284 -0
  16. grove/nestor_client.py +376 -0
  17. grove/persona_roster.py +317 -0
  18. grove/resident_watcher.py +624 -0
  19. grove/seed_html.py +545 -0
  20. grove/seed_reader.py +254 -0
  21. grove_db.py +831 -0
  22. grove_html.py +302 -0
  23. grove_reader.py +1361 -0
  24. grove_serve.py +538 -0
  25. panes/__init__.py +5 -0
  26. panes/chat_admin.py +38 -0
  27. schema.sql +175 -0
  28. u2u/__init__.py +0 -0
  29. u2u/consent.py +128 -0
  30. u2u/contacts.py +149 -0
  31. u2u/dispatcher.py +37 -0
  32. u2u/identity.py +51 -0
  33. u2u/listener.py +145 -0
  34. u2u/packets.py +148 -0
  35. u2u/sender.py +63 -0
  36. web/boot/layout-memory-boot.js +107 -0
  37. web/boot/refusal-summon-boot.js +185 -0
  38. web/components/grove-card.js +265 -0
  39. web/components/grove-cast-chip.js +176 -0
  40. web/components/grove-chat.js +616 -0
  41. web/components/grove-dispatch-rail.js +425 -0
  42. web/components/grove-envelope-panel.js +269 -0
  43. web/components/grove-lens-switch.js +236 -0
  44. web/components/grove-persona-registry.js +290 -0
  45. web/components/grove-refusal-chip.js +292 -0
  46. web/fixtures/envelopes.json +66 -0
  47. web/fixtures/persona-registry.json +33 -0
  48. web/fixtures/refusals.json +39 -0
  49. web/harness.html +170 -0
  50. web/lib/layout-memory.js +227 -0
  51. willows_grove-0.9.0.dist-info/METADATA +131 -0
  52. willows_grove-0.9.0.dist-info/RECORD +54 -0
  53. willows_grove-0.9.0.dist-info/WHEEL +4 -0
  54. willows_grove-0.9.0.dist-info/entry_points.txt +2 -0
bridge/__init__.py ADDED
@@ -0,0 +1 @@
1
+ # bridge — Grove ↔ Matrix Application Service bridge
bridge/__main__.py ADDED
@@ -0,0 +1,112 @@
1
+ """
2
+ Grove ↔ Matrix bridge.
3
+
4
+ Tokens are never passed as CLI args — they live in an env file or environment
5
+ variables so they stay out of shell history and process listings.
6
+
7
+ Usage:
8
+ # Generate tokens once:
9
+ python3 -c "import secrets; print(secrets.token_hex(32))" # as_token
10
+ python3 -c "import secrets; print(secrets.token_hex(32))" # hs_token
11
+
12
+ # Write to ~/.willow/bridge/tokens.env (chmod 600):
13
+ GROVE_AS_TOKEN=<as_token>
14
+ GROVE_HS_TOKEN=<hs_token>
15
+
16
+ # Run:
17
+ python3 -m bridge \\
18
+ --homeserver https://matrix.example.com \\
19
+ --hs-name example.com \\
20
+ --env-file ~/.willow/bridge/tokens.env
21
+
22
+ # Or via environment directly (e.g. systemd EnvironmentFile):
23
+ GROVE_AS_TOKEN=... GROVE_HS_TOKEN=... python3 -m bridge ...
24
+
25
+ Then copy bridge/registration.yaml to your Synapse config dir and add it to
26
+ homeserver.yaml under `app_service_config_files`.
27
+ """
28
+
29
+ import argparse
30
+ import asyncio
31
+ import logging
32
+ import os
33
+ from pathlib import Path
34
+
35
+ from .app import GroveMatrixBridge
36
+
37
+ logging.basicConfig(
38
+ level=logging.INFO,
39
+ format="%(asctime)s %(name)-22s %(levelname)s %(message)s",
40
+ )
41
+
42
+
43
+ def _load_env_file(path: Path) -> None:
44
+ """Parse a simple KEY=VALUE env file into os.environ. Ignores comments and blanks."""
45
+ for line in path.read_text().splitlines():
46
+ line = line.strip()
47
+ if not line or line.startswith("#"):
48
+ continue
49
+ if "=" not in line:
50
+ continue
51
+ key, _, value = line.partition("=")
52
+ os.environ.setdefault(key.strip(), value.strip())
53
+
54
+
55
+ def _require_env(name: str) -> str:
56
+ value = os.environ.get(name, "").strip()
57
+ if not value:
58
+ raise SystemExit(
59
+ f"Error: {name} is not set.\n"
60
+ f"Add it to your --env-file or set it as an environment variable."
61
+ )
62
+ return value
63
+
64
+
65
+ def main() -> None:
66
+ p = argparse.ArgumentParser(
67
+ description="Grove ↔ Matrix bridge",
68
+ formatter_class=argparse.RawDescriptionHelpFormatter,
69
+ )
70
+ p.add_argument("--homeserver", required=True, help="Matrix homeserver URL")
71
+ p.add_argument("--hs-name", required=True, help="Homeserver name (e.g. example.com)")
72
+ p.add_argument("--env-file", default=None, help="Path to KEY=VALUE env file for tokens")
73
+ p.add_argument("--grove-port", type=int, default=8551, help="u2u listen port (default 8551)")
74
+ p.add_argument("--as-port", type=int, default=8560, help="AS HTTP server port (default 8560)")
75
+ p.add_argument("--data-dir", default="~/.willow/bridge", help="State directory")
76
+ args = p.parse_args()
77
+
78
+ if args.env_file:
79
+ env_path = Path(args.env_file).expanduser()
80
+ if not env_path.exists():
81
+ raise SystemExit(f"Error: env file not found: {env_path}")
82
+ _load_env_file(env_path)
83
+
84
+ as_token = _require_env("GROVE_AS_TOKEN")
85
+ hs_token = _require_env("GROVE_HS_TOKEN")
86
+
87
+ data = Path(args.data_dir).expanduser()
88
+ data.mkdir(parents=True, exist_ok=True)
89
+
90
+ bridge = GroveMatrixBridge(
91
+ homeserver = args.homeserver,
92
+ hs_name = args.hs_name,
93
+ as_token = as_token,
94
+ hs_token = hs_token,
95
+ grove_port = args.grove_port,
96
+ as_port = args.as_port,
97
+ identity_path = data / "identity.json",
98
+ store_path = data / "bridge.db",
99
+ )
100
+
101
+ print("Grove ↔ Matrix bridge starting")
102
+ print(f" homeserver → {args.homeserver}")
103
+ print(f" AS server → http://0.0.0.0:{args.as_port}")
104
+ print(f" u2u port → {args.grove_port}")
105
+ print(f" data dir → {data}")
106
+ print()
107
+
108
+ asyncio.run(bridge.run())
109
+
110
+
111
+ if __name__ == "__main__":
112
+ main()
bridge/app.py ADDED
@@ -0,0 +1,311 @@
1
+ # bridge/app.py — Grove ↔ Matrix bridge coordinator
2
+ """
3
+ Flow summary
4
+ ============
5
+
6
+ Matrix user → Grove user
7
+ 1. Matrix user invites @grove_<hex_addr>:<hs> to a DM room
8
+ 2. ASServer receives m.room.member/invite
9
+ 3. Bridge sends u2u KNOCK to Grove addr
10
+ 4. Grove user sees KNOCK in their TUI, approves
11
+ 5. Grove user sends a NOTE back to bridge (implicit approval signal)
12
+ 6. Bridge marks mapping active, delivers NOTE as Matrix message
13
+ 7. Subsequent Matrix messages → u2u NOTEs, and vice versa
14
+
15
+ Grove user → Matrix user
16
+ 1. Grove user KNOCKs the bridge at bridge_addr
17
+ 2. Bridge receives KNOCK, creates Matrix DM room, invites the target Matrix user
18
+ (target must be pre-configured or resolved from a directory — out of scope for now)
19
+ Currently: Grove-initiated flow requires the Matrix user to initiate first.
20
+ """
21
+
22
+ import asyncio
23
+ import logging
24
+ import socket
25
+ from pathlib import Path
26
+
27
+ from aiohttp import web
28
+
29
+ from u2u import dispatcher
30
+ from u2u.consent import ConsentGate
31
+ from u2u.contacts import ContactStore
32
+ from u2u.identity import Identity
33
+ from u2u.listener import U2UListener
34
+ from u2u.packets import PacketType
35
+ from u2u.sender import send_packet
36
+
37
+ from .matrix import ASServer, MatrixClient
38
+ from .store import BridgeStore
39
+
40
+ log = logging.getLogger("bridge.app")
41
+
42
+
43
+ def _addr_to_localpart(addr: str) -> str:
44
+ """grove addr → hex string safe for Matrix localpart."""
45
+ return addr.encode().hex()
46
+
47
+
48
+ def _localpart_to_addr(localpart: str) -> str:
49
+ """Reverse of _addr_to_localpart. Raises ValueError on bad input."""
50
+ return bytes.fromhex(localpart).decode()
51
+
52
+
53
+ def _puppet_id(grove_addr: str, hs_name: str) -> str:
54
+ return f"@grove_{_addr_to_localpart(grove_addr)}:{hs_name}"
55
+
56
+
57
+ def _resolve_local_ip() -> str:
58
+ try:
59
+ with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
60
+ s.connect(("8.8.8.8", 80))
61
+ return s.getsockname()[0]
62
+ except OSError:
63
+ return "localhost"
64
+
65
+
66
+ class GroveMatrixBridge:
67
+ def __init__(
68
+ self,
69
+ homeserver: str,
70
+ hs_name: str,
71
+ as_token: str,
72
+ hs_token: str,
73
+ grove_port: int,
74
+ as_port: int,
75
+ identity_path: Path,
76
+ store_path: Path,
77
+ ):
78
+ self._hs_name = hs_name
79
+ self._grove_port = grove_port
80
+ self._as_port = as_port
81
+
82
+ self.identity = Identity.load_or_generate(identity_path)
83
+ self.store = BridgeStore(store_path)
84
+
85
+ local_ip = _resolve_local_ip()
86
+ self._bridge_addr = f"grove_bridge@{local_ip}:{grove_port}"
87
+
88
+ contacts_path = identity_path.parent / "grove_bridge_contacts.json"
89
+ self.contacts = ContactStore(contacts_path)
90
+
91
+ self._bot_id = f"@grove_bridge:{hs_name}"
92
+ self.matrix = MatrixClient(homeserver, as_token, self._bot_id)
93
+ self._as_srv = ASServer(hs_token, self._on_matrix_event)
94
+
95
+ log.info("bridge addr: %s", self._bridge_addr)
96
+
97
+ async def run(self) -> None:
98
+ await self.matrix.start()
99
+ try:
100
+ await asyncio.gather(
101
+ self._run_grove_listener(),
102
+ self._run_as_server(),
103
+ )
104
+ finally:
105
+ await self.matrix.close()
106
+
107
+ # ── Grove listener ───────────────────────────────────────────────
108
+
109
+ async def _run_grove_listener(self) -> None:
110
+ gate = ConsentGate(self.contacts)
111
+
112
+ def _on_note(packet: dict) -> None:
113
+ h = packet["header"]
114
+ pl = packet.get("payload", {})
115
+ from_addr = h.get("from", "")
116
+ body = pl.get("body", "")
117
+ if h.get("_denied"):
118
+ log.debug("denied NOTE from %s", from_addr)
119
+ return
120
+ asyncio.get_running_loop().create_task(
121
+ self._grove_note(from_addr, body)
122
+ )
123
+
124
+ def _on_knock(packet: dict) -> None:
125
+ h = packet["header"]
126
+ pl = packet.get("payload", {})
127
+ from_addr = h.get("from", "")
128
+ pubkey = pl.get("public_key", "")
129
+ asyncio.get_running_loop().create_task(
130
+ self._grove_knock(from_addr, pubkey)
131
+ )
132
+
133
+ dispatcher.register(PacketType.NOTE, _on_note)
134
+ dispatcher.register(PacketType.KNOCK, _on_knock)
135
+
136
+ listener = U2UListener(
137
+ host="0.0.0.0", port=self._grove_port,
138
+ identity=self.identity, consent=gate,
139
+ )
140
+ async with listener.serve():
141
+ await asyncio.Event().wait()
142
+
143
+ # See INVARIANTS.md §5 — this is the re-KNOCK admission the section
144
+ # names: the caller has already verified the packet signature, but
145
+ # admission itself must never silently rotate a trusted key.
146
+ def _admit_contact(self, from_addr: str, pubkey: str) -> bool:
147
+ """Admit `from_addr` with `pubkey`, never silently rotating a key.
148
+
149
+ A first admission creates the contact. A repeat presentation of the
150
+ SAME key is a no-op. A DIFFERENT key is refused and logged: rotating a
151
+ peer's trusted key is an operator decision, and doing it from an
152
+ inbound packet is exactly how a re-KNOCK became a full trust reset.
153
+ """
154
+ if not pubkey:
155
+ return False
156
+ existing = self.contacts.get(from_addr)
157
+ if existing is None:
158
+ self.contacts.add(from_addr, pubkey)
159
+ log.info("admitted Grove contact %s", from_addr)
160
+ return True
161
+ if existing.public_key_hex == pubkey:
162
+ return True
163
+ log.warning(
164
+ "REFUSED key rotation for %s — inbound key %s… does not match the "
165
+ "stored key; existing key, blocked state and consent flags kept",
166
+ from_addr, pubkey[:16],
167
+ )
168
+ return False
169
+
170
+ async def _grove_note(self, from_addr: str, body: str) -> None:
171
+ """Grove NOTE → Matrix message. First NOTE from a pending addr = approval."""
172
+ mapping = self.store.get_by_grove_addr(from_addr)
173
+ if not mapping:
174
+ log.warning("NOTE from unmapped Grove addr %s — dropped", from_addr)
175
+ return
176
+
177
+ if mapping["state"] == "pending_knock":
178
+ # First NOTE = implicit KNOCK approval. Admit them to contacts and activate.
179
+ pending = self.store.get_pending_knock(from_addr)
180
+ if pending and pending["public_key"]:
181
+ self._admit_contact(from_addr, pending["public_key"])
182
+ self.store.clear_pending_knock(from_addr)
183
+ self.store.activate(from_addr)
184
+ await self.matrix.send_notice(
185
+ mapping["matrix_room"],
186
+ f"Grove user {from_addr} accepted the connection.",
187
+ )
188
+
189
+ puppet = _puppet_id(from_addr, self._hs_name)
190
+ await self.matrix.ensure_virtual_user(puppet)
191
+ await self.matrix.send_message(mapping["matrix_room"], body, as_user=puppet)
192
+ log.info("Grove→Matrix %s → %s", from_addr, mapping["matrix_room"])
193
+
194
+ async def _grove_knock(self, from_addr: str, pubkey: str) -> None:
195
+ """
196
+ Grove KNOCK → bridge queues it. Matrix user must have already invited
197
+ the puppet (creating a mapping) for this to activate. Otherwise just
198
+ store the pubkey for when the invite arrives.
199
+ """
200
+ mapping = self.store.get_by_grove_addr(from_addr)
201
+ if mapping and mapping["state"] == "active":
202
+ # Re-knock from a known contact. This used to "update key
203
+ # silently", which reset blocked and every consent flag along with
204
+ # it. A re-KNOCK now confirms the existing key or is refused.
205
+ self._admit_contact(from_addr, pubkey)
206
+ return
207
+
208
+ self.store.set_pending_knock(from_addr, pubkey)
209
+ log.info("queued KNOCK from %s", from_addr)
210
+
211
+ # ── Matrix AS server ───────────────────────────────────────────
212
+
213
+ async def _run_as_server(self) -> None:
214
+ app = self._as_srv.build_app()
215
+ runner = web.AppRunner(app)
216
+ await runner.setup()
217
+ site = web.TCPSite(runner, "0.0.0.0", self._as_port)
218
+ await site.start()
219
+ log.info("AS server on port %d", self._as_port)
220
+ await asyncio.Event().wait()
221
+
222
+ async def _on_matrix_event(self, event: dict) -> None:
223
+ etype = event.get("type")
224
+ if etype == "m.room.member":
225
+ await self._mx_member(event)
226
+ elif etype == "m.room.message":
227
+ await self._mx_message(event)
228
+
229
+ async def _mx_member(self, event: dict) -> None:
230
+ """Matrix invite to a grove_* puppet → send KNOCK to that Grove addr."""
231
+ if event.get("content", {}).get("membership") != "invite":
232
+ return
233
+
234
+ state_key = event.get("state_key", "") # the invited user
235
+ room_id = event.get("room_id", "")
236
+ inviter = event.get("sender", "")
237
+
238
+ if not state_key.startswith("@grove_"):
239
+ return
240
+
241
+ localpart = state_key.split(":")[0].lstrip("@")[len("grove_"):]
242
+
243
+ # Bridge bot itself invited → just join
244
+ if localpart == "bridge":
245
+ await self.matrix.join_room(room_id, as_user=self._bot_id)
246
+ return
247
+
248
+ try:
249
+ grove_addr = _localpart_to_addr(localpart)
250
+ except (ValueError, UnicodeDecodeError):
251
+ log.warning("bad grove addr in puppet %s", state_key)
252
+ return
253
+
254
+ # Persist mapping (pending until Grove user sends their first NOTE)
255
+ self.store.upsert(grove_addr, room_id, inviter, state="pending_knock")
256
+
257
+ # Accept the invite on behalf of the puppet
258
+ puppet = state_key
259
+ await self.matrix.ensure_virtual_user(puppet)
260
+ await self.matrix.join_room(room_id, as_user=puppet)
261
+
262
+ # Send u2u KNOCK to the Grove user
263
+ ok = await send_packet(
264
+ PacketType.KNOCK,
265
+ self._bridge_addr,
266
+ grove_addr,
267
+ {"public_key": self.identity.public_key_hex},
268
+ self.identity,
269
+ )
270
+
271
+ notice = (
272
+ f"Knocked Grove user at {grove_addr}. Waiting for them to approve."
273
+ if ok else
274
+ f"Could not reach Grove user at {grove_addr} — are they online?"
275
+ )
276
+ await self.matrix.send_notice(room_id, notice)
277
+ log.info("Matrix→Grove KNOCK: %s → %s (ok=%s)", inviter, grove_addr, ok)
278
+
279
+ async def _mx_message(self, event: dict) -> None:
280
+ """Matrix message → u2u NOTE to Grove user."""
281
+ sender = event.get("sender", "")
282
+ room_id = event.get("room_id", "")
283
+ body = event.get("content", {}).get("body", "")
284
+
285
+ # Ignore messages from our own puppets to avoid loops
286
+ if sender.startswith("@grove_"):
287
+ return
288
+
289
+ mapping = self.store.get_by_matrix_room(room_id)
290
+ if not mapping:
291
+ return
292
+
293
+ grove_addr = mapping["grove_addr"]
294
+
295
+ if mapping["state"] == "pending_knock":
296
+ await self.matrix.send_notice(
297
+ room_id,
298
+ "Waiting for Grove user to approve the connection. Message not sent.",
299
+ )
300
+ return
301
+
302
+ ok = await send_packet(
303
+ PacketType.NOTE,
304
+ self._bridge_addr,
305
+ grove_addr,
306
+ {"subject": "", "body": body},
307
+ self.identity,
308
+ )
309
+ if not ok:
310
+ await self.matrix.send_notice(room_id, "Grove user appears offline — message not delivered.")
311
+ log.info("Matrix→Grove NOTE: %s → %s (ok=%s)", sender, grove_addr, ok)
bridge/matrix.py ADDED
@@ -0,0 +1,109 @@
1
+ # bridge/matrix.py — Matrix CS API client + Application Service HTTP server
2
+ import logging
3
+ import time
4
+ from aiohttp import web, ClientSession, ClientTimeout
5
+
6
+ log = logging.getLogger("bridge.matrix")
7
+
8
+ _TIMEOUT = ClientTimeout(total=15)
9
+
10
+
11
+ class MatrixClient:
12
+ """Minimal Matrix Client-Server API wrapper for a bridge bot."""
13
+
14
+ def __init__(self, homeserver: str, as_token: str, bot_user_id: str):
15
+ self._hs = homeserver.rstrip("/")
16
+ self._token = as_token
17
+ self._bot = bot_user_id
18
+ self._session: ClientSession | None = None
19
+
20
+ async def start(self) -> None:
21
+ self._session = ClientSession(
22
+ headers={"Authorization": f"Bearer {self._token}"},
23
+ timeout=_TIMEOUT,
24
+ )
25
+
26
+ async def close(self) -> None:
27
+ if self._session:
28
+ await self._session.close()
29
+
30
+ def _cs(self, path: str) -> str:
31
+ return f"{self._hs}/_matrix/client/v3{path}"
32
+
33
+ async def ensure_virtual_user(self, user_id: str) -> None:
34
+ """Register virtual user if not already registered. Idempotent."""
35
+ localpart = user_id.split(":")[0].lstrip("@")
36
+ async with self._session.post(
37
+ f"{self._hs}/_matrix/client/v3/register",
38
+ json={"type": "m.login.application_service", "username": localpart},
39
+ ) as r:
40
+ # 200 = registered, 400 M_USER_IN_USE = already exists — both are fine
41
+ if r.status not in (200, 400):
42
+ log.warning("register %s → %s", user_id, r.status)
43
+
44
+ async def join_room(self, room_id: str, as_user: str) -> bool:
45
+ async with self._session.post(
46
+ self._cs(f"/join/{room_id}"),
47
+ params={"user_id": as_user},
48
+ json={},
49
+ ) as r:
50
+ ok = r.status == 200
51
+ if not ok:
52
+ log.warning("join_room %s as %s → %s", room_id, as_user, r.status)
53
+ return ok
54
+
55
+ async def send_message(self, room_id: str, body: str, as_user: str) -> bool:
56
+ txn_id = str(int(time.monotonic() * 1_000_000))
57
+ async with self._session.put(
58
+ self._cs(f"/rooms/{room_id}/send/m.room.message/{txn_id}"),
59
+ params={"user_id": as_user},
60
+ json={"msgtype": "m.text", "body": body},
61
+ ) as r:
62
+ ok = r.status == 200
63
+ if not ok:
64
+ log.warning("send_message %s → %s", room_id, r.status)
65
+ return ok
66
+
67
+ async def send_notice(self, room_id: str, body: str) -> bool:
68
+ """Send a bridge status notice as the bot itself."""
69
+ return await self.send_message(room_id, f"[bridge] {body}", as_user=self._bot)
70
+
71
+
72
+ class ASServer:
73
+ """
74
+ Receives transaction events POSTed by the homeserver.
75
+ Passes each event to on_event(event: dict) coroutine.
76
+ """
77
+
78
+ _SEEN_MAX = 2048
79
+
80
+ def __init__(self, hs_token: str, on_event):
81
+ self._hs_token = hs_token
82
+ self._on_event = on_event
83
+ self._seen: dict[str, None] = {} # insertion-ordered dict as bounded LRU
84
+
85
+ def build_app(self) -> web.Application:
86
+ app = web.Application()
87
+ app.router.add_put("/transactions/{txn_id}", self._handle_txn)
88
+ return app
89
+
90
+ async def _handle_txn(self, request: web.Request) -> web.Response:
91
+ token = request.headers.get("Authorization", "").removeprefix("Bearer ")
92
+ if token != self._hs_token:
93
+ return web.Response(status=403)
94
+
95
+ txn_id = request.match_info["txn_id"]
96
+ if txn_id in self._seen:
97
+ return web.json_response({})
98
+ self._seen[txn_id] = None
99
+ if len(self._seen) > self._SEEN_MAX:
100
+ self._seen.pop(next(iter(self._seen)))
101
+
102
+ body = await request.json()
103
+ for event in body.get("events", []):
104
+ try:
105
+ await self._on_event(event)
106
+ except Exception as e:
107
+ log.error("event handler error: %s", e)
108
+
109
+ return web.json_response({})
@@ -0,0 +1,24 @@
1
+ # Grove ↔ Matrix Application Service registration
2
+ # Copy this file to your Synapse config directory, then add to homeserver.yaml:
3
+ #
4
+ # app_service_config_files:
5
+ # - /path/to/grove_bridge_registration.yaml
6
+ #
7
+ # Generate fresh tokens with: python3 -c "import secrets; print(secrets.token_hex(32))"
8
+
9
+ id: grove-bridge
10
+ url: "http://localhost:8560" # where Synapse POSTs events (your bridge's AS port)
11
+
12
+ as_token: "REPLACE_WITH_AS_TOKEN" # = GROVE_AS_TOKEN in your env file
13
+ hs_token: "REPLACE_WITH_HS_TOKEN" # = GROVE_HS_TOKEN in your env file
14
+
15
+ sender_localpart: grove_bridge # the bridge bot's Matrix localpart
16
+
17
+ namespaces:
18
+ users:
19
+ - exclusive: true
20
+ regex: '@grove_.*' # bridge owns all @grove_* virtual users
21
+ rooms: []
22
+ aliases: []
23
+
24
+ rate_limited: false
bridge/store.py ADDED
@@ -0,0 +1,69 @@
1
+ # bridge/store.py — SQLite mapping: grove addr ↔ Matrix room ↔ Matrix user
2
+ import sqlite3
3
+ from pathlib import Path
4
+ from typing import Optional
5
+
6
+
7
+ class BridgeStore:
8
+ def __init__(self, path: Path):
9
+ self._db = sqlite3.connect(str(path), check_same_thread=False)
10
+ self._db.row_factory = sqlite3.Row
11
+ self._db.executescript("""
12
+ CREATE TABLE IF NOT EXISTS mappings (
13
+ grove_addr TEXT PRIMARY KEY,
14
+ matrix_room TEXT NOT NULL,
15
+ matrix_user TEXT NOT NULL,
16
+ state TEXT NOT NULL DEFAULT 'pending_knock'
17
+ );
18
+ CREATE TABLE IF NOT EXISTS pending_knocks (
19
+ grove_addr TEXT PRIMARY KEY,
20
+ public_key TEXT NOT NULL,
21
+ created_at REAL DEFAULT (unixepoch('now'))
22
+ );
23
+ """)
24
+
25
+ def get_by_grove_addr(self, addr: str) -> Optional[sqlite3.Row]:
26
+ return self._db.execute(
27
+ "SELECT * FROM mappings WHERE grove_addr = ?", (addr,)
28
+ ).fetchone()
29
+
30
+ def get_by_matrix_room(self, room_id: str) -> Optional[sqlite3.Row]:
31
+ return self._db.execute(
32
+ "SELECT * FROM mappings WHERE matrix_room = ?", (room_id,)
33
+ ).fetchone()
34
+
35
+ def upsert(self, grove_addr: str, matrix_room: str, matrix_user: str,
36
+ state: str = "pending_knock") -> None:
37
+ self._db.execute("""
38
+ INSERT INTO mappings (grove_addr, matrix_room, matrix_user, state)
39
+ VALUES (?, ?, ?, ?)
40
+ ON CONFLICT (grove_addr) DO UPDATE SET
41
+ matrix_room = excluded.matrix_room,
42
+ matrix_user = excluded.matrix_user,
43
+ state = excluded.state
44
+ """, (grove_addr, matrix_room, matrix_user, state))
45
+ self._db.commit()
46
+
47
+ def activate(self, grove_addr: str) -> None:
48
+ self._db.execute(
49
+ "UPDATE mappings SET state = 'active' WHERE grove_addr = ?", (grove_addr,)
50
+ )
51
+ self._db.commit()
52
+
53
+ def set_pending_knock(self, grove_addr: str, public_key: str) -> None:
54
+ self._db.execute("""
55
+ INSERT OR REPLACE INTO pending_knocks (grove_addr, public_key)
56
+ VALUES (?, ?)
57
+ """, (grove_addr, public_key))
58
+ self._db.commit()
59
+
60
+ def get_pending_knock(self, grove_addr: str) -> Optional[sqlite3.Row]:
61
+ return self._db.execute(
62
+ "SELECT * FROM pending_knocks WHERE grove_addr = ?", (grove_addr,)
63
+ ).fetchone()
64
+
65
+ def clear_pending_knock(self, grove_addr: str) -> None:
66
+ self._db.execute(
67
+ "DELETE FROM pending_knocks WHERE grove_addr = ?", (grove_addr,)
68
+ )
69
+ self._db.commit()
grove/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Grove dashboard package — theme and vitals for Textual shell."""