Skip to content

Commit 3d00f98

Browse files
committed
feat(matrix): Matrix chat integration surface — bridge, wormhole, shell commands
turtle-matrix-bridge binary: - Daemon tails memory-mesh JSONL and forwards shell events to a Matrix room (cloudshell-connect, k3s-tunnel-start, split-view, error-triage, note-saved, runbook-run forwarded by default — configurable via MATRIX_BRIDGE_EVENTS) - `send <text>` — post plain text to default room - `send-file <path>` — post file as a syntax-highlighted code block - `wormhole-send <path>` — start magic-wormhole send, post the wormhole code to the room so any recipient can receive it - `wormhole-recv <code>` — receive a wormhole transfer, post result to room - `status` — homeserver connectivity + wormhole binary check Config: ~/.config/sourceos/matrix.yaml or MATRIX_* env vars Shell surface (turtle-shell-init.zsh): - `mx <msg>` post message to Matrix room - `mxf <file>` post file as code block - `mxw <file>` wormhole-send + post code to room (CMD+SHIFT+ALT+W) - `mxr <code>` wormhole-recv + post result to room - `mxstatus` show bridge config + connectivity - Daemon auto-started at shell init if matrix.yaml or MATRIX_ACCESS_TOKEN present - CMD+SHIFT+ALT+M → `mx ` (compose message prefix) Palette (turtleterm.lua): - Matrix: send message, send file, wormhole send, bridge status - CMD+SHIFT+ALT+M / CMD+SHIFT+ALT+W keybindings Dashboard (turtle-mesh-serve): - Matrix badge in status bar: green=bridge daemon alive, red=off - matrix_bridge_up in gather_state() via PID file liveness check
1 parent 1d9e30d commit 3d00f98

4 files changed

Lines changed: 512 additions & 3 deletions

File tree

Lines changed: 399 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,399 @@
1+
#!/usr/bin/env python3
2+
# MIT License
3+
# Copyright (c) 2026 @mdheller
4+
#
5+
# turtle-matrix-bridge — bridge between TurtleTerm mesh events and a Matrix room.
6+
#
7+
# Tails the memory-mesh JSONL and forwards shell events to a Matrix room via the
8+
# Matrix CS API. Also provides a send-message path used by the mx/mxf shell
9+
# functions.
10+
#
11+
# Config (env vars or ~/.config/sourceos/matrix.yaml):
12+
# MATRIX_HOMESERVER_URL e.g. https://matrix.org or http://localhost:8448
13+
# MATRIX_ACCESS_TOKEN bot user access token
14+
# MATRIX_BOT_ROOM_ID default room to post into (!roomid:server)
15+
# MATRIX_BRIDGE_EVENTS comma-separated mesh event types to forward
16+
# default: cloudshell-connect,k3s-tunnel-start,split-view,error-triage
17+
#
18+
# Usage:
19+
# turtle-matrix-bridge daemon # tail mesh + forward events (runs forever)
20+
# turtle-matrix-bridge send <text> # post plain text to default room
21+
# turtle-matrix-bridge send-file <path> [<room_id>] # post file content as code block
22+
# turtle-matrix-bridge send-code <lang> <path> # formatted code block
23+
# turtle-matrix-bridge wormhole-send <path> # start wormhole send, post code to room
24+
# turtle-matrix-bridge wormhole-recv <code> [<room>] # receive wormhole, post result to room
25+
# turtle-matrix-bridge status # show config + connectivity
26+
27+
from __future__ import annotations
28+
29+
import json
30+
import os
31+
import re
32+
import subprocess
33+
import sys
34+
import time
35+
import urllib.error
36+
import urllib.request
37+
import uuid
38+
from pathlib import Path
39+
from typing import Any
40+
41+
# ── Config ────────────────────────────────────────────────────────────────────
42+
43+
YAML_CONFIG = Path.home() / ".config" / "sourceos" / "matrix.yaml"
44+
MESH_JSONL = Path.home() / ".local" / "state" / "sourceos" / "memory-mesh" / "context.jsonl"
45+
STATE_DIR = Path.home() / ".local" / "state" / "sourceos"
46+
CURSOR_FILE = STATE_DIR / "matrix-bridge-cursor"
47+
48+
DEFAULT_FORWARD_EVENTS = {
49+
"cloudshell-connect",
50+
"k3s-tunnel-start",
51+
"split-view",
52+
"error-triage",
53+
"note-saved",
54+
"runbook-run",
55+
}
56+
57+
def _load_yaml_config() -> dict[str, str]:
58+
if not YAML_CONFIG.exists():
59+
return {}
60+
try:
61+
import re as _re
62+
cfg: dict[str, str] = {}
63+
with open(YAML_CONFIG) as f:
64+
for line in f:
65+
line = line.strip()
66+
m = _re.match(r'^(\w[\w_]*):\s*(.+)$', line)
67+
if m:
68+
cfg[m.group(1)] = m.group(2).strip('"\'')
69+
return cfg
70+
except Exception:
71+
return {}
72+
73+
_yaml = _load_yaml_config()
74+
75+
def _cfg(key: str, default: str = "") -> str:
76+
return os.environ.get(key) or _yaml.get(key.lower().replace("matrix_", "").replace("_", "_")) or _yaml.get(key) or default
77+
78+
HOMESERVER_URL = _cfg("MATRIX_HOMESERVER_URL", "http://localhost:8448").rstrip("/")
79+
ACCESS_TOKEN = _cfg("MATRIX_ACCESS_TOKEN", "")
80+
DEFAULT_ROOM_ID = _cfg("MATRIX_BOT_ROOM_ID", "")
81+
_ev_cfg = _cfg("MATRIX_BRIDGE_EVENTS", "")
82+
FORWARD_EVENTS = set(_ev_cfg.split(",")) if _ev_cfg else DEFAULT_FORWARD_EVENTS
83+
84+
# ── Matrix CS API helpers ─────────────────────────────────────────────────────
85+
86+
def _headers() -> dict[str, str]:
87+
h = {"Content-Type": "application/json"}
88+
if ACCESS_TOKEN:
89+
h["Authorization"] = f"Bearer {ACCESS_TOKEN}"
90+
return h
91+
92+
def _req(method: str, path: str, body: dict | None = None, timeout: int = 10) -> dict:
93+
url = f"{HOMESERVER_URL}{path}"
94+
data = json.dumps(body).encode() if body else None
95+
req = urllib.request.Request(url, data=data, headers=_headers(), method=method)
96+
try:
97+
with urllib.request.urlopen(req, timeout=timeout) as resp:
98+
return json.loads(resp.read().decode())
99+
except urllib.error.HTTPError as e:
100+
body_text = e.read().decode()
101+
raise RuntimeError(f"Matrix HTTP {e.code}: {body_text}") from e
102+
103+
def _send_message(room_id: str, text: str, thread_event_id: str | None = None,
104+
formatted_html: str | None = None) -> str:
105+
txn = uuid.uuid4().hex
106+
content: dict[str, Any] = {
107+
"msgtype": "m.text",
108+
"body": text,
109+
}
110+
if formatted_html:
111+
content["format"] = "org.matrix.custom.html"
112+
content["formatted_body"] = formatted_html
113+
if thread_event_id:
114+
content["m.relates_to"] = {
115+
"rel_type": "m.thread",
116+
"event_id": thread_event_id,
117+
}
118+
result = _req("PUT", f"/_matrix/client/v3/rooms/{urllib.parse.quote(room_id)}/send/m.room.message/{txn}", content)
119+
return result.get("event_id", "")
120+
121+
# python 3.9+ has urllib.parse but we import lazily
122+
import urllib.parse
123+
124+
def _send_code_block(room_id: str, code: str, lang: str = "", label: str = "") -> str:
125+
import html
126+
plain = f"```{lang}\n{code}\n```"
127+
if label:
128+
plain = f"{label}\n{plain}"
129+
escaped = html.escape(code)
130+
fmted = f"<p><strong>{html.escape(label)}</strong></p>" if label else ""
131+
fmted += f"<pre><code class=\"language-{html.escape(lang)}\">{escaped}</code></pre>"
132+
return _send_message(room_id, plain, formatted_html=fmted)
133+
134+
# ── Wormhole ──────────────────────────────────────────────────────────────────
135+
136+
def _wormhole_bin() -> str | None:
137+
for candidate in ["wormhole", "wormhole-william"]:
138+
try:
139+
result = subprocess.run(
140+
["which", candidate], capture_output=True, text=True, timeout=5
141+
)
142+
if result.returncode == 0:
143+
return candidate
144+
except Exception:
145+
continue
146+
return None
147+
148+
_WORMHOLE_CODE_RE = re.compile(r'\b(\d+-[\w]+-[\w]+(?:-[\w]+)*)\b')
149+
150+
def _wormhole_send(path: str, room_id: str) -> str:
151+
bin_ = _wormhole_bin()
152+
if not bin_:
153+
return "⚠ magic-wormhole not found. Install with: pip install magic-wormhole or brew install magic-wormhole"
154+
155+
_send_message(room_id, f"⏳ Starting wormhole send for `{Path(path).name}`…")
156+
157+
proc = subprocess.Popen(
158+
[bin_, "send", path],
159+
stdout=subprocess.PIPE,
160+
stderr=subprocess.STDOUT,
161+
text=True,
162+
)
163+
164+
code: str | None = None
165+
deadline = time.monotonic() + 60
166+
while time.monotonic() < deadline:
167+
line = proc.stdout.readline() if proc.stdout else ""
168+
if not line:
169+
if proc.poll() is not None:
170+
break
171+
time.sleep(0.2)
172+
continue
173+
m = _WORMHOLE_CODE_RE.search(line)
174+
if m:
175+
code = m.group(1)
176+
break
177+
178+
if not code:
179+
proc.terminate()
180+
return "⚠ Could not get wormhole code within 60s"
181+
182+
# proc stays running in background — receiver picks up the file
183+
reply = (
184+
f"📦 **Wormhole code:** `{code}`\n\n"
185+
f"Receive with:\n```\nwormhole receive {code}\n```\n"
186+
f"or in this room:\n```\n!qes wormhole recv {code}\n```"
187+
)
188+
_send_message(room_id, reply, formatted_html=(
189+
f"<p>📦 <strong>Wormhole code:</strong> <code>{code}</code></p>"
190+
f"<p>Receive with: <code>wormhole receive {code}</code>"
191+
f"<br>or in this room: <code>!qes wormhole recv {code}</code></p>"
192+
))
193+
return code
194+
195+
def _wormhole_recv(code: str, room_id: str) -> str:
196+
bin_ = _wormhole_bin()
197+
if not bin_:
198+
return "⚠ magic-wormhole not found. Install with: pip install magic-wormhole"
199+
200+
dest = Path.home() / "Downloads" / "wormhole-recv"
201+
dest.mkdir(parents=True, exist_ok=True)
202+
_send_message(room_id, f"⏳ Receiving wormhole `{code}`…")
203+
204+
try:
205+
result = subprocess.run(
206+
[bin_, "receive", "--accept-file", "--output-file", str(dest / code), code],
207+
capture_output=True, text=True, timeout=120, cwd=str(dest),
208+
)
209+
except subprocess.TimeoutExpired:
210+
return "⚠ wormhole receive timed out (120s)"
211+
212+
if result.returncode != 0:
213+
return f"⚠ wormhole receive failed:\n```\n{result.stderr[:800]}\n```"
214+
215+
# Find what was downloaded
216+
out = result.stdout.strip() or result.stderr.strip()
217+
path_match = re.search(r'Received file written to (.+)', out)
218+
saved = path_match.group(1).strip() if path_match else str(dest)
219+
return f"✅ Received → `{saved}`"
220+
221+
# ── Mesh event tail ───────────────────────────────────────────────────────────
222+
223+
def _read_cursor() -> int:
224+
try:
225+
return int(CURSOR_FILE.read_text().strip())
226+
except Exception:
227+
return 0
228+
229+
def _write_cursor(pos: int) -> None:
230+
CURSOR_FILE.write_text(str(pos))
231+
232+
def _event_to_matrix_body(event: dict) -> str | None:
233+
ev_type = event.get("type", "")
234+
if ev_type not in FORWARD_EVENTS:
235+
return None
236+
237+
ts = event.get("ts", "")[:19].replace("T", " ")
238+
data = event.get("data", {})
239+
title = event.get("title", ev_type)
240+
241+
if ev_type == "cloudshell-connect":
242+
host = data.get("host", "?")
243+
return f"🔗 **Cloudshell connected** → `{host}` at {ts}"
244+
245+
if ev_type == "k3s-tunnel-start":
246+
port = data.get("local_port", "16443")
247+
return f"🚇 **k3s tunnel open** → `localhost:{port}` at {ts}"
248+
249+
if ev_type == "split-view":
250+
left = data.get("left", "")
251+
right = data.get("right", "")
252+
return f"⧉ **Split view** opened: `{left}` ‖ `{right}` at {ts}"
253+
254+
if ev_type == "error-triage":
255+
cmd = data.get("cmd", "")[:80]
256+
rc = data.get("rc", "?")
257+
return f"⚠ **Error triage** — `{cmd}` exited {rc} at {ts}"
258+
259+
if ev_type == "note-saved":
260+
name = data.get("title", data.get("name", ""))
261+
return f"📝 **Note saved:** `{name}` at {ts}"
262+
263+
if ev_type == "runbook-run":
264+
name = data.get("name", "")
265+
return f"📋 **Runbook run:** `{name}` at {ts}"
266+
267+
return f"ℹ **{title}** at {ts}"
268+
269+
def _daemon(room_id: str) -> None:
270+
print(f"turtle-matrix-bridge: daemon starting, room={room_id}", flush=True)
271+
pos = _read_cursor()
272+
273+
while True:
274+
if not MESH_JSONL.exists():
275+
time.sleep(5)
276+
continue
277+
278+
with open(MESH_JSONL) as f:
279+
f.seek(pos)
280+
while True:
281+
line = f.readline()
282+
if not line:
283+
break
284+
pos = f.tell()
285+
_write_cursor(pos)
286+
try:
287+
event = json.loads(line.strip())
288+
except json.JSONDecodeError:
289+
continue
290+
body = _event_to_matrix_body(event)
291+
if body:
292+
try:
293+
_send_message(room_id, body)
294+
print(f" → forwarded: {event.get('type')}", flush=True)
295+
except Exception as exc:
296+
print(f" ⚠ send failed: {exc}", flush=True)
297+
298+
time.sleep(2)
299+
300+
# ── CLI ───────────────────────────────────────────────────────────────────────
301+
302+
def _require_room(argv: list[str], offset: int) -> str:
303+
if len(argv) > offset:
304+
return argv[offset]
305+
if DEFAULT_ROOM_ID:
306+
return DEFAULT_ROOM_ID
307+
print("⚠ No room ID. Set MATRIX_BOT_ROOM_ID or pass as argument.", file=sys.stderr)
308+
sys.exit(1)
309+
310+
def _status() -> None:
311+
print(f"homeserver : {HOMESERVER_URL}")
312+
print(f"token : {'set' if ACCESS_TOKEN else 'NOT SET'}")
313+
print(f"room : {DEFAULT_ROOM_ID or 'NOT SET'}")
314+
print(f"forward : {', '.join(sorted(FORWARD_EVENTS))}")
315+
print(f"mesh jsonl : {'exists' if MESH_JSONL.exists() else 'missing'}")
316+
print(f"cursor : {_read_cursor()} bytes")
317+
print(f"wormhole : {_wormhole_bin() or 'not found'}")
318+
if ACCESS_TOKEN and HOMESERVER_URL:
319+
try:
320+
me = _req("GET", "/_matrix/client/v3/account/whoami")
321+
print(f"whoami : {me.get('user_id', '?')}")
322+
except Exception as exc:
323+
print(f"whoami : ⚠ {exc}")
324+
325+
def main() -> None:
326+
argv = sys.argv[1:]
327+
if not argv:
328+
print(__doc__.strip())
329+
sys.exit(0)
330+
331+
cmd = argv[0]
332+
333+
if cmd == "daemon":
334+
room = _require_room(argv, 1)
335+
_daemon(room)
336+
337+
elif cmd == "send":
338+
if len(argv) < 2:
339+
print("Usage: turtle-matrix-bridge send <text> [<room_id>]", file=sys.stderr)
340+
sys.exit(1)
341+
text = argv[1]
342+
room = _require_room(argv, 2)
343+
eid = _send_message(room, text)
344+
print(f"sent: {eid}")
345+
346+
elif cmd == "send-file":
347+
if len(argv) < 2:
348+
print("Usage: turtle-matrix-bridge send-file <path> [<room_id>]", file=sys.stderr)
349+
sys.exit(1)
350+
path = Path(argv[1])
351+
room = _require_room(argv, 2)
352+
try:
353+
content = path.read_text(errors="replace")
354+
except Exception as exc:
355+
print(f"⚠ Cannot read {path}: {exc}", file=sys.stderr)
356+
sys.exit(1)
357+
lang = path.suffix.lstrip(".") or "text"
358+
eid = _send_code_block(room, content[:8000], lang=lang, label=str(path.name))
359+
print(f"sent: {eid}")
360+
361+
elif cmd == "send-code":
362+
if len(argv) < 3:
363+
print("Usage: turtle-matrix-bridge send-code <lang> <path> [<room_id>]", file=sys.stderr)
364+
sys.exit(1)
365+
lang = argv[1]
366+
path = Path(argv[2])
367+
room = _require_room(argv, 3)
368+
content = path.read_text(errors="replace")
369+
eid = _send_code_block(room, content[:8000], lang=lang, label=str(path.name))
370+
print(f"sent: {eid}")
371+
372+
elif cmd == "wormhole-send":
373+
if len(argv) < 2:
374+
print("Usage: turtle-matrix-bridge wormhole-send <path> [<room_id>]", file=sys.stderr)
375+
sys.exit(1)
376+
path = argv[1]
377+
room = _require_room(argv, 2)
378+
code = _wormhole_send(path, room)
379+
print(code)
380+
381+
elif cmd == "wormhole-recv":
382+
if len(argv) < 2:
383+
print("Usage: turtle-matrix-bridge wormhole-recv <code> [<room_id>]", file=sys.stderr)
384+
sys.exit(1)
385+
code = argv[1]
386+
room = _require_room(argv, 2)
387+
result = _wormhole_recv(code, room)
388+
_send_message(room, result)
389+
print(result)
390+
391+
elif cmd == "status":
392+
_status()
393+
394+
else:
395+
print(f"Unknown command: {cmd}", file=sys.stderr)
396+
sys.exit(1)
397+
398+
if __name__ == "__main__":
399+
main()

0 commit comments

Comments
 (0)