mirror of
https://github.com/prdlk/vortex.git
synced 2026-08-02 17:31:41 +00:00
feat: unified messaging TUI MVP — Synapse + 7 mautrix bridges + Textual TUI + MCP draft staging
One-command local stack: cp .env.example .env && make up. Idempotent bootstrap renders Synapse + bridge configs from pristine upstream examples, provisions databases, registrations, double puppeting, and the admin user. Textual TUI with bridge manager, in-terminal QR login, and drafts panel. MCP server exposes read tools and a human-gated draft send workflow. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
"""Vortex TUI - unified Matrix messaging client."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,53 @@
|
||||
"""SQLite drafts store, shared with other vortex components via DRAFTS_DB."""
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS drafts(
|
||||
draft_id TEXT PRIMARY KEY,
|
||||
room_id TEXT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
reply_to_event_id TEXT,
|
||||
created_by TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'draft',
|
||||
sent_event_id TEXT
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
def connect(path: str | None = None) -> sqlite3.Connection:
|
||||
db = Path(path or os.environ.get("DRAFTS_DB", "/shared/drafts/drafts.db"))
|
||||
db.parent.mkdir(parents=True, exist_ok=True)
|
||||
con = sqlite3.connect(db)
|
||||
con.row_factory = sqlite3.Row
|
||||
con.execute("PRAGMA journal_mode=WAL")
|
||||
con.execute(SCHEMA)
|
||||
con.commit()
|
||||
return con
|
||||
|
||||
|
||||
def pending(con: sqlite3.Connection) -> list[sqlite3.Row]:
|
||||
return con.execute(
|
||||
"SELECT * FROM drafts WHERE status='draft' ORDER BY created_at"
|
||||
).fetchall()
|
||||
|
||||
|
||||
def update_body(con: sqlite3.Connection, draft_id: str, body: str) -> None:
|
||||
con.execute("UPDATE drafts SET body=? WHERE draft_id=?", (body, draft_id))
|
||||
con.commit()
|
||||
|
||||
|
||||
def mark(
|
||||
con: sqlite3.Connection,
|
||||
draft_id: str,
|
||||
status: str,
|
||||
sent_event_id: str | None = None,
|
||||
) -> None:
|
||||
con.execute(
|
||||
"UPDATE drafts SET status=?, sent_event_id=? WHERE draft_id=?",
|
||||
(status, sent_event_id, draft_id),
|
||||
)
|
||||
con.commit()
|
||||
@@ -0,0 +1,369 @@
|
||||
"""Vortex TUI application: room list, timeline, bridges, drafts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from nio import (
|
||||
AsyncClient,
|
||||
MatrixRoom,
|
||||
RoomCreateResponse,
|
||||
RoomMessageMedia,
|
||||
RoomMessageNotice,
|
||||
RoomMessageText,
|
||||
SyncResponse,
|
||||
)
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.binding import Binding
|
||||
from textual.containers import Horizontal, Vertical
|
||||
from textual.screen import Screen
|
||||
from textual.widgets import Footer, Input, Label, ListItem, ListView, RichLog, Static
|
||||
|
||||
from . import drafts as drafts_db
|
||||
from .qr import looks_like_qr_payload, render_qr
|
||||
from .session import build_client, load_credentials
|
||||
|
||||
NETWORKS = ["gmessages", "telegram", "whatsapp", "twitter", "linkedin", "discord", "meta"]
|
||||
LABELS = {
|
||||
"gmessages": "gm",
|
||||
"telegram": "tg",
|
||||
"whatsapp": "wa",
|
||||
"twitter": "tw",
|
||||
"linkedin": "li",
|
||||
"discord": "dc",
|
||||
"meta": "ig",
|
||||
"matrix": "mx",
|
||||
}
|
||||
|
||||
|
||||
class BridgeScreen(Screen[str | None]):
|
||||
"""List the known bridge bots; Enter opens or creates the bot DM."""
|
||||
|
||||
BINDINGS = [Binding("escape", "back", "Back")]
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Static(
|
||||
"Bridges - Enter opens (or creates) the bot DM, then send it a login "
|
||||
"command there (e.g. '!wa help', 'login', 'help').",
|
||||
id="bridge-help",
|
||||
)
|
||||
yield ListView(id="bridge-list")
|
||||
yield Footer()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
app: VortexApp = self.app
|
||||
lv = self.query_one("#bridge-list", ListView)
|
||||
for net in NETWORKS:
|
||||
bot = f"@{net}bot:{app.domain}"
|
||||
dm = app.find_bot_dm(bot)
|
||||
status = "DM open" if dm else "no DM (Enter creates one)"
|
||||
item = ListItem(Label(f"[{LABELS[net]}] {net:<10} {bot:<32} {status}"))
|
||||
item.bot, item.dm = bot, dm
|
||||
lv.append(item)
|
||||
lv.focus()
|
||||
|
||||
async def on_list_view_selected(self, event: ListView.Selected) -> None:
|
||||
event.stop()
|
||||
item = event.item
|
||||
if item.dm:
|
||||
self.dismiss(item.dm)
|
||||
return
|
||||
resp = await self.app.client.room_create(invite=[item.bot], is_direct=True)
|
||||
if isinstance(resp, RoomCreateResponse):
|
||||
self.dismiss(resp.room_id)
|
||||
else:
|
||||
self.notify(f"room_create failed: {resp}", severity="error")
|
||||
|
||||
def action_back(self) -> None:
|
||||
self.dismiss(None)
|
||||
|
||||
|
||||
class DraftsScreen(Screen):
|
||||
"""Pending drafts from the shared SQLite DB, polled every 2s."""
|
||||
|
||||
BINDINGS = [
|
||||
Binding("escape", "back", "Back"),
|
||||
Binding("e", "edit", "Edit"),
|
||||
Binding("s", "send", "Send"),
|
||||
Binding("x", "discard", "Discard"),
|
||||
]
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Static("Drafts - e edit, s send, x discard, esc back", id="drafts-help")
|
||||
yield ListView(id="draft-list")
|
||||
yield Input(placeholder="Edit draft body, Enter saves", id="draft-edit")
|
||||
yield Footer()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.con = drafts_db.connect()
|
||||
self._editing: str | None = None
|
||||
self.query_one("#draft-edit", Input).display = False
|
||||
self.call_later(self._refresh)
|
||||
# ponytail: 2s poll instead of watchfiles; watch the db file if this chafes
|
||||
self.set_interval(2, self._refresh)
|
||||
self.query_one("#draft-list", ListView).focus()
|
||||
|
||||
async def _refresh(self) -> None:
|
||||
app: VortexApp = self.app
|
||||
lv = self.query_one("#draft-list", ListView)
|
||||
idx = lv.index
|
||||
items = []
|
||||
for row in drafts_db.pending(self.con):
|
||||
room = app.client.rooms.get(row["room_id"]) if app.client else None
|
||||
name = (room.display_name if room else row["room_id"])[:24]
|
||||
body = row["body"].replace("\n", " ")[:48]
|
||||
item = ListItem(
|
||||
Label(f"{name:<24} | {body:<48} | {row['created_by']} @ {row['created_at']}")
|
||||
)
|
||||
item.draft = dict(row)
|
||||
items.append(item)
|
||||
await lv.clear()
|
||||
await lv.extend(items)
|
||||
if idx is not None and items:
|
||||
lv.index = min(idx, len(items) - 1)
|
||||
|
||||
def _current(self) -> dict | None:
|
||||
item = self.query_one("#draft-list", ListView).highlighted_child
|
||||
return getattr(item, "draft", None)
|
||||
|
||||
def action_edit(self) -> None:
|
||||
draft = self._current()
|
||||
if not draft:
|
||||
return
|
||||
self._editing = draft["draft_id"]
|
||||
box = self.query_one("#draft-edit", Input)
|
||||
box.value = draft["body"]
|
||||
box.display = True
|
||||
box.focus()
|
||||
|
||||
def on_input_submitted(self, event: Input.Submitted) -> None:
|
||||
event.stop()
|
||||
if event.input.id != "draft-edit" or not self._editing:
|
||||
return
|
||||
drafts_db.update_body(self.con, self._editing, event.value)
|
||||
self._editing = None
|
||||
event.input.display = False
|
||||
self.query_one("#draft-list", ListView).focus()
|
||||
self.call_later(self._refresh)
|
||||
|
||||
async def action_send(self) -> None:
|
||||
draft = self._current()
|
||||
app: VortexApp = self.app
|
||||
if not draft or not app.client:
|
||||
return
|
||||
resp = await app.client.room_send(
|
||||
draft["room_id"],
|
||||
"m.room.message",
|
||||
{"msgtype": "m.text", "body": draft["body"]},
|
||||
ignore_unverified_devices=True,
|
||||
)
|
||||
drafts_db.mark(self.con, draft["draft_id"], "sent", getattr(resp, "event_id", None))
|
||||
await self._refresh()
|
||||
|
||||
def action_discard(self) -> None:
|
||||
draft = self._current()
|
||||
if draft:
|
||||
drafts_db.mark(self.con, draft["draft_id"], "discarded")
|
||||
self.call_later(self._refresh)
|
||||
|
||||
def action_back(self) -> None:
|
||||
self.con.close()
|
||||
self.dismiss()
|
||||
|
||||
|
||||
class VortexApp(App):
|
||||
"""Three panes: room list | timeline over input, plus footer bindings."""
|
||||
|
||||
CSS = """
|
||||
#rooms { width: 36; border-right: solid $accent; }
|
||||
#timeline { height: 1fr; }
|
||||
#bridge-help, #drafts-help { padding: 0 1; color: $text-muted; }
|
||||
"""
|
||||
TITLE = "Vortex"
|
||||
SUB_TITLE = "/reply <text> replies to the last message in the room"
|
||||
BINDINGS = [
|
||||
Binding("q", "quit", "Quit"),
|
||||
Binding("b", "bridges", "Bridges"),
|
||||
Binding("d", "drafts", "Drafts"),
|
||||
Binding("tab", "focus_next", "Cycle focus"),
|
||||
]
|
||||
|
||||
def __init__(self, creds: dict):
|
||||
super().__init__()
|
||||
self.creds = creds
|
||||
self.client: AsyncClient | None = None
|
||||
self.domain = creds["user_id"].split(":", 1)[1]
|
||||
self.selected_room: str | None = None
|
||||
self.pending_select: str | None = None
|
||||
self.timelines: dict[str, list[str]] = {}
|
||||
self.last_event: dict[str, str] = {}
|
||||
self.unread: dict[str, int] = {}
|
||||
self.first_sync = False
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Horizontal():
|
||||
yield ListView(id="rooms")
|
||||
with Vertical():
|
||||
yield RichLog(id="timeline", wrap=True, markup=False, highlight=False)
|
||||
yield Input(
|
||||
placeholder="Message (Enter sends, /reply <text> replies)", id="msg"
|
||||
)
|
||||
yield Footer()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.run_worker(self._connect_and_sync(), exclusive=True)
|
||||
|
||||
# ---- matrix -----------------------------------------------------------
|
||||
async def _connect_and_sync(self) -> None:
|
||||
log = self.query_one("#timeline", RichLog)
|
||||
log.write("Connecting...")
|
||||
try:
|
||||
self.client = await build_client(self.creds)
|
||||
except Exception as exc: # surface login problems in the UI
|
||||
log.write(f"Login failed: {exc}")
|
||||
return
|
||||
self.client.add_event_callback(
|
||||
self._on_event, (RoomMessageText, RoomMessageNotice, RoomMessageMedia)
|
||||
)
|
||||
self.client.add_response_callback(self._on_sync, SyncResponse)
|
||||
log.write(
|
||||
f"Logged in as {self.client.user_id} ({self.client.device_id}). Syncing..."
|
||||
)
|
||||
await self.client.sync_forever(timeout=30000, full_state=True)
|
||||
|
||||
async def _on_sync(self, _response) -> None:
|
||||
self.first_sync = True
|
||||
await self._refresh_rooms()
|
||||
if self.pending_select and self.pending_select in self.client.rooms:
|
||||
rid, self.pending_select = self.pending_select, None
|
||||
self._select_room(rid)
|
||||
|
||||
async def _on_event(self, room: MatrixRoom, event) -> None:
|
||||
line = self._format_event(room, event)
|
||||
self.timelines.setdefault(room.room_id, []).append(line)
|
||||
self.last_event[room.room_id] = event.event_id
|
||||
if room.room_id == self.selected_room:
|
||||
self.query_one("#timeline", RichLog).write(line)
|
||||
elif self.first_sync:
|
||||
self.unread[room.room_id] = self.unread.get(room.room_id, 0) + 1
|
||||
await self._refresh_rooms()
|
||||
|
||||
def _format_event(self, room: MatrixRoom, event) -> str:
|
||||
sender = room.user_name(event.sender) or event.sender
|
||||
if isinstance(event, RoomMessageMedia):
|
||||
path = (event.url or "")[len("mxc://"):]
|
||||
url = f"{self.client.homeserver}/_matrix/media/v3/download/{path}"
|
||||
return f"<{sender}> [file] {event.body}: {url}"
|
||||
body = event.body or ""
|
||||
is_bot = any(event.sender == f"@{n}bot:{self.domain}" for n in NETWORKS)
|
||||
if looks_like_qr_payload(body, is_bot):
|
||||
return f"<{sender}> QR for {body}\n{render_qr(body)}"
|
||||
return f"<{sender}> {body}"
|
||||
|
||||
# ---- rooms ------------------------------------------------------------
|
||||
def room_network(self, room: MatrixRoom) -> str:
|
||||
for uid in room.users:
|
||||
for net in NETWORKS:
|
||||
if uid.startswith(f"@{net}_") or uid == f"@{net}bot:{self.domain}":
|
||||
return net
|
||||
return "matrix"
|
||||
|
||||
def find_bot_dm(self, bot: str) -> str | None:
|
||||
# ponytail: "DM" = bot is a member and room has <=2 members
|
||||
for room in self.client.rooms.values():
|
||||
if bot in room.users and len(room.users) <= 2:
|
||||
return room.room_id
|
||||
return None
|
||||
|
||||
def _room_label(self, room: MatrixRoom) -> str:
|
||||
n = self.unread.get(room.room_id, 0)
|
||||
badge = f" ({n})" if n else ""
|
||||
return f"[{LABELS[self.room_network(room)]}] {room.display_name or room.room_id}{badge}"
|
||||
|
||||
async def _refresh_rooms(self) -> None:
|
||||
lv = self.query_one("#rooms", ListView)
|
||||
rooms = sorted(
|
||||
self.client.rooms.values(),
|
||||
key=lambda r: (self.room_network(r), (r.display_name or r.room_id).lower()),
|
||||
)
|
||||
items = []
|
||||
for room in rooms:
|
||||
item = ListItem(Label(self._room_label(room)))
|
||||
item.room_id = room.room_id
|
||||
items.append(item)
|
||||
idx = next(
|
||||
(i for i, r in enumerate(rooms) if r.room_id == self.selected_room), None
|
||||
)
|
||||
await lv.clear()
|
||||
await lv.extend(items)
|
||||
if idx is not None:
|
||||
lv.index = idx
|
||||
|
||||
def on_list_view_selected(self, event: ListView.Selected) -> None:
|
||||
rid = getattr(event.item, "room_id", None)
|
||||
if rid:
|
||||
self._select_room(rid)
|
||||
|
||||
def _select_room(self, rid: str) -> None:
|
||||
self.selected_room = rid
|
||||
self.unread.pop(rid, None)
|
||||
log = self.query_one("#timeline", RichLog)
|
||||
log.clear()
|
||||
for line in self.timelines.get(rid, []):
|
||||
log.write(line)
|
||||
room = self.client.rooms.get(rid) if self.client else None
|
||||
if room: # update badge in place, no full rebuild
|
||||
for item in self.query_one("#rooms", ListView).children:
|
||||
if getattr(item, "room_id", None) == rid:
|
||||
item.query_one(Label).update(self._room_label(room))
|
||||
self.query_one("#msg", Input).focus()
|
||||
|
||||
# ---- sending ----------------------------------------------------------
|
||||
async def on_input_submitted(self, event: Input.Submitted) -> None:
|
||||
if event.input.id != "msg":
|
||||
return
|
||||
text = event.value.strip()
|
||||
event.input.value = ""
|
||||
if not text or not self.client or not self.selected_room:
|
||||
return
|
||||
content: dict = {"msgtype": "m.text", "body": text}
|
||||
if text.startswith("/reply "):
|
||||
content["body"] = text[len("/reply "):]
|
||||
last = self.last_event.get(self.selected_room)
|
||||
if last:
|
||||
content["m.relates_to"] = {"m.in_reply_to": {"event_id": last}}
|
||||
await self.client.room_send(
|
||||
self.selected_room,
|
||||
"m.room.message",
|
||||
content,
|
||||
ignore_unverified_devices=True,
|
||||
)
|
||||
|
||||
# ---- actions ----------------------------------------------------------
|
||||
def action_bridges(self) -> None:
|
||||
if self.client:
|
||||
self.push_screen(BridgeScreen(), self._after_bridge)
|
||||
|
||||
def _after_bridge(self, room_id: str | None) -> None:
|
||||
if not room_id:
|
||||
return
|
||||
if self.client and room_id in self.client.rooms:
|
||||
self._select_room(room_id)
|
||||
else:
|
||||
self.pending_select = room_id
|
||||
self.notify("DM created - it will open once it syncs.")
|
||||
|
||||
def action_drafts(self) -> None:
|
||||
self.push_screen(DraftsScreen())
|
||||
|
||||
async def action_quit(self) -> None:
|
||||
if self.client:
|
||||
await self.client.close()
|
||||
self.exit()
|
||||
|
||||
|
||||
def run() -> None:
|
||||
creds = load_credentials()
|
||||
VortexApp(creds).run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Render QR-ish message payloads as unicode half-block QR codes."""
|
||||
|
||||
import qrcode
|
||||
|
||||
QR_SCHEMES = ("https://wa.me/", "gmessages://")
|
||||
|
||||
|
||||
def looks_like_qr_payload(body: str, sender_is_bridge_bot: bool) -> bool:
|
||||
"""Deliberately simple heuristic:
|
||||
|
||||
- message starts with a known pairing scheme (whatsapp / gmessages), or
|
||||
- a bridge-bot message that is a single URL/token: 20-199 chars, no
|
||||
whitespace, and either contains '://' or is alnum-ish (pairing code).
|
||||
"""
|
||||
body = body.strip()
|
||||
if body.startswith(QR_SCHEMES):
|
||||
return True
|
||||
if sender_is_bridge_bot and 20 <= len(body) < 200 and not any(c.isspace() for c in body):
|
||||
return "://" in body or body.replace("-", "").replace("_", "").isalnum()
|
||||
return False
|
||||
|
||||
|
||||
def render_qr(text: str) -> str:
|
||||
"""QR code as text, two matrix rows per output line via half blocks."""
|
||||
qr = qrcode.QRCode(border=1)
|
||||
qr.add_data(text)
|
||||
qr.make(fit=True)
|
||||
matrix = qr.get_matrix()
|
||||
if len(matrix) % 2:
|
||||
matrix.append([False] * len(matrix[0]))
|
||||
chars = {(True, True): "█", (True, False): "▀", (False, True): "▄", (False, False): " "}
|
||||
return "\n".join(
|
||||
"".join(chars[(top[x], bottom[x])] for x in range(len(top)))
|
||||
for top, bottom in zip(matrix[::2], matrix[1::2])
|
||||
)
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Credentials loading and Matrix client construction."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from nio import AsyncClient, AsyncClientConfig, LoginResponse
|
||||
|
||||
|
||||
def store_dir() -> Path:
|
||||
return Path(os.environ.get("STORE_DIR", "/shared/tui-store"))
|
||||
|
||||
|
||||
def load_credentials(retries: int = 10, delay: float = 3.0) -> dict:
|
||||
"""Read CREDENTIALS_FILE, waiting a bit in case bootstrap is still provisioning."""
|
||||
path = Path(os.environ.get("CREDENTIALS_FILE", "/shared/credentials.json"))
|
||||
for attempt in range(retries):
|
||||
try:
|
||||
return json.loads(path.read_text())
|
||||
except (OSError, json.JSONDecodeError):
|
||||
if attempt < retries - 1:
|
||||
print(
|
||||
f"vortex-tui: waiting for {path} (bootstrap still provisioning?)...",
|
||||
file=sys.stderr,
|
||||
)
|
||||
time.sleep(delay)
|
||||
sys.exit(
|
||||
f"vortex-tui: credentials file {path} missing or invalid after "
|
||||
f"{retries} attempts. Run the bootstrap first, or set CREDENTIALS_FILE."
|
||||
)
|
||||
|
||||
|
||||
async def build_client(creds: dict) -> AsyncClient:
|
||||
"""Return a logged-in AsyncClient with its own persisted E2EE device.
|
||||
|
||||
Order: STORE_DIR/session.json (our own device) -> password login (persist
|
||||
new device) -> bootstrap access_token fallback.
|
||||
"""
|
||||
store = store_dir()
|
||||
store.mkdir(parents=True, exist_ok=True)
|
||||
homeserver = (
|
||||
os.environ.get("MATRIX_HOMESERVER")
|
||||
or creds.get("homeserver")
|
||||
or "http://localhost:8008"
|
||||
)
|
||||
user_id = creds["user_id"]
|
||||
# encryption_enabled defaults to True only when olm is importable,
|
||||
# so this works with or without the [e2e] extra installed.
|
||||
client = AsyncClient(
|
||||
homeserver,
|
||||
user_id,
|
||||
store_path=str(store),
|
||||
config=AsyncClientConfig(store_sync_tokens=True),
|
||||
)
|
||||
session_file = store / "session.json"
|
||||
if session_file.exists():
|
||||
s = json.loads(session_file.read_text())
|
||||
client.restore_login(s["user_id"], s["device_id"], s["access_token"])
|
||||
return client
|
||||
if creds.get("password"):
|
||||
resp = await client.login(creds["password"], device_name="vortex-tui")
|
||||
if isinstance(resp, LoginResponse):
|
||||
session_file.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"user_id": resp.user_id,
|
||||
"device_id": resp.device_id,
|
||||
"access_token": resp.access_token,
|
||||
}
|
||||
)
|
||||
)
|
||||
return client
|
||||
if creds.get("access_token"):
|
||||
# Fall back to the bootstrap token (shares the bootstrap device).
|
||||
client.restore_login(
|
||||
user_id, creds.get("device_id") or "VORTEXTUI", creds["access_token"]
|
||||
)
|
||||
return client
|
||||
await client.close()
|
||||
raise RuntimeError("no usable password or access_token in credentials file")
|
||||
Reference in New Issue
Block a user