213 lines
8.2 KiB
Python
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())
|