Files
timmy-config/operator-gate/adapters/nostur_adapter.py

213 lines
8.2 KiB
Python

#!/usr/bin/env python3
"""
Nostur (Nostr DM) Adapter for the Operator Ingress Gate
Reads Kind-4 DMs from authorized operator pubkeys, normalizes them into
Gate Commands, and executes via gitea_gate.
This adapter is deliberately thin: all idempotency, deduplication, and
mutation logic lives in the gate.
"""
import asyncio
import json
import os
import sys
from datetime import datetime, timedelta
from pathlib import Path
# Add parent to path for gate import
sys.path.insert(0, str(Path(__file__).parent.parent))
try:
from nostr_sdk import Keys, Client, Filter, Kind, NostrSigner, Timestamp, PublicKey
except ImportError as e:
print(f"[WARN] nostr_sdk not available: {e}")
Keys = Client = Filter = Kind = NostrSigner = Timestamp = PublicKey = None
from gitea_gate import create_issue, add_comment, close_issue, assign_issue, merge_pr
# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
RELAY_URL = os.environ.get("NOSTR_RELAY", "ws://localhost:2929")
KEYSTORE_PATH = os.environ.get("KEYSTORE_PATH", "/root/nostr-relay/keystore.json")
ALLOWED_SOURCES = os.environ.get("ALLOWED_OPERATOR_NPUBS", "").split(",")
POLL_INTERVAL = int(os.environ.get("NOSTUR_POLL_INTERVAL", "60"))
def _load_keystore() -> dict:
with open(KEYSTORE_PATH) as f:
return json.load(f)
def _allowed_pubkeys() -> list:
ks = _load_keystore()
out = []
if "alexander" in ks:
out.append(ks["alexander"].get("pubkey", ""))
out.append(ks["alexander"].get("hex_public", ""))
for s in ALLOWED_SOURCES:
s = s.strip()
if s:
out.append(s)
return [p for p in out if p]
# ---------------------------------------------------------------------------
# DM → Command normalizer
# ---------------------------------------------------------------------------
def _normalize_dm(content: str) -> dict:
content = content.strip()
lines = content.splitlines()
first = lines[0].strip().lower()
if first == "status" or first.startswith("status"):
return {"action": "status", "repo": "Timmy_Foundation/the-nexus"}
if first.startswith("create "):
rest = content[7:]
parts = rest.split(" ", 1)
if len(parts) >= 2:
repo = parts[0] if "/" in parts[0] else f"Timmy_Foundation/{parts[0]}"
title = parts[1].split("\n", 1)[0]
body = "\n".join(lines[1:]) if len(lines) > 1 else ""
return {"action": "create_issue", "repo": repo, "title": title, "body": body}
if first.startswith("comment "):
rest = content[8:]
parts = rest.split(" ", 2)
if len(parts) >= 3:
repo = parts[0] if "/" in parts[0] else f"Timmy_Foundation/{parts[0]}"
issue_ref = parts[1]
body = parts[2]
if issue_ref.startswith("#"):
return {"action": "add_comment", "repo": repo, "issue_num": int(issue_ref[1:]), "body": body}
if first.startswith("close "):
rest = content[6:]
parts = rest.split(" ", 1)
if len(parts) == 2:
repo = parts[0] if "/" in parts[0] else f"Timmy_Foundation/{parts[0]}"
issue_ref = parts[1]
if issue_ref.startswith("#"):
return {"action": "close_issue", "repo": repo, "issue_num": int(issue_ref[1:])}
if first.startswith("assign "):
rest = content[7:]
parts = rest.split(" ", 2)
if len(parts) >= 3:
repo = parts[0] if "/" in parts[0] else f"Timmy_Foundation/{parts[0]}"
issue_ref = parts[1]
users = [u.strip() for u in parts[2].split(",")]
if issue_ref.startswith("#"):
return {"action": "assign_issue", "repo": repo, "issue_num": int(issue_ref[1:]), "assignees": users}
if first.startswith("merge "):
rest = content[6:]
parts = rest.split(" ", 1)
if len(parts) == 2:
repo = parts[0] if "/" in parts[0] else f"Timmy_Foundation/{parts[0]}"
pr_ref = parts[1]
if pr_ref.startswith("#"):
return {"action": "merge_pr", "repo": repo, "pr_num": int(pr_ref[1:])}
return {"action": "unknown", "raw": content}
# ---------------------------------------------------------------------------
# Status helper (read-only, bypasses gate)
# ---------------------------------------------------------------------------
def _status_summary(repo: str) -> str:
import urllib.request
from gitea_gate import _api_get
try:
issues = _api_get(f"/repos/{repo}/issues?state=open&limit=20")
unassigned = [i for i in issues if not i.get("assignee")]
blockers = [i for i in issues if any(l["name"] == "blocker" for l in i.get("labels", []))]
summary = f"Queue Status for {repo}\nOpen: {len(issues)} | Unassigned: {len(unassigned)} | Blockers: {len(blockers)}\n"
if unassigned[:3]:
summary += "Top unassigned:\n"
for i in unassigned[:3]:
summary += f"- #{i['number']}: {i['title'][:50]}...\n"
return summary
except Exception as e:
return f"Error fetching status: {e}"
# ---------------------------------------------------------------------------
# DM execution
# ---------------------------------------------------------------------------
def _execute_normalized(source_npub: str, cmd: dict) -> dict:
action = cmd.get("action")
if action == "status":
return {"success": True, "message": _status_summary(cmd["repo"])}
if action == "create_issue":
ack = create_issue(source_npub, cmd["repo"], cmd["title"], cmd.get("body", ""))
return {"success": ack.success, "message": ack.message, "url": ack.gitea_url}
if action == "add_comment":
ack = add_comment(source_npub, cmd["repo"], cmd["issue_num"], cmd["body"])
return {"success": ack.success, "message": ack.message, "url": ack.gitea_url}
if action == "close_issue":
ack = close_issue(source_npub, cmd["repo"], cmd["issue_num"])
return {"success": ack.success, "message": ack.message, "url": ack.gitea_url}
if action == "assign_issue":
ack = assign_issue(source_npub, cmd["repo"], cmd["issue_num"], cmd["assignees"])
return {"success": ack.success, "message": ack.message, "url": ack.gitea_url}
if action == "merge_pr":
ack = merge_pr(source_npub, cmd["repo"], cmd["pr_num"])
return {"success": ack.success, "message": ack.message, "url": ack.gitea_url}
return {"success": False, "message": f"Unknown command. Supported: status, create, comment, close, assign, merge"}
# ---------------------------------------------------------------------------
# Async polling loop
# ---------------------------------------------------------------------------
async def poll_loop():
if Client is None:
raise RuntimeError("nostr_sdk not installed; cannot run Nostur adapter")
signer = NostrSigner.keys(Keys.generate())
client = Client(signer)
await client.add_relay(RELAY_URL)
await client.connect()
allowed = _allowed_pubkeys()
since = Timestamp.now()
print(f"[NOSTUR-ADAPTER] Connected to {RELAY_URL}")
print(f"[NOSTUR-ADAPTER] Allowed operators: {len(allowed)}")
while True:
await asyncio.sleep(POLL_INTERVAL)
try:
filter_dm = Filter().kind(Kind(4)).since(since)
events = await client.fetch_events(filter_dm, timedelta(seconds=5))
since = Timestamp.now()
for event in events.to_vec():
author_hex = event.author().to_hex()
author_npub = event.author().to_bech32()
if author_hex not in allowed and author_npub not in allowed:
print(f"[SKIP] Unauthorized: {author_npub[:20]}...")
continue
content = event.content()
cmd = _normalize_dm(content)
print(f"[DM] {author_npub[:20]}... -> {cmd.get('action', 'unknown')}")
result = _execute_normalized(author_npub, cmd)
print(f"[ACK] {result['message'][:120]}")
except Exception as e:
print(f"[ERROR] Poll loop: {e}")
if __name__ == "__main__":
asyncio.run(poll_loop())