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.
- bridge/__init__.py +1 -0
- bridge/__main__.py +112 -0
- bridge/app.py +311 -0
- bridge/matrix.py +109 -0
- bridge/registration.yaml +24 -0
- bridge/store.py +69 -0
- grove/__init__.py +1 -0
- grove/envelope_reader.py +235 -0
- grove/errors.py +63 -0
- grove/fleet_presence.py +107 -0
- grove/journal_reader.py +311 -0
- grove/journal_writer.py +211 -0
- grove/kart_reader.py +303 -0
- grove/mcp_auth.py +454 -0
- grove/mcp_local.py +1284 -0
- grove/nestor_client.py +376 -0
- grove/persona_roster.py +317 -0
- grove/resident_watcher.py +624 -0
- grove/seed_html.py +545 -0
- grove/seed_reader.py +254 -0
- grove_db.py +831 -0
- grove_html.py +302 -0
- grove_reader.py +1361 -0
- grove_serve.py +538 -0
- panes/__init__.py +5 -0
- panes/chat_admin.py +38 -0
- schema.sql +175 -0
- u2u/__init__.py +0 -0
- u2u/consent.py +128 -0
- u2u/contacts.py +149 -0
- u2u/dispatcher.py +37 -0
- u2u/identity.py +51 -0
- u2u/listener.py +145 -0
- u2u/packets.py +148 -0
- u2u/sender.py +63 -0
- web/boot/layout-memory-boot.js +107 -0
- web/boot/refusal-summon-boot.js +185 -0
- web/components/grove-card.js +265 -0
- web/components/grove-cast-chip.js +176 -0
- web/components/grove-chat.js +616 -0
- web/components/grove-dispatch-rail.js +425 -0
- web/components/grove-envelope-panel.js +269 -0
- web/components/grove-lens-switch.js +236 -0
- web/components/grove-persona-registry.js +290 -0
- web/components/grove-refusal-chip.js +292 -0
- web/fixtures/envelopes.json +66 -0
- web/fixtures/persona-registry.json +33 -0
- web/fixtures/refusals.json +39 -0
- web/harness.html +170 -0
- web/lib/layout-memory.js +227 -0
- willows_grove-0.9.0.dist-info/METADATA +131 -0
- willows_grove-0.9.0.dist-info/RECORD +54 -0
- willows_grove-0.9.0.dist-info/WHEEL +4 -0
- 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({})
|
bridge/registration.yaml
ADDED
|
@@ -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."""
|