9 changed files with 937 additions and 99 deletions
@ -0,0 +1,76 @@
@@ -0,0 +1,76 @@
|
||||
"""NIP-11 relay information document (HTTP JSON) for display names and icons.""" |
||||
|
||||
from __future__ import annotations |
||||
|
||||
import json |
||||
import logging |
||||
import re |
||||
from typing import Any, cast |
||||
from urllib.error import HTTPError, URLError |
||||
from urllib.parse import urljoin |
||||
from urllib.request import Request, urlopen |
||||
|
||||
log = logging.getLogger(__name__) |
||||
|
||||
|
||||
def ws_to_http_base(ws_url: str) -> str: |
||||
"""``wss://host/path`` → ``https://host/path`` (NIP-11 is HTTP on the same host).""" |
||||
u = ws_url.strip() |
||||
if u.lower().startswith("ws://"): |
||||
return "http://" + u[5:] |
||||
if u.lower().startswith("wss://"): |
||||
return "https://" + u[6:] |
||||
if u.lower().startswith("https://"): |
||||
return u |
||||
if u.lower().startswith("http://"): |
||||
return u |
||||
return "https://" + u |
||||
|
||||
|
||||
def fetch_nip11(ws_url: str, *, timeout: float = 10.0) -> dict[str, Any] | None: |
||||
""" |
||||
GET NIP-11 JSON from the relay's HTTP URL with ``Accept: application/nostr+json``. |
||||
Returns parsed object or ``None`` on failure. |
||||
""" |
||||
base = ws_to_http_base(ws_url).rstrip("/") + "/" |
||||
req = Request( |
||||
base, |
||||
headers={"Accept": "application/nostr+json, application/json"}, |
||||
method="GET", |
||||
) |
||||
try: |
||||
with urlopen(req, timeout=timeout) as resp: # noqa: S310 — intentional relay fetch |
||||
raw = resp.read() |
||||
except (HTTPError, URLError, TimeoutError, OSError) as e: |
||||
log.info("NIP-11 HTTP fetch failed for %s: %s", ws_url, e) |
||||
return None |
||||
try: |
||||
data = json.loads(raw.decode("utf-8")) |
||||
except (UnicodeDecodeError, json.JSONDecodeError) as e: |
||||
log.info("NIP-11 invalid JSON for %s: %s", ws_url, e) |
||||
return None |
||||
if not isinstance(data, dict): |
||||
return None |
||||
return cast(dict[str, Any], data) |
||||
|
||||
|
||||
def relay_display_name(nip11: dict[str, Any] | None, ws_url: str) -> str: |
||||
if nip11: |
||||
name = nip11.get("name") |
||||
if isinstance(name, str) and name.strip(): |
||||
return name.strip() |
||||
host = re.sub(r"^wss?://", "", ws_url.strip(), flags=re.I).split("/")[0] |
||||
return host or ws_url |
||||
|
||||
|
||||
def absolute_icon_url(ws_url: str, icon_field: str | None) -> str | None: |
||||
"""Resolve NIP-11 ``icon`` (often relative) against the relay HTTP origin.""" |
||||
if not icon_field: |
||||
return None |
||||
icon = icon_field.strip() |
||||
if not icon: |
||||
return None |
||||
base = ws_to_http_base(ws_url).rstrip("/") + "/" |
||||
if icon.startswith(("http://", "https://")): |
||||
return icon |
||||
return urljoin(base, icon.lstrip("/")) |
||||
@ -0,0 +1,320 @@
@@ -0,0 +1,320 @@
|
||||
"""Right-side relay health (NIP-11 names/icons) + rolling client log.""" |
||||
|
||||
from __future__ import annotations |
||||
|
||||
import logging |
||||
from typing import Any, Callable, cast |
||||
|
||||
from PySide6.QtCore import QObject, QRunnable, Qt, QThreadPool, Signal |
||||
from PySide6.QtGui import QFont, QPixmap, QTextCursor |
||||
from PySide6.QtWidgets import ( |
||||
QFrame, |
||||
QHBoxLayout, |
||||
QLabel, |
||||
QScrollArea, |
||||
QSizePolicy, |
||||
QSplitter, |
||||
QTextEdit, |
||||
QVBoxLayout, |
||||
QWidget, |
||||
) |
||||
|
||||
from imwald.core.nip11_relay_info import absolute_icon_url, fetch_nip11, relay_display_name |
||||
from imwald.core.nostr_engine import NostrEngine |
||||
from imwald.ui.theme import BG_CARD, BG_FIELD, BORDER, TEXT, TEXT_DIM, TEXT_MUTED |
||||
|
||||
_LOG_MAX_CHARS = 120_000 |
||||
|
||||
log = logging.getLogger(__name__) |
||||
|
||||
|
||||
class QtLogHandler(logging.Handler): |
||||
"""Thread-safe append via Qt ``Signal.emit`` (queued across threads).""" |
||||
|
||||
def __init__(self, sink_emit: Callable[[str], None]) -> None: |
||||
super().__init__() |
||||
self._sink_emit = sink_emit |
||||
|
||||
def emit(self, record: logging.LogRecord) -> None: |
||||
try: |
||||
self._sink_emit(self.format(record)) |
||||
except RuntimeError: |
||||
pass |
||||
|
||||
|
||||
class _Nip11FetchSignals(QObject): |
||||
done = Signal(str, object, object) # ws_url, nip11 dict|None, pixmap: QPixmap|None |
||||
|
||||
|
||||
class _Nip11Runnable(QRunnable): |
||||
def __init__(self, ws_url: str, sigs: _Nip11FetchSignals) -> None: |
||||
super().__init__() |
||||
self._ws_url = ws_url |
||||
self._sigs = sigs |
||||
|
||||
def run(self) -> None: |
||||
lg = logging.getLogger(__name__) |
||||
lg.info("NIP-11 fetching %s", self._ws_url) |
||||
nip = fetch_nip11(self._ws_url) |
||||
pm: QPixmap | None = None |
||||
if nip: |
||||
dn = relay_display_name(nip, self._ws_url) |
||||
lg.info("NIP-11 metadata for %s: %r", self._ws_url, dn) |
||||
iu = absolute_icon_url(self._ws_url, cast(str | None, nip.get("icon"))) |
||||
if iu: |
||||
try: |
||||
from urllib.request import urlopen |
||||
|
||||
with urlopen(iu, timeout=8) as resp: # noqa: S310 |
||||
data = resp.read() |
||||
p = QPixmap() |
||||
if p.loadFromData(data) and not p.isNull(): |
||||
pm = p.scaled(36, 36, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation) |
||||
lg.info("NIP-11 icon ok for %s", self._ws_url) |
||||
else: |
||||
lg.info("NIP-11 icon decode failed for %s", self._ws_url) |
||||
except OSError as e: |
||||
pm = None |
||||
lg.info("NIP-11 icon download failed for %s: %s", self._ws_url, e) |
||||
else: |
||||
lg.info("NIP-11 no icon URL for %s", self._ws_url) |
||||
else: |
||||
lg.info("NIP-11 no document for %s", self._ws_url) |
||||
self._sigs.done.emit(self._ws_url, nip, pm) |
||||
|
||||
|
||||
class _RelayRow(QFrame): |
||||
def __init__(self, ws_url: str, parent: QWidget | None = None) -> None: |
||||
super().__init__(parent) |
||||
self._url = ws_url |
||||
self.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Minimum) |
||||
self.setObjectName("RelayRow") |
||||
self.setStyleSheet( |
||||
f"#RelayRow {{ background-color: {BG_CARD}; border: 1px solid {BORDER}; border-radius: 8px; }}" |
||||
) |
||||
self._icon = QLabel() |
||||
self._icon.setFixedSize(40, 40) |
||||
self._icon.setAlignment(Qt.AlignmentFlag.AlignCenter) |
||||
self._icon.setStyleSheet(f"color: {TEXT_MUTED}; font-size: 11px;") |
||||
self._icon.setText("…") |
||||
self._name = QLabel(ws_url.replace("wss://", "").replace("ws://", "").split("/")[0][:42]) |
||||
self._name.setWordWrap(False) |
||||
self._name.setStyleSheet(f"color: {TEXT}; font-weight: 600; font-size: 13px;") |
||||
self._state = QLabel("…") |
||||
self._state.setStyleSheet(f"color: {TEXT_DIM}; font-size: 12px;") |
||||
self._state.setWordWrap(False) |
||||
txt = QVBoxLayout() |
||||
txt.setSpacing(2) |
||||
txt.addWidget(self._name) |
||||
txt.addWidget(self._state) |
||||
row = QHBoxLayout(self) |
||||
row.setContentsMargins(8, 6, 8, 6) |
||||
row.setSpacing(8) |
||||
row.addWidget(self._icon) |
||||
row.addLayout(txt, stretch=1) |
||||
|
||||
def set_snapshot(self, state: str, err: str | None) -> None: |
||||
err_t = (err or "").strip() |
||||
self._state.setText(f"{state}{f' — {err_t}' if err_t else ''}") |
||||
healthy = state == "connected" |
||||
self.setStyleSheet( |
||||
f"#RelayRow {{ background-color: {BG_CARD}; border: 1px solid {BORDER}; " |
||||
f"border-radius: 8px; opacity: {'1' if healthy else '0.45'}; }}" |
||||
) |
||||
self.setToolTip(self._url if not err_t else f"{self._url}\n{err_t}") |
||||
|
||||
def set_nip11(self, name: str, pm: QPixmap | None) -> None: |
||||
self._name.setText(name[:80]) |
||||
if pm is not None and not pm.isNull(): |
||||
self._icon.setPixmap(pm) |
||||
self._icon.setText("") |
||||
else: |
||||
self._icon.clear() |
||||
self._icon.setPixmap(QPixmap()) |
||||
host = self._url.replace("wss://", "").replace("ws://", "").split("/")[0] |
||||
self._icon.setText((host[:2] or "?").upper()) |
||||
|
||||
|
||||
class RelayStatusPanel(QWidget): |
||||
"""Relays (top) + log (bottom).""" |
||||
|
||||
log_line = Signal(str) |
||||
|
||||
def __init__(self, engine: NostrEngine, parent: QWidget | None = None) -> None: |
||||
super().__init__(parent) |
||||
self._engine = engine |
||||
self._rows: dict[str, _RelayRow] = {} |
||||
self._relay_order: tuple[str, ...] = () |
||||
self._last_relay_states: dict[str, tuple[str, str | None]] = {} |
||||
self._nip11_started: set[str] = set() |
||||
self._nip11_sigs = _Nip11FetchSignals(self) |
||||
self._pool = QThreadPool(self) |
||||
self._pool.setMaxThreadCount(3) |
||||
|
||||
root = QVBoxLayout(self) |
||||
root.setContentsMargins(6, 6, 6, 6) |
||||
root.setSpacing(6) |
||||
|
||||
title = QLabel("Relays") |
||||
title.setStyleSheet(f"color: {TEXT_MUTED}; font-size: 12px; font-weight: 600;") |
||||
root.addWidget(title) |
||||
|
||||
self._relay_host = QWidget() |
||||
self._relay_lay = QVBoxLayout(self._relay_host) |
||||
self._relay_lay.setContentsMargins(0, 0, 0, 0) |
||||
self._relay_lay.setSpacing(6) |
||||
self._relay_lay.setAlignment(Qt.AlignmentFlag.AlignTop) |
||||
self._relay_lay.addStretch(1) |
||||
self._pin_relay_layout_stretch() |
||||
|
||||
relay_scroll = QScrollArea() |
||||
relay_scroll.setWidgetResizable(True) |
||||
relay_scroll.setWidget(self._relay_host) |
||||
relay_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) |
||||
relay_scroll.setFrameShape(QFrame.Shape.NoFrame) |
||||
relay_scroll.setStyleSheet(f"QScrollArea {{ background: transparent; border: none; }}") |
||||
relay_scroll.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred) |
||||
|
||||
self._log = QTextEdit() |
||||
self._log.setReadOnly(True) |
||||
self._log.setPlaceholderText("Client log…") |
||||
mono = QFont("monospace") |
||||
if not mono.exactMatch(): |
||||
mono = QFont("Courier New") |
||||
self._log.setFont(mono) |
||||
self._log.setStyleSheet( |
||||
f"QTextEdit {{ background-color: {BG_FIELD}; color: {TEXT_DIM}; " |
||||
f"border: 1px solid {BORDER}; border-radius: 8px; padding: 6px; font-size: 12px; }}" |
||||
) |
||||
self._log.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) |
||||
|
||||
split = QSplitter(Qt.Orientation.Vertical) |
||||
split.setChildrenCollapsible(False) |
||||
split.addWidget(relay_scroll) |
||||
split.addWidget(self._log) |
||||
split.setStretchFactor(0, 0) |
||||
split.setStretchFactor(1, 1) |
||||
split.setSizes([200, 360]) |
||||
root.addWidget(split, stretch=1) |
||||
|
||||
self.setMaximumWidth(360) |
||||
self.setMinimumWidth(200) |
||||
|
||||
self.log_line.connect(self._append_log) |
||||
self._nip11_sigs.done.connect(self._on_nip11_ready_slot) |
||||
engine.relay_snapshot.connect(self._on_relay_snapshot) |
||||
|
||||
self._log_handler = QtLogHandler(self.log_line.emit) |
||||
self._log_handler.setLevel(logging.DEBUG) |
||||
self._log_handler.setFormatter( |
||||
logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s", "%H:%M:%S") |
||||
) |
||||
imwald_log = logging.getLogger("imwald") |
||||
imwald_log.addHandler(self._log_handler) |
||||
imwald_log.setLevel(logging.DEBUG) |
||||
# Markdown renderer debug is noisy; keep it at INFO while the panel shows DEBUG elsewhere. |
||||
logging.getLogger("imwald.core.md_render").setLevel(logging.INFO) |
||||
|
||||
def _append_log(self, line: str) -> None: |
||||
cur = self._log.textCursor() |
||||
cur.movePosition(QTextCursor.MoveOperation.End) |
||||
self._log.setTextCursor(cur) |
||||
self._log.insertPlainText(line + "\n") |
||||
doc = self._log.document() |
||||
if doc.characterCount() > _LOG_MAX_CHARS: |
||||
doc.setPlainText(doc.toPlainText()[-_LOG_MAX_CHARS:]) |
||||
|
||||
def _on_relay_snapshot(self, rows: object) -> None: |
||||
if not isinstance(rows, list): |
||||
return |
||||
rows_list = cast(list[Any], rows) |
||||
urls: list[str] = [] |
||||
states: dict[str, tuple[str, str | None]] = {} |
||||
for raw in rows_list: |
||||
if not isinstance(raw, dict): |
||||
continue |
||||
item = cast(dict[str, Any], raw) |
||||
u = str(item.get("url") or "") |
||||
if not u: |
||||
continue |
||||
urls.append(u) |
||||
err_o = item.get("error") |
||||
err_s = str(err_o).strip() if err_o is not None else None |
||||
states[u] = (str(item.get("state") or "?"), err_s or None) |
||||
key = tuple(urls) |
||||
if key == self._relay_order and set(urls) == set(self._rows.keys()): |
||||
for u in urls: |
||||
row = self._rows.get(u) |
||||
if row is None: |
||||
continue |
||||
st = states.get(u, ("?", None)) |
||||
row.set_snapshot(st[0], st[1]) |
||||
self._log_relay_transitions(states) |
||||
return |
||||
for u in list(self._rows.keys()): |
||||
if u not in urls: |
||||
w = self._rows.pop(u) |
||||
w.deleteLater() |
||||
for u in urls: |
||||
if u not in self._rows: |
||||
row = _RelayRow(u, self._relay_host) |
||||
self._rows[u] = row |
||||
self._relay_lay.insertWidget(self._relay_lay.count() - 1, row) |
||||
if u not in self._nip11_started: |
||||
self._nip11_started.add(u) |
||||
self._pool.start(_Nip11Runnable(u, self._nip11_sigs)) |
||||
if key != self._relay_order: |
||||
for i, u in enumerate(urls): |
||||
row = self._rows.get(u) |
||||
if row is None: |
||||
continue |
||||
self._relay_lay.removeWidget(row) |
||||
self._relay_lay.insertWidget(i, row) |
||||
self._relay_order = key |
||||
self._pin_relay_layout_stretch() |
||||
for u in urls: |
||||
row = self._rows.get(u) |
||||
if row is None: |
||||
continue |
||||
st = states.get(u, ("?", None)) |
||||
row.set_snapshot(st[0], st[1]) |
||||
self._log_relay_transitions(states) |
||||
|
||||
def _log_relay_transitions(self, states: dict[str, tuple[str, str | None]]) -> None: |
||||
"""INFO only when roster membership or per-relay state/error changes (not every pulse).""" |
||||
for u in list(self._last_relay_states): |
||||
if u not in states: |
||||
log.info("relay roster: removed %s", u) |
||||
del self._last_relay_states[u] |
||||
for u, cur in states.items(): |
||||
prev = self._last_relay_states.get(u) |
||||
if prev == cur: |
||||
continue |
||||
self._last_relay_states[u] = cur |
||||
st, err = cur |
||||
extra = f" ({err})" if err else "" |
||||
log.info("relay state %s → %s%s", u, st, extra) |
||||
|
||||
def _pin_relay_layout_stretch(self) -> None: |
||||
lay = self._relay_lay |
||||
n = lay.count() |
||||
if n <= 0: |
||||
return |
||||
for i in range(n - 1): |
||||
lay.setStretch(i, 0) |
||||
lay.setStretch(n - 1, 1) |
||||
|
||||
def _on_nip11_ready_slot(self, ws_url: str, nip: object, pm: object) -> None: |
||||
row = self._rows.get(ws_url) |
||||
if not row: |
||||
return |
||||
nip_d = cast(dict[str, Any] | None, nip if isinstance(nip, dict) else None) |
||||
name = relay_display_name(nip_d, ws_url) |
||||
qpm: QPixmap | None = pm if isinstance(pm, QPixmap) else None |
||||
row.set_nip11(name, qpm) |
||||
|
||||
def shutdown_logging(self) -> None: |
||||
imwald_log = logging.getLogger("imwald") |
||||
imwald_log.removeHandler(self._log_handler) |
||||
imwald_log.setLevel(logging.NOTSET) |
||||
logging.getLogger("imwald.core.md_render").setLevel(logging.NOTSET) |
||||
@ -0,0 +1,45 @@
@@ -0,0 +1,45 @@
|
||||
"""Database helpers for per-author metadata relay ``since`` cursors.""" |
||||
|
||||
import tempfile |
||||
from hashlib import sha256 |
||||
from pathlib import Path |
||||
|
||||
from imwald.core.database import Database |
||||
from imwald.core.nostr_crypto import build_signed_event, pubkey_hex_from_secret |
||||
|
||||
|
||||
def _sk() -> bytes: |
||||
return bytes.fromhex("3501454135014541350145413501453fefb02227e449e57cf4d3a3ce05378683") |
||||
|
||||
|
||||
def test_max_created_at_for_author_kinds() -> None: |
||||
sk = _sk() |
||||
pk = pubkey_hex_from_secret(sk) |
||||
k0 = build_signed_event(sk, created_at=10, kind=0, tags=[], content="{}") |
||||
k1 = build_signed_event(sk, created_at=50, kind=1, tags=[], content="hi") |
||||
with tempfile.TemporaryDirectory() as td: |
||||
db = Database(Path(td) / "m.sqlite") |
||||
db.connect() |
||||
db.upsert_event(k0) |
||||
db.upsert_event(k1) |
||||
assert db.max_created_at_for_author_kinds(pk, (0, 30000)) == 10 |
||||
assert db.max_created_at_for_author_kinds(pk, (0, 1)) == 50 |
||||
|
||||
|
||||
def test_distinct_pubkeys_recent_orders_by_max_created() -> None: |
||||
sk_a = _sk() |
||||
sk_b = sha256(b"author-b").digest() |
||||
pk_a = pubkey_hex_from_secret(sk_a) |
||||
pk_b = pubkey_hex_from_secret(sk_b) |
||||
ea = build_signed_event(sk_a, created_at=100, kind=1, tags=[], content="a") |
||||
eb_old = build_signed_event(sk_b, created_at=50, kind=1, tags=[], content="old") |
||||
eb_new = build_signed_event(sk_b, created_at=300, kind=1, tags=[], content="new") |
||||
with tempfile.TemporaryDirectory() as td: |
||||
db = Database(Path(td) / "d.sqlite") |
||||
db.connect() |
||||
db.upsert_event(ea) |
||||
db.upsert_event(eb_old) |
||||
db.upsert_event(eb_new) |
||||
got = db.distinct_pubkeys_recent(10) |
||||
assert got[0] == pk_b.lower() |
||||
assert got[1] == pk_a.lower() |
||||
@ -0,0 +1,68 @@
@@ -0,0 +1,68 @@
|
||||
"""Thread root matching via ``e``/``E``/``a``/``A``/``q`` (Jumble-style).""" |
||||
|
||||
import tempfile |
||||
from pathlib import Path |
||||
from typing import Any, cast |
||||
|
||||
from imwald.core.database import Database, thread_root_link_targets |
||||
from imwald.core.nostr_crypto import build_signed_event, pubkey_hex_from_secret |
||||
|
||||
|
||||
def _sk() -> bytes: |
||||
return bytes.fromhex("3501454135014541350145413501453fefb02227e449e57cf4d3a3ce05378683") |
||||
|
||||
|
||||
def test_thread_root_link_targets_kind1() -> None: |
||||
root = cast(dict[str, Any], {"id": "a" * 64, "kind": 1, "pubkey": "b" * 64, "tags": []}) |
||||
assert thread_root_link_targets(root) == ["a" * 64] |
||||
|
||||
|
||||
def test_thread_root_link_targets_addressable_adds_a_coordinate() -> None: |
||||
pk = "c" * 64 |
||||
root = cast( |
||||
dict[str, Any], |
||||
{"id": "d" * 64, "kind": 30023, "pubkey": pk, "tags": [["d", "slug-x"]]}, |
||||
) |
||||
t = thread_root_link_targets(root) |
||||
assert "d" * 64 in t |
||||
assert f"30023:{pk}:slug-x" in t |
||||
|
||||
|
||||
def test_list_replies_to_matches_q_tag() -> None: |
||||
sk = _sk() |
||||
root = build_signed_event(sk, created_at=1, kind=1, tags=[], content="root") |
||||
rid = root["id"] |
||||
rep = build_signed_event( |
||||
sk, |
||||
created_at=2, |
||||
kind=1, |
||||
tags=[["q", rid]], |
||||
content="quote", |
||||
) |
||||
with tempfile.TemporaryDirectory() as td: |
||||
db = Database(Path(td) / "t.sqlite") |
||||
db.connect() |
||||
db.upsert_event(root) |
||||
db.upsert_event(rep) |
||||
got = db.list_replies_to(root, limit=20) |
||||
assert len(got) == 1 and got[0]["id"] == rep["id"] |
||||
|
||||
|
||||
def test_list_replies_to_matches_uppercase_e() -> None: |
||||
sk = _sk() |
||||
root = build_signed_event(sk, created_at=1, kind=1, tags=[], content="root") |
||||
rid = root["id"] |
||||
rep = build_signed_event( |
||||
sk, |
||||
created_at=2, |
||||
kind=1111, |
||||
tags=[["E", rid, "", "1" * 64], ["p", pubkey_hex_from_secret(sk)]], |
||||
content="c", |
||||
) |
||||
with tempfile.TemporaryDirectory() as td: |
||||
db = Database(Path(td) / "t2.sqlite") |
||||
db.connect() |
||||
db.upsert_event(root) |
||||
db.upsert_event(rep) |
||||
got = db.list_replies_to(root, limit=20) |
||||
assert len(got) == 1 and got[0]["id"] == rep["id"] |
||||
Loading…
Reference in new issue