diff --git a/workspace/timmy-config/nostr-bridge/bridge_mvp.py b/workspace/timmy-config/nostr-bridge/bridge_mvp.py new file mode 100644 index 00000000..ee126dde --- /dev/null +++ b/workspace/timmy-config/nostr-bridge/bridge_mvp.py @@ -0,0 +1,298 @@ +#!/usr/bin/env python3 +""" +Nostur -> Gitea Ingress Bridge MVP +Reads DMs from Nostr and creates Gitea issues/comments +""" +import asyncio +import json +import os +import sys +import time +from datetime import datetime, timedelta +from urllib.request import Request, urlopen + +# nostr_sdk imports +try: + from nostr_sdk import Keys, Client, Filter, Kind, NostrSigner, Timestamp, RelayUrl +except ImportError as e: + print(f"[ERROR] nostr_sdk import failed: {e}") + sys.exit(1) + +# Configuration +GITEA = "http://143.198.27.163:3000" +RELAY_URL = "ws://localhost:2929" # Local relay +POLL_INTERVAL = 60 # Seconds between polls +ALLOWED_PUBKEYS = [] # Will load from keystore +_GITEA_TOKEN = None + +# Load credentials +def load_keystore(): + with open("/root/nostr-relay/keystore.json") as f: + return json.load(f) + +def load_gitea_token(): + global _GITEA_TOKEN + if _GITEA_TOKEN: + return _GITEA_TOKEN + for path in ["/root/.gitea_token", os.path.expanduser("~/.gitea_token")]: + try: + with open(path) as f: + _GITEA_TOKEN = f.read().strip() + if _GITEA_TOKEN: + return _GITEA_TOKEN + except FileNotFoundError: + pass + return None + +def load_allowed_pubkeys(): + """Load sovereign operator pubkeys that can create work""" + keystore = load_keystore() + allowed = [] + # Alexander's pubkey is the primary operator + if "alexander" in keystore: + allowed.append(keystore["alexander"].get("pubkey", "")) + return [p for p in allowed if p] + +# Gitea API helpers +def gitea_post(path, data): + token = load_gitea_token() + if not token: + raise RuntimeError("Gitea token not available") + headers = {"Authorization": f"token {token}", "Content-Type": "application/json"} + body = json.dumps(data).encode() + req = Request(f"{GITEA}/api/v1{path}", data=body, headers=headers, method="POST") + with urlopen(req, timeout=15) as resp: + return json.loads(resp.read().decode()) + +def gitea_get(path): + token = load_gitea_token() + if not token: + raise RuntimeError("Gitea token not available") + headers = {"Authorization": f"token {token}"} + req = Request(f"{GITEA}/api/v1{path}", headers=headers) + with urlopen(req, timeout=15) as resp: + return json.loads(resp.read().decode()) + +def create_issue(repo, title, body, assignees=None): + """Create a Gitea issue from DM content""" + data = { + "title": f"[NOSTR] {title}", + "body": f"**Ingress via Nostr DM**\n\n{body}\n\n---\n*Created by Nostur→Gitea Bridge MVP*" + } + if assignees: + data["assignees"] = assignees + return gitea_post(f"/repos/{repo}/issues", data) + +def add_comment(repo, issue_num, body): + """Add comment to existing issue""" + return gitea_post(f"/repos/{repo}/issues/{issue_num}/comments", { + "body": f"**Nostr DM Update**\n\n{body}\n\n---\n*Posted by Bridge MVP*" + }) + +def get_open_issues(repo, label=None): + """Get open issues for status summary""" + path = f"/repos/{repo}/issues?state=open&limit=20" + if label: + path += f"&labels={label}" + return gitea_get(path) + +# DM Content Processing +def parse_dm_command(content): + """ + Parse DM content for commands: + - 'status' -> return queue summary + - 'create ' -> create issue + - 'comment <repo> #<num> <text>' -> add comment + """ + content = content.strip() + lines = content.split('\n') + first_line = lines[0].strip().lower() + + if first_line == 'status' or first_line.startswith('status'): + return {'cmd': 'status', 'repo': 'Timmy_Foundation/the-nexus'} + + if first_line.startswith('create '): + parts = content[7:].split(' ', 1) # Skip 'create ' + if len(parts) >= 2: + repo = parts[0] if '/' in parts[0] else f"Timmy_Foundation/{parts[0]}" + return {'cmd': 'create', 'repo': repo, 'title': parts[1], 'body': '\n'.join(lines[1:]) if len(lines) > 1 else ''} + + if first_line.startswith('comment '): + parts = content[8:].split(' ', 2) # Skip 'comment ' + if len(parts) >= 3: + repo = parts[0] if '/' in parts[0] else f"Timmy_Foundation/{parts[0]}" + issue_ref = parts[1] # e.g., #123 + if issue_ref.startswith('#'): + issue_num = issue_ref[1:] + return {'cmd': 'comment', 'repo': repo, 'issue': issue_num, 'body': parts[2]} + + return {'cmd': 'unknown', 'raw': content} + +def execute_command(cmd, author_npub): + """Execute parsed command and return result""" + try: + if cmd['cmd'] == 'status': + issues = get_open_issues(cmd['repo']) + priority = [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 {cmd['repo']}**\n\n" + summary += f"Open issues: {len(issues)}\n" + summary += f"Unassigned (priority): {len(priority)}\n" + summary += f"Blockers: {len(blockers)}\n\n" + + if priority[:3]: + summary += "**Top Priority (unassigned):**\n" + for i in priority[:3]: + summary += f"- #{i['number']}: {i['title'][:50]}...\n" + + return {'success': True, 'message': summary, 'action': 'status'} + + elif cmd['cmd'] == 'create': + result = create_issue(cmd['repo'], cmd['title'], cmd['body']) + url = result.get('html_url', f"{GITEA}/{cmd['repo']}/issues/{result['number']}") + return { + 'success': True, + 'message': f"✅ Created issue #{result['number']}: {result['title']}\n🔗 {url}", + 'action': 'create', + 'issue_num': result['number'], + 'url': url + } + + elif cmd['cmd'] == 'comment': + result = add_comment(cmd['repo'], cmd['issue'], cmd['body']) + return { + 'success': True, + 'message': f"✅ Added comment to {cmd['repo']}#{cmd['issue']}", + 'action': 'comment' + } + + else: + return {'success': False, 'message': f"Unknown command. Try: status, create <repo> <title>, comment <repo> #<num> <text>"} + + except Exception as e: + return {'success': False, 'message': f"Error: {str(e)}"} + +# Nostr DM processing +async def poll_dms(client, signer, since_ts): + """Poll for DMs and process commands""" + keystore = load_keystore() + allowed_pubkeys = load_allowed_pubkeys() + + # Note: relay29 restricts kinds, kind 4 may be blocked + filter_dm = Filter().kind(Kind(4)).since(since_ts) + + events_processed = 0 + commands_executed = 0 + + try: + events = await client.fetch_events(filter_dm, timedelta(seconds=5)) + + for event in events: + author = event.author().to_hex() + author_npub = event.author().to_bech32() + + # Verify sovereign identity + if author not in allowed_pubkeys: + print(f" [SKIP] Event from unauthorized pubkey: {author[:16]}...") + continue + + events_processed += 1 + print(f" [DM] Event {event.id().to_hex()[:16]}... from {author_npub[:20]}...") + + # Decrypt content (requires NIP-44 or NIP-04 decryption) + try: + # Try to decrypt - this is a simplified placeholder + # Real implementation would use signer.decrypt or similar + content = event.content() # May be encrypted + print(f" Content preview: {content[:80]}...") + + # Parse and execute command + cmd = parse_dm_command(content) + if cmd['cmd'] != 'unknown': + result = execute_command(cmd, author_npub) + commands_executed += 1 + print(f" ✅ {result.get('action', 'unknown')}: {result.get('message', '')[:60]}...") + else: + print(f" [PARSE] Unrecognized command format") + + except Exception as e: + print(f" [ERROR] Failed to process: {e}") + + return events_processed, commands_executed + + except Exception as e: + print(f"[BRIDGE] DM fetch issue (may be relay restriction): {e}") + return 0, 0 + +async def run_bridge_loop(): + """Main bridge loop - runs continuously""" + keystore = load_keystore() + + # Initialize Allegro's keys with NostrSigner + allegro_hex = keystore["allegro"]["hex_secret"] + keys = Keys.parse(allegro_hex) + signer = NostrSigner.keys(keys) + + # Create client with signer + client = Client(signer) + relay_url = RelayUrl.parse(RELAY_URL) + await client.add_relay(relay_url) + await client.connect() + + print(f"[BRIDGE] Connected to relay as {keystore['allegro']['npub'][:32]}...") + print(f"[BRIDGE] Monitoring DMs from authorized pubkeys: {len(load_allowed_pubkeys())}") + print(f"[BRIDGE] Poll interval: {POLL_INTERVAL}s") + print("="*60) + + last_check = Timestamp.now() + + try: + while True: + print(f"\n[{datetime.utcnow().strftime('%H:%M:%S')}] Polling for DMs...") + events, commands = await poll_dms(client, signer, last_check) + last_check = Timestamp.now() + + if events > 0 or commands > 0: + print(f" Processed: {events} events, {commands} commands") + else: + print(f" No new DMs") + + await asyncio.sleep(POLL_INTERVAL) + + except KeyboardInterrupt: + print("\n[BRIDGE] Shutting down...") + finally: + await client.disconnect() + +def main(): + print("="*60) + print("NOSTUR → GITEA BRIDGE MVP") + print("Continuous DM → Issue Bridge Service") + print("="*60) + + # Verify keystore + keystore = load_keystore() + print(f"[INIT] Keystore loaded: {len(keystore)} identities") + print(f"[INIT] Allegro npub: {keystore['allegro']['npub'][:32]}...") + + # Verify Gitea API + token = load_gitea_token() + if not token: + print("[ERROR] Gitea token not found") + sys.exit(1) + print(f"[INIT] Gitea token loaded: {token[:8]}...") + + # Load allowed pubkeys + allowed = load_allowed_pubkeys() + print(f"[INIT] Allowed operators: {len(allowed)}") + + # Run bridge loop + try: + asyncio.run(run_bridge_loop()) + except Exception as e: + print(f"\n[ERROR] Bridge crashed: {e}") + sys.exit(1) + +if __name__ == "__main__": + main()