#!/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, PublicKey except ImportError as e: print(f"[ERROR] nostr_sdk import failed: {e}") sys.exit(1) # Configuration GITEA = "https://forge.alexanderwhitestone.com" 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", "")) allowed.append(keystore["alexander"].get("hex_public", "")) 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 using signer's decrypt method # Note: This is for NIP-04, NIP-44 may need different handling decrypted = signer.decrypt(author, event.content()) content = decrypted 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]}...") # Send acknowledgement DM back try: reply_content = f"ACK: {result.get('message', 'Command processed')[:200]}" # Build and send DM reply recipient = PublicKey.parse(author) # Note: Sending DMs requires proper event construction # This is a placeholder - actual send needs NIP-04/NIP-44 event building print(f" [ACK] Would send: {reply_content[:60]}...") except Exception as ack_err: print(f" [ACK ERROR] Failed to send acknowledgement: {ack_err}") 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)}") for pk in allowed: print(f" - {pk[:32]}...") # Run bridge loop try: asyncio.run(run_bridge_loop()) except Exception as e: print(f"\n[ERROR] Bridge crashed: {e}") import traceback traceback.print_exc() sys.exit(1) if __name__ == "__main__": main()