diff --git a/assets/sourceos/bin/turtle-matrix-bridge b/assets/sourceos/bin/turtle-matrix-bridge index 0765f467a18..b46931da2ad 100755 --- a/assets/sourceos/bin/turtle-matrix-bridge +++ b/assets/sourceos/bin/turtle-matrix-bridge @@ -2,37 +2,43 @@ # MIT License # Copyright (c) 2026 @mdheller # -# turtle-matrix-bridge — bridge between TurtleTerm mesh events and a Matrix room. -# -# Tails the memory-mesh JSONL and forwards shell events to a Matrix room via the -# Matrix CS API. Also provides a send-message path used by the mx/mxf shell -# functions. +# turtle-matrix-bridge — sovereign Matrix integration surface for TurtleTerm. # # Config (env vars or ~/.config/sourceos/matrix.yaml): # MATRIX_HOMESERVER_URL e.g. https://matrix.org or http://localhost:8448 # MATRIX_ACCESS_TOKEN bot user access token -# MATRIX_BOT_ROOM_ID default room to post into (!roomid:server) +# MATRIX_BOT_ROOM_ID default room (!roomid:server) # MATRIX_BRIDGE_EVENTS comma-separated mesh event types to forward -# default: cloudshell-connect,k3s-tunnel-start,split-view,error-triage # # Usage: -# turtle-matrix-bridge daemon # tail mesh + forward events (runs forever) -# turtle-matrix-bridge send # post plain text to default room -# turtle-matrix-bridge send-file [] # post file content as code block -# turtle-matrix-bridge send-code # formatted code block -# turtle-matrix-bridge wormhole-send # start wormhole send, post code to room -# turtle-matrix-bridge wormhole-recv [] # receive wormhole, post result to room -# turtle-matrix-bridge status # show config + connectivity +# turtle-matrix-bridge daemon [] # tail mesh → Matrix (runs forever) +# turtle-matrix-bridge send [-r ] [] # post text; reads stdin if no text +# turtle-matrix-bridge send-file [-r ] +# turtle-matrix-bridge send-code [-r ] +# turtle-matrix-bridge pipe [-r ] [--lang ] # stdin → code block +# turtle-matrix-bridge reply [-r ] +# turtle-matrix-bridge react [-r ] +# turtle-matrix-bridge rooms # list joined rooms +# turtle-matrix-bridge messages [] [-r ] # fetch last n messages +# turtle-matrix-bridge wormhole-send [-r ] +# turtle-matrix-bridge wormhole-pipe [-r ] # stdin → wormhole → post code +# turtle-matrix-bridge wormhole-recv [-r ] +# turtle-matrix-bridge ctx [-r ] # post active context snapshot +# turtle-matrix-bridge status from __future__ import annotations import json +import mimetypes import os import re +import shutil import subprocess import sys +import tempfile import time import urllib.error +import urllib.parse import urllib.request import uuid from pathlib import Path @@ -40,40 +46,54 @@ from typing import Any # ── Config ──────────────────────────────────────────────────────────────────── -YAML_CONFIG = Path.home() / ".config" / "sourceos" / "matrix.yaml" -MESH_JSONL = Path.home() / ".local" / "state" / "sourceos" / "memory-mesh" / "context.jsonl" +YAML_CONFIG = Path.home() / ".config" / "sourceos" / "matrix.yaml" +MESH_DIR = Path.home() / ".local" / "state" / "sourceos" / "memory-mesh" STATE_DIR = Path.home() / ".local" / "state" / "sourceos" CURSOR_FILE = STATE_DIR / "matrix-bridge-cursor" +ACTIVE_JSON = MESH_DIR / "active.json" DEFAULT_FORWARD_EVENTS = { - "cloudshell-connect", - "k3s-tunnel-start", - "split-view", - "error-triage", - "note-saved", - "runbook-run", + "cloudshell-connect", "k3s-tunnel-start", "split-view", + "error-triage", "note-saved", "runbook-run", +} + +_LANG_MAP: dict[str, str] = { + ".py": "python", ".js": "javascript", ".ts": "typescript", + ".sh": "bash", ".zsh": "bash", ".yaml": "yaml", ".yml": "yaml", + ".json": "json", ".go": "go", ".rs": "rust", ".lua": "lua", + ".md": "markdown", ".sql": "sql", ".toml": "toml", ".diff": "diff", + ".patch": "diff", ".tf": "hcl", ".nix": "nix", } + def _load_yaml_config() -> dict[str, str]: if not YAML_CONFIG.exists(): return {} try: - import re as _re cfg: dict[str, str] = {} with open(YAML_CONFIG) as f: for line in f: line = line.strip() - m = _re.match(r'^(\w[\w_]*):\s*(.+)$', line) - if m: - cfg[m.group(1)] = m.group(2).strip('"\'') + if line.startswith("#") or ":" not in line: + continue + k, _, v = line.partition(":") + cfg[k.strip()] = v.strip().strip("\"'") return cfg except Exception: return {} + _yaml = _load_yaml_config() + def _cfg(key: str, default: str = "") -> str: - return os.environ.get(key) or _yaml.get(key.lower().replace("matrix_", "").replace("_", "_")) or _yaml.get(key) or default + return ( + os.environ.get(key) + or _yaml.get(key) + or _yaml.get(key.lower()) + or default + ) + HOMESERVER_URL = _cfg("MATRIX_HOMESERVER_URL", "http://localhost:8448").rstrip("/") ACCESS_TOKEN = _cfg("MATRIX_ACCESS_TOKEN", "") @@ -81,7 +101,8 @@ DEFAULT_ROOM_ID = _cfg("MATRIX_BOT_ROOM_ID", "") _ev_cfg = _cfg("MATRIX_BRIDGE_EVENTS", "") FORWARD_EVENTS = set(_ev_cfg.split(",")) if _ev_cfg else DEFAULT_FORWARD_EVENTS -# ── Matrix CS API helpers ───────────────────────────────────────────────────── +# ── Matrix CS API ───────────────────────────────────────────────────────────── + def _headers() -> dict[str, str]: h = {"Content-Type": "application/json"} @@ -89,24 +110,26 @@ def _headers() -> dict[str, str]: h["Authorization"] = f"Bearer {ACCESS_TOKEN}" return h + def _req(method: str, path: str, body: dict | None = None, timeout: int = 10) -> dict: url = f"{HOMESERVER_URL}{path}" - data = json.dumps(body).encode() if body else None + data = json.dumps(body).encode() if body is not None else None req = urllib.request.Request(url, data=data, headers=_headers(), method=method) try: with urllib.request.urlopen(req, timeout=timeout) as resp: return json.loads(resp.read().decode()) except urllib.error.HTTPError as e: - body_text = e.read().decode() - raise RuntimeError(f"Matrix HTTP {e.code}: {body_text}") from e + raise RuntimeError(f"Matrix {e.code}: {e.read().decode()[:300]}") from e -def _send_message(room_id: str, text: str, thread_event_id: str | None = None, - formatted_html: str | None = None) -> str: + +def _send_message( + room_id: str, + text: str, + thread_event_id: str | None = None, + formatted_html: str | None = None, +) -> str: txn = uuid.uuid4().hex - content: dict[str, Any] = { - "msgtype": "m.text", - "body": text, - } + content: dict[str, Any] = {"msgtype": "m.text", "body": text} if formatted_html: content["format"] = "org.matrix.custom.html" content["formatted_body"] = formatted_html @@ -115,167 +138,303 @@ def _send_message(room_id: str, text: str, thread_event_id: str | None = None, "rel_type": "m.thread", "event_id": thread_event_id, } - result = _req("PUT", f"/_matrix/client/v3/rooms/{urllib.parse.quote(room_id)}/send/m.room.message/{txn}", content) + room_enc = urllib.parse.quote(room_id, safe="") + result = _req("PUT", f"/_matrix/client/v3/rooms/{room_enc}/send/m.room.message/{txn}", content) return result.get("event_id", "") -# python 3.9+ has urllib.parse but we import lazily -import urllib.parse -def _send_code_block(room_id: str, code: str, lang: str = "", label: str = "") -> str: - import html - plain = f"```{lang}\n{code}\n```" - if label: - plain = f"{label}\n{plain}" - escaped = html.escape(code) - fmted = f"

{html.escape(label)}

" if label else "" - fmted += f"
{escaped}
" - return _send_message(room_id, plain, formatted_html=fmted) +def _send_reaction(room_id: str, event_id: str, emoji: str) -> str: + txn = uuid.uuid4().hex + room_enc = urllib.parse.quote(room_id, safe="") + result = _req( + "PUT", + f"/_matrix/client/v3/rooms/{room_enc}/send/m.reaction/{txn}", + {"m.relates_to": {"rel_type": "m.annotation", "event_id": event_id, "key": emoji}}, + ) + return result.get("event_id", "") + + +def _edit_message(room_id: str, event_id: str, new_text: str) -> str: + txn = uuid.uuid4().hex + room_enc = urllib.parse.quote(room_id, safe="") + result = _req( + "PUT", + f"/_matrix/client/v3/rooms/{room_enc}/send/m.room.message/{txn}", + { + "msgtype": "m.text", + "body": f"* {new_text}", + "m.new_content": {"msgtype": "m.text", "body": new_text}, + "m.relates_to": {"rel_type": "m.replace", "event_id": event_id}, + }, + ) + return result.get("event_id", "") + + +def _joined_rooms() -> list[str]: + result = _req("GET", "/_matrix/client/v3/joined_rooms") + return result.get("joined_rooms", []) + + +def _room_messages(room_id: str, limit: int = 20) -> list[dict]: + room_enc = urllib.parse.quote(room_id, safe="") + try: + result = _req( + "GET", + f"/_matrix/client/v3/rooms/{room_enc}/messages?dir=b&limit={limit}", + timeout=5, + ) + msgs = [] + for ev in reversed(result.get("chunk", [])): + if ev.get("type") != "m.room.message": + continue + content = ev.get("content", {}) + if content.get("msgtype") != "m.text": + continue + msgs.append({ + "event_id": ev.get("event_id", ""), + "sender": ev.get("sender", ""), + "body": content.get("body", ""), + "ts": ev.get("origin_server_ts", 0), + }) + return msgs + except Exception: + return [] + + +def _room_name(room_id: str) -> str: + room_enc = urllib.parse.quote(room_id, safe="") + try: + result = _req("GET", f"/_matrix/client/v3/rooms/{room_enc}/state/m.room.name/", timeout=5) + return result.get("name", room_id) + except Exception: + return room_id + + +def _lang_for(path: Path) -> str: + return _LANG_MAP.get(path.suffix.lower(), "") + + +def _code_block_html(code: str, lang: str, label: str = "") -> tuple[str, str]: + """Return (plain_text, formatted_html) for a code block.""" + import html as _html + escaped = _html.escape(code) + plain = (f"{label}\n" if label else "") + f"```{lang}\n{code}\n```" + html_label = f"

{_html.escape(label)}

" if label else "" + fmted = html_label + f"
{escaped}
" + return plain, fmted + + +def _send_code_block( + room_id: str, + code: str, + lang: str = "", + label: str = "", + thread_event_id: str | None = None, +) -> str: + plain, fmted = _code_block_html(code, lang, label) + return _send_message(room_id, plain, thread_event_id=thread_event_id, formatted_html=fmted) + # ── Wormhole ────────────────────────────────────────────────────────────────── +_WORMHOLE_CODE_RE = re.compile(r'\b(\d+(?:-\w+){2,})\b') + + def _wormhole_bin() -> str | None: for candidate in ["wormhole", "wormhole-william"]: - try: - result = subprocess.run( - ["which", candidate], capture_output=True, text=True, timeout=5 - ) - if result.returncode == 0: - return candidate - except Exception: - continue + found = shutil.which(candidate) + if found: + return found + explicit = "/usr/local/bin/wormhole-william" + if os.path.isfile(explicit): + return explicit return None -_WORMHOLE_CODE_RE = re.compile(r'\b(\d+-[\w]+-[\w]+(?:-[\w]+)*)\b') -def _wormhole_send(path: str, room_id: str) -> str: +def _wormhole_send_path(path: str, room_id: str) -> str: bin_ = _wormhole_bin() if not bin_: - return "⚠ magic-wormhole not found. Install with: pip install magic-wormhole or brew install magic-wormhole" + return "⚠ magic-wormhole not found — install with: brew install magic-wormhole" _send_message(room_id, f"⏳ Starting wormhole send for `{Path(path).name}`…") - proc = subprocess.Popen( [bin_, "send", path], - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, + ) + code = _capture_wormhole_code(proc, timeout=60) + if not code: + proc.terminate() + return "⚠ Could not get wormhole code within 60s" + return _post_wormhole_code(room_id, code) + + +def _wormhole_send_text(text: str, room_id: str) -> str: + bin_ = _wormhole_bin() + if not bin_: + return "⚠ magic-wormhole not found — install with: brew install magic-wormhole" + + _send_message(room_id, "⏳ Starting wormhole text send…") + proc = subprocess.Popen( + [bin_, "send", "--text", text], + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, ) + code = _capture_wormhole_code(proc, timeout=60) + if not code: + proc.terminate() + return "⚠ Could not get wormhole code within 60s" + return _post_wormhole_code(room_id, code) + + +def _wormhole_send_stdin(room_id: str) -> str: + """Read stdin, save to temp file, send via wormhole.""" + data = sys.stdin.buffer.read() + with tempfile.NamedTemporaryFile(delete=False, suffix=".txt", prefix="mx-pipe-") as f: + f.write(data) + tmp = f.name + try: + return _wormhole_send_path(tmp, room_id) + finally: + try: + os.unlink(tmp) + except OSError: + pass - code: str | None = None - deadline = time.monotonic() + 60 + +def _capture_wormhole_code(proc: subprocess.Popen, timeout: int = 60) -> str | None: + deadline = time.monotonic() + timeout while time.monotonic() < deadline: line = proc.stdout.readline() if proc.stdout else "" if not line: if proc.poll() is not None: break - time.sleep(0.2) + time.sleep(0.1) continue m = _WORMHOLE_CODE_RE.search(line) if m: - code = m.group(1) - break + return m.group(1) + return None - if not code: - proc.terminate() - return "⚠ Could not get wormhole code within 60s" - # proc stays running in background — receiver picks up the file - reply = ( +def _post_wormhole_code(room_id: str, code: str) -> str: + msg = ( f"📦 **Wormhole code:** `{code}`\n\n" - f"Receive with:\n```\nwormhole receive {code}\n```\n" - f"or in this room:\n```\n!qes wormhole recv {code}\n```" + f"Receive:\n```\nwormhole receive {code}\n```\n" + f"or: `!qes wormhole recv {code}`" + ) + import html as _html + fmted = ( + f"

📦 Wormhole code: {_html.escape(code)}

" + f"

Receive: wormhole receive {_html.escape(code)}" + f"
or: !qes wormhole recv {_html.escape(code)}

" ) - _send_message(room_id, reply, formatted_html=( - f"

📦 Wormhole code: {code}

" - f"

Receive with: wormhole receive {code}" - f"
or in this room: !qes wormhole recv {code}

" - )) + _send_message(room_id, msg, formatted_html=fmted) return code + def _wormhole_recv(code: str, room_id: str) -> str: bin_ = _wormhole_bin() if not bin_: - return "⚠ magic-wormhole not found. Install with: pip install magic-wormhole" + return "⚠ magic-wormhole not found" dest = Path.home() / "Downloads" / "wormhole-recv" dest.mkdir(parents=True, exist_ok=True) - _send_message(room_id, f"⏳ Receiving wormhole `{code}`…") + _send_message(room_id, f"⏳ Receiving `{code}`…") try: result = subprocess.run( - [bin_, "receive", "--accept-file", "--output-file", str(dest / code), code], + [bin_, "receive", "--accept-file", code], capture_output=True, text=True, timeout=120, cwd=str(dest), ) except subprocess.TimeoutExpired: return "⚠ wormhole receive timed out (120s)" + out = (result.stdout + result.stderr).strip() if result.returncode != 0: - return f"⚠ wormhole receive failed:\n```\n{result.stderr[:800]}\n```" + return f"⚠ wormhole failed:\n```\n{out[:600]}\n```" - # Find what was downloaded - out = result.stdout.strip() or result.stderr.strip() - path_match = re.search(r'Received file written to (.+)', out) - saved = path_match.group(1).strip() if path_match else str(dest) - return f"✅ Received → `{saved}`" + match = re.search(r'(?:Received file written to|Saved to)[:\s]+(.+)', out) + saved = match.group(1).strip() if match else str(dest) + reply = f"✅ Received → `{saved}`" + _send_message(room_id, reply) + return reply -# ── Mesh event tail ─────────────────────────────────────────────────────────── -def _read_cursor() -> int: +# ── Context snapshot ────────────────────────────────────────────────────────── + +def _post_ctx(room_id: str) -> str: try: - return int(CURSOR_FILE.read_text().strip()) + active = json.loads(ACTIVE_JSON.read_text()) if ACTIVE_JSON.exists() else {} except Exception: - return 0 + active = {} + + mesh_tail: list[dict] = [] + mesh_jsonl = MESH_DIR / "context.jsonl" + if mesh_jsonl.exists(): + lines = mesh_jsonl.read_text(errors="replace").splitlines() + for line in lines[-10:]: + try: + mesh_tail.append(json.loads(line)) + except Exception: + pass + + lines_out = ["**Active context:**"] + if active: + for k, v in active.items(): + lines_out.append(f" {k}: {v}") + else: + lines_out.append(" (none)") + + lines_out.append("\n**Recent mesh events:**") + for ev in mesh_tail: + ts = str(ev.get("ts", ""))[:16] + lines_out.append(f" {ts} [{ev.get('type','')}] {ev.get('title','')}") + + text = "\n".join(lines_out) + return _send_message(room_id, text) -def _write_cursor(pos: int) -> None: - CURSOR_FILE.write_text(str(pos)) -def _event_to_matrix_body(event: dict) -> str | None: +# ── Mesh event daemon ───────────────────────────────────────────────────────── + +def _event_to_body(event: dict) -> str | None: ev_type = event.get("type", "") if ev_type not in FORWARD_EVENTS: return None - - ts = event.get("ts", "")[:19].replace("T", " ") - data = event.get("data", {}) + ts = str(event.get("ts", ""))[:16].replace("T", " ") + data = event.get("data", {}) title = event.get("title", ev_type) - if ev_type == "cloudshell-connect": - host = data.get("host", "?") - return f"🔗 **Cloudshell connected** → `{host}` at {ts}" - - if ev_type == "k3s-tunnel-start": - port = data.get("local_port", "16443") - return f"🚇 **k3s tunnel open** → `localhost:{port}` at {ts}" + mapping = { + "cloudshell-connect": lambda: f"🔗 **Cloudshell** → `{data.get('host','?')}` at {ts}", + "k3s-tunnel-start": lambda: f"🚇 **k3s tunnel** → `localhost:{data.get('local_port','16443')}` at {ts}", + "split-view": lambda: f"⧉ **Split view** `{data.get('left','')}` ‖ `{data.get('right','')}` at {ts}", + "error-triage": lambda: f"⚠ **Error triage** `{str(data.get('cmd',''))[:60]}` rc={data.get('rc','?')} at {ts}", + "note-saved": lambda: f"📝 **Note** `{data.get('title', data.get('name',''))}` at {ts}", + "runbook-run": lambda: f"📋 **Runbook** `{data.get('name','')}` at {ts}", + } + fn = mapping.get(ev_type) + return fn() if fn else f"ℹ **{title}** at {ts}" - if ev_type == "split-view": - left = data.get("left", "") - right = data.get("right", "") - return f"⧉ **Split view** opened: `{left}` ‖ `{right}` at {ts}" - if ev_type == "error-triage": - cmd = data.get("cmd", "")[:80] - rc = data.get("rc", "?") - return f"⚠ **Error triage** — `{cmd}` exited {rc} at {ts}" +def _read_cursor() -> int: + try: + return int(CURSOR_FILE.read_text().strip()) + except Exception: + return 0 - if ev_type == "note-saved": - name = data.get("title", data.get("name", "")) - return f"📝 **Note saved:** `{name}` at {ts}" - if ev_type == "runbook-run": - name = data.get("name", "") - return f"📋 **Runbook run:** `{name}` at {ts}" +def _write_cursor(pos: int) -> None: + CURSOR_FILE.write_text(str(pos)) - return f"ℹ **{title}** at {ts}" def _daemon(room_id: str) -> None: - print(f"turtle-matrix-bridge: daemon starting, room={room_id}", flush=True) + print(f"turtle-matrix-bridge: daemon room={room_id}", flush=True) pos = _read_cursor() - + mesh_jsonl = MESH_DIR / "context.jsonl" while True: - if not MESH_JSONL.exists(): + if not mesh_jsonl.exists(): time.sleep(5) continue - - with open(MESH_JSONL) as f: + with open(mesh_jsonl) as f: f.seek(pos) while True: line = f.readline() @@ -287,107 +446,233 @@ def _daemon(room_id: str) -> None: event = json.loads(line.strip()) except json.JSONDecodeError: continue - body = _event_to_matrix_body(event) + body = _event_to_body(event) if body: try: _send_message(room_id, body) - print(f" → forwarded: {event.get('type')}", flush=True) except Exception as exc: - print(f" ⚠ send failed: {exc}", flush=True) - + print(f" ⚠ {exc}", flush=True) time.sleep(2) -# ── CLI ─────────────────────────────────────────────────────────────────────── -def _require_room(argv: list[str], offset: int) -> str: - if len(argv) > offset: - return argv[offset] - if DEFAULT_ROOM_ID: - return DEFAULT_ROOM_ID - print("⚠ No room ID. Set MATRIX_BOT_ROOM_ID or pass as argument.", file=sys.stderr) - sys.exit(1) +# ── CLI arg helpers ─────────────────────────────────────────────────────────── + +def _parse_flags(argv: list[str]) -> tuple[list[str], dict[str, str]]: + """Strip -r/--room and --lang flags, return (positional_args, flags).""" + flags: dict[str, str] = {} + pos: list[str] = [] + i = 0 + while i < len(argv): + if argv[i] in ("-r", "--room") and i + 1 < len(argv): + flags["room"] = argv[i + 1] + i += 2 + elif argv[i] == "--lang" and i + 1 < len(argv): + flags["lang"] = argv[i + 1] + i += 2 + elif argv[i].startswith("--room="): + flags["room"] = argv[i][7:] + i += 1 + elif argv[i].startswith("--lang="): + flags["lang"] = argv[i][7:] + i += 1 + else: + pos.append(argv[i]) + i += 1 + return pos, flags + + +def _room(flags: dict[str, str]) -> str: + r = flags.get("room") or DEFAULT_ROOM_ID + if not r: + print("⚠ No room ID. Set MATRIX_BOT_ROOM_ID or pass -r ", file=sys.stderr) + sys.exit(1) + return r + def _status() -> None: print(f"homeserver : {HOMESERVER_URL}") - print(f"token : {'set' if ACCESS_TOKEN else 'NOT SET'}") + print(f"token : {'set (' + ACCESS_TOKEN[:8] + '…)' if ACCESS_TOKEN else 'NOT SET'}") print(f"room : {DEFAULT_ROOM_ID or 'NOT SET'}") print(f"forward : {', '.join(sorted(FORWARD_EVENTS))}") - print(f"mesh jsonl : {'exists' if MESH_JSONL.exists() else 'missing'}") - print(f"cursor : {_read_cursor()} bytes") - print(f"wormhole : {_wormhole_bin() or 'not found'}") - if ACCESS_TOKEN and HOMESERVER_URL: + print(f"wormhole : {_wormhole_bin() or 'not found — brew install magic-wormhole'}") + print(f"mesh jsonl : {'exists' if (MESH_DIR / 'context.jsonl').exists() else 'missing'}") + pid_file = STATE_DIR / "matrix-bridge.pid" + pid_ok = False + if pid_file.exists(): + try: + pid = int(pid_file.read_text().strip()) + os.kill(pid, 0) + pid_ok = True + except Exception: + pass + print(f"daemon : {'running (pid ' + str(pid) + ')' if pid_ok else 'stopped'}") + if ACCESS_TOKEN: try: - me = _req("GET", "/_matrix/client/v3/account/whoami") + me = _req("GET", "/_matrix/client/v3/account/whoami", timeout=5) print(f"whoami : {me.get('user_id', '?')}") + rooms = _joined_rooms() + print(f"rooms : {len(rooms)} joined") except Exception as exc: print(f"whoami : ⚠ {exc}") + +# ── Main ────────────────────────────────────────────────────────────────────── + def main() -> None: - argv = sys.argv[1:] - if not argv: + if len(sys.argv) < 2: print(__doc__.strip()) sys.exit(0) - cmd = argv[0] + cmd = sys.argv[1] + rest = sys.argv[2:] + pos, flags = _parse_flags(rest) if cmd == "daemon": - room = _require_room(argv, 1) + room = pos[0] if pos else _room(flags) _daemon(room) elif cmd == "send": - if len(argv) < 2: - print("Usage: turtle-matrix-bridge send []", file=sys.stderr) + room = _room(flags) + if pos: + text = " ".join(pos) + elif not sys.stdin.isatty(): + text = sys.stdin.read() + else: + print("Usage: turtle-matrix-bridge send [-r ] []", file=sys.stderr) sys.exit(1) - text = argv[1] - room = _require_room(argv, 2) - eid = _send_message(room, text) - print(f"sent: {eid}") + eid = _send_message(room, text.strip()) + print(eid) + + elif cmd == "pipe": + room = _room(flags) + lang = flags.get("lang", "") + data = sys.stdin.read() + if not lang and not lang: + # Try to detect from first line shebang or content + first = data.lstrip()[:20] + if first.startswith("#!/"): + lang = "bash" + elif first.startswith("{") or first.startswith("["): + lang = "json" + eid = _send_code_block(room, data.strip()[:8000], lang=lang) + print(eid) elif cmd == "send-file": - if len(argv) < 2: - print("Usage: turtle-matrix-bridge send-file []", file=sys.stderr) + if not pos: + print("Usage: turtle-matrix-bridge send-file [-r ] ", file=sys.stderr) sys.exit(1) - path = Path(argv[1]) - room = _require_room(argv, 2) + path = Path(pos[0]) + room = _room(flags) try: content = path.read_text(errors="replace") except Exception as exc: - print(f"⚠ Cannot read {path}: {exc}", file=sys.stderr) + print(f"⚠ {exc}", file=sys.stderr) sys.exit(1) - lang = path.suffix.lstrip(".") or "text" - eid = _send_code_block(room, content[:8000], lang=lang, label=str(path.name)) - print(f"sent: {eid}") + lang = flags.get("lang") or _lang_for(path) + eid = _send_code_block(room, content[:8000], lang=lang, label=path.name) + print(eid) elif cmd == "send-code": - if len(argv) < 3: - print("Usage: turtle-matrix-bridge send-code []", file=sys.stderr) + if len(pos) < 2: + print("Usage: turtle-matrix-bridge send-code [-r ]", file=sys.stderr) + sys.exit(1) + lang, path_str = pos[0], pos[1] + room = _room(flags) + content = Path(path_str).read_text(errors="replace") + eid = _send_code_block(room, content[:8000], lang=lang, label=Path(path_str).name) + print(eid) + + elif cmd == "reply": + if len(pos) < 2: + print("Usage: turtle-matrix-bridge reply [-r ]", file=sys.stderr) sys.exit(1) - lang = argv[1] - path = Path(argv[2]) - room = _require_room(argv, 3) - content = path.read_text(errors="replace") - eid = _send_code_block(room, content[:8000], lang=lang, label=str(path.name)) - print(f"sent: {eid}") + event_id = pos[0] + text = " ".join(pos[1:]) + room = _room(flags) + eid = _send_message(room, text, thread_event_id=event_id) + print(eid) + + elif cmd == "react": + if len(pos) < 2: + print("Usage: turtle-matrix-bridge react [-r ]", file=sys.stderr) + sys.exit(1) + event_id, emoji = pos[0], pos[1] + room = _room(flags) + eid = _send_reaction(room, event_id, emoji) + print(eid) + + elif cmd == "edit": + if len(pos) < 2: + print("Usage: turtle-matrix-bridge edit [-r ]", file=sys.stderr) + sys.exit(1) + event_id = pos[0] + new_text = " ".join(pos[1:]) + room = _room(flags) + eid = _edit_message(room, event_id, new_text) + print(eid) + + elif cmd == "rooms": + try: + rooms = _joined_rooms() + except Exception as exc: + print(f"⚠ {exc}", file=sys.stderr) + sys.exit(1) + if not rooms: + print("(no rooms joined)") + return + for r in rooms: + name = _room_name(r) + label = f" {name}" if name != r else "" + print(f"{r}{label}") + + elif cmd == "messages": + room = _room(flags) + n = int(pos[0]) if pos else 20 + try: + msgs = _room_messages(room, n) + except Exception as exc: + print(f"⚠ {exc}", file=sys.stderr) + sys.exit(1) + for m in msgs: + ts = str(m.get("ts", "")) + try: + import datetime + dt = datetime.datetime.fromtimestamp(int(ts) / 1000) + ts_str = dt.strftime("%H:%M") + except Exception: + ts_str = ts[:5] + sender = m.get("sender", "").split(":")[0].lstrip("@") + print(f"[{ts_str}] <{sender}> {m.get('body','')[:120]}") elif cmd == "wormhole-send": - if len(argv) < 2: - print("Usage: turtle-matrix-bridge wormhole-send []", file=sys.stderr) + if not pos: + print("Usage: turtle-matrix-bridge wormhole-send [-r ] ", file=sys.stderr) sys.exit(1) - path = argv[1] - room = _require_room(argv, 2) - code = _wormhole_send(path, room) + room = _room(flags) + code = _wormhole_send_path(pos[0], room) + print(code) + + elif cmd == "wormhole-pipe": + room = _room(flags) + if sys.stdin.isatty(): + print("⚠ wormhole-pipe reads from stdin (pipe something in)", file=sys.stderr) + sys.exit(1) + code = _wormhole_send_stdin(room) print(code) elif cmd == "wormhole-recv": - if len(argv) < 2: - print("Usage: turtle-matrix-bridge wormhole-recv []", file=sys.stderr) + if not pos: + print("Usage: turtle-matrix-bridge wormhole-recv [-r ] ", file=sys.stderr) sys.exit(1) - code = argv[1] - room = _require_room(argv, 2) - result = _wormhole_recv(code, room) - _send_message(room, result) + room = _room(flags) + result = _wormhole_recv(pos[0], room) print(result) + elif cmd == "ctx": + room = _room(flags) + eid = _post_ctx(room) + print(eid) + elif cmd == "status": _status() @@ -395,5 +680,6 @@ def main() -> None: print(f"Unknown command: {cmd}", file=sys.stderr) sys.exit(1) + if __name__ == "__main__": main() diff --git a/assets/sourceos/bin/turtle-mesh-serve b/assets/sourceos/bin/turtle-mesh-serve index bc6b37dd0ff..cc62dc8c354 100755 --- a/assets/sourceos/bin/turtle-mesh-serve +++ b/assets/sourceos/bin/turtle-mesh-serve @@ -26,6 +26,7 @@ import queue import sys import threading import time +import urllib.parse import urllib.request from http.server import BaseHTTPRequestHandler, HTTPServer from pathlib import Path @@ -99,6 +100,69 @@ def _matrix_bridge_alive() -> bool: return False +def _matrix_config() -> dict: + """Read Matrix config from env or ~/.config/sourceos/matrix.yaml.""" + yaml_path = Path.home() / ".config" / "sourceos" / "matrix.yaml" + yaml: dict = {} + if yaml_path.exists(): + try: + import re as _re + with open(yaml_path) as f: + for line in f: + line = line.strip() + m = _re.match(r'^(\w[\w_]*):\s*(.+)$', line) + if m: + yaml[m.group(1)] = m.group(2).strip('"\'') + except Exception: + pass + + def _cfg(env_key: str, yaml_key: str, default: str = "") -> str: + return os.environ.get(env_key) or yaml.get(yaml_key) or yaml.get(env_key) or default + + return { + "homeserver_url": _cfg("MATRIX_HOMESERVER_URL", "homeserver_url", "").rstrip("/"), + "access_token": _cfg("MATRIX_ACCESS_TOKEN", "access_token", ""), + "room_id": _cfg("MATRIX_BOT_ROOM_ID", "room_id", ""), + } + + +def _matrix_recent_messages(n: int = 8) -> list[dict]: + """Fetch the last n messages from the configured Matrix room via CS API. + + Returns list of {sender, body, ts} dicts. Returns [] on any error. + Uses urllib.request with 3s timeout. No new imports needed. + """ + try: + cfg = _matrix_config() + homeserver = cfg.get("homeserver_url", "") + token = cfg.get("access_token", "") + room_id = cfg.get("room_id", "") + if not (homeserver and token and room_id): + return [] + encoded_room = urllib.parse.quote(room_id, safe="") + url = f"{homeserver}/_matrix/client/v3/rooms/{encoded_room}/messages?dir=b&limit={n}" + req = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}"}) + with urllib.request.urlopen(req, timeout=3) as resp: + data = json.loads(resp.read().decode()) + messages = [] + for ev in data.get("chunk", []): + if ev.get("type") != "m.room.message": + continue + content = ev.get("content", {}) + if content.get("msgtype") != "m.text": + continue + messages.append({ + "sender": ev.get("sender", ""), + "body": content.get("body", ""), + "ts": ev.get("origin_server_ts"), + }) + # chunk is newest-first (dir=b); reverse so newest-last + messages.reverse() + return messages + except Exception: + return [] + + def gather_state() -> dict: mesh = load_jsonl_tail(MESH_DIR / "context.jsonl", 60) active = load_json(MESH_DIR / "active.json") @@ -154,6 +218,8 @@ def gather_state() -> dict: "k3s_tunnel_up": _k3s_tunnel_alive(), "matrix_bridge_up": _matrix_bridge_alive(), "runbooks": runbooks, + "matrix_messages": _matrix_recent_messages(8), + "matrix_room_id": _matrix_config().get("room_id", ""), } @@ -320,6 +386,10 @@ h2{font-size:12px;color:var(--dim);text-transform:uppercase;letter-spacing:.08em

Runbooks

+
+

Matrix Room

+
+
@@ -410,6 +480,22 @@ function render(state) { document.getElementById('runbooks-list').innerHTML = rbs.length ? rbs.map(r => `${esc(r.name)}`).join('') : 'none yet — try: rb new deploy' + + // Matrix messages panel + const msgs = state.matrix_messages || [] + const roomLabel = state.matrix_room_id || '' + document.getElementById('matrix-room-label').textContent = roomLabel ? roomLabel.split(':')[0].replace('!','') : '' + const msgsEl = document.getElementById('matrix-messages') + if (msgs.length === 0) { + msgsEl.innerHTML = 'No messages — configure MATRIX_BOT_ROOM_ID' + } else { + msgsEl.innerHTML = msgs.map(m => { + const sender = esc(m.sender.split(':')[0].replace('@','')) + const body = esc((m.body || '').slice(0, 120)) + const ts = m.ts ? new Date(m.ts).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'}) : '' + return `
${sender} ${ts}
${body}
` + }).join('') + } } // SSE connection with auto-reconnect @@ -729,6 +815,37 @@ class DashHandler(BaseHTTPRequestHandler): self.end_headers() self.wfile.write(body) + elif self.path == "/api/matrix/rooms": + try: + cfg = _matrix_config() + homeserver = cfg.get("homeserver_url", "") + token = cfg.get("access_token", "") + if not (homeserver and token): + raise ValueError("Matrix not configured") + url = f"{homeserver}/_matrix/client/v3/joined_rooms" + req = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}"}) + with urllib.request.urlopen(req, timeout=5) as resp: + payload = json.loads(resp.read().decode()) + body = json.dumps(payload).encode() + except Exception as exc: + body = json.dumps({"rooms": [], "error": str(exc)}).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + elif self.path == "/api/matrix/messages": + payload = {"messages": _matrix_recent_messages(20)} + body = json.dumps(payload, default=str).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + elif self.path == "/events": self.send_response(200) self.send_header("Content-Type", "text/event-stream") diff --git a/assets/sourceos/shell/turtle-shell-init.zsh b/assets/sourceos/shell/turtle-shell-init.zsh index 704b64635a4..283c0ab1c19 100644 --- a/assets/sourceos/shell/turtle-shell-init.zsh +++ b/assets/sourceos/shell/turtle-shell-init.zsh @@ -1968,43 +1968,85 @@ _turtle_matrix_bridge_bin() { printf '%s' "$(dirname "${(%):-%x}")/turtle-matrix-bridge" } -# mx [room_id] — post a plain text message to the default Matrix room +# mx [-r ] [] +# Post text to Matrix room. Reads stdin when piped: cmd | mx +# Examples: mx "deploy done" • k3s get pods | mx • mx -r !ops:srv hi mx() { - if [[ -z "$1" ]]; then - printf 'Usage: mx [room_id]\n' >&2 + local _bin; _bin="$(_turtle_matrix_bridge_bin)" + if [[ ! -t 0 ]]; then + python3 "$_bin" send "$@" + elif [[ -n "$1" ]]; then + python3 "$_bin" send "$@" + else + printf 'Usage: mx [-r ] | cmd | mx\n' >&2 return 1 fi - python3 "$(_turtle_matrix_bridge_bin)" send "$@" } -# mxf [room_id] — post a file as a code block to the Matrix room +# mxf [-r ] [--lang ] +# Post a file as a syntax-highlighted code block mxf() { - if [[ -z "$1" ]]; then - printf 'Usage: mxf [room_id]\n' >&2 - return 1 - fi + [[ -z "$1" ]] && { printf 'Usage: mxf [-r ] \n' >&2; return 1; } python3 "$(_turtle_matrix_bridge_bin)" send-file "$@" } -# mxw [room_id] — wormhole-send a file and post the code to the Matrix room +# mxpipe [-r ] [--lang ] +# Pipe stdin as a code block: k3s get pods | mxpipe --lang yaml +mxpipe() { python3 "$(_turtle_matrix_bridge_bin)" pipe "$@" } + +# mxw [-r ] +# Wormhole-send a file; posts the wormhole receive code to the Matrix room +# Also works with stdin: k3s logs mypod | mxw (no file arg = stdin mode) mxw() { - if [[ -z "$1" ]]; then - printf 'Usage: mxw [room_id]\n' >&2 + local _bin; _bin="$(_turtle_matrix_bridge_bin)" + if [[ ! -t 0 ]] && [[ -z "$1" || "$1" == -* ]]; then + python3 "$_bin" wormhole-pipe "$@" + elif [[ -n "$1" ]]; then + python3 "$_bin" wormhole-send "$@" + else + printf 'Usage: mxw [-r ] | cmd | mxw\n' >&2 return 1 fi - python3 "$(_turtle_matrix_bridge_bin)" wormhole-send "$@" } -# mxr [room_id] — wormhole-receive by code, post result to Matrix room +# mxr [-r ] +# Receive a wormhole transfer and post the saved path to the room mxr() { - if [[ -z "$1" ]]; then - printf 'Usage: mxr [room_id]\n' >&2 - return 1 - fi + [[ -z "$1" ]] && { printf 'Usage: mxr [-r ] \n' >&2; return 1; } python3 "$(_turtle_matrix_bridge_bin)" wormhole-recv "$@" } -# mxstatus — show Matrix bridge config + connectivity +# mxreply [-r ] +# Send a threaded reply to a specific Matrix event +mxreply() { + [[ $# -lt 2 ]] && { printf 'Usage: mxreply [-r ] \n' >&2; return 1; } + python3 "$(_turtle_matrix_bridge_bin)" reply "$@" +} + +# mxreact [-r ] +# Send an emoji reaction to a Matrix event +mxreact() { + [[ $# -lt 2 ]] && { printf 'Usage: mxreact [-r ] \n' >&2; return 1; } + python3 "$(_turtle_matrix_bridge_bin)" react "$@" +} + +# mxedit [-r ] +# Edit (replace) a Matrix message you sent +mxedit() { + [[ $# -lt 2 ]] && { printf 'Usage: mxedit [-r ] \n' >&2; return 1; } + python3 "$(_turtle_matrix_bridge_bin)" edit "$@" +} + +# mxrooms — list joined Matrix rooms with names +mxrooms() { python3 "$(_turtle_matrix_bridge_bin)" rooms } + +# mxlog [-r ] [] — print last n Matrix messages from the room (default 20) +mxlog() { python3 "$(_turtle_matrix_bridge_bin)" messages "$@" } + +# mxctx [-r ] — post current active context + mesh tail to Matrix room +mxctx() { python3 "$(_turtle_matrix_bridge_bin)" ctx "$@" } + +# mxstatus — show Matrix bridge config + connectivity mxstatus() { python3 "$(_turtle_matrix_bridge_bin)" status } # Auto-start Matrix bridge daemon at shell init if config is present diff --git a/assets/sourceos/turtleterm.lua b/assets/sourceos/turtleterm.lua index 160a3878421..4223f1d8ad3 100644 --- a/assets/sourceos/turtleterm.lua +++ b/assets/sourceos/turtleterm.lua @@ -1455,10 +1455,15 @@ local PALETTE_COMMANDS = { { label = '☁ k3s tunnel start (ktunnel) —', id = 'k3s_tunnel' }, { label = '☁ CloudShell status (csh-status) —', id = 'cloudshell_status' }, -- Matrix bridge - { label = '💬 Matrix: send message (mx) CMD+SHIFT+ALT+M', id = 'matrix_send' }, - { label = '💬 Matrix: send file (mxf) —', id = 'matrix_send_file' }, - { label = '🕳 Matrix: wormhole send (mxw) —', id = 'matrix_wormhole' }, - { label = '💬 Matrix: bridge status (mxstatus) —', id = 'matrix_status' }, + { label = '💬 Matrix: send message (mx) CMD+SHIFT+ALT+M', id = 'matrix_send' }, + { label = '💬 Matrix: pipe output (| mxpipe) —', id = 'matrix_pipe' }, + { label = '💬 Matrix: send file (mxf) —', id = 'matrix_send_file'}, + { label = '🕳 Matrix: wormhole send (mxw) CMD+SHIFT+ALT+W', id = 'matrix_wormhole' }, + { label = '🕳 Matrix: wormhole pipe (| mxw) —', id = 'matrix_wh_pipe' }, + { label = '💬 Matrix: list rooms (mxrooms) —', id = 'matrix_rooms' }, + { label = '💬 Matrix: recent log (mxlog) —', id = 'matrix_log' }, + { label = '💬 Matrix: post context (mxctx) —', id = 'matrix_ctx' }, + { label = '💬 Matrix: bridge status (mxstatus) —', id = 'matrix_status' }, } local function turtle_command_palette() @@ -1564,8 +1569,13 @@ local function turtle_command_palette() k3s_tunnel = act.SendString('ktunnel start\n'), cloudshell_status = act.SendString('csh-status\n'), matrix_send = act.SendString('mx '), + matrix_pipe = act.SendString('mxpipe'), matrix_send_file = act.SendString('mxf '), matrix_wormhole = act.SendString('mxw '), + matrix_wh_pipe = act.SendString('mxw'), + matrix_rooms = act.SendString('mxrooms\n'), + matrix_log = act.SendString('mxlog\n'), + matrix_ctx = act.SendString('mxctx\n'), matrix_status = act.SendString('mxstatus\n'), } local a = dispatch[id]