diff --git a/nostr-bridge/bridge_mvp.py b/nostr-bridge/bridge_mvp.py index 576f6a51..0b312ca1 100644 --- a/nostr-bridge/bridge_mvp.py +++ b/nostr-bridge/bridge_mvp.py @@ -7,12 +7,13 @@ 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 + 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) @@ -20,6 +21,8 @@ except ImportError as e: # 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 @@ -41,6 +44,16 @@ def load_gitea_token(): 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() @@ -52,6 +65,15 @@ def gitea_post(path, data): 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 = { @@ -62,9 +84,162 @@ def create_issue(repo, title, body, assignees=None): 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(): - """Poll for DMs addressed to Allegro""" +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 @@ -78,36 +253,35 @@ async def poll_dms(): await client.add_relay(relay_url) await client.connect() - print(f"[BRIDGE] Connected to relay as {keystore['allegro']['npub']}") + 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) - # Check Alexander's pubkey - alexander_npub = keystore["alexander"]["npub"] - print(f"[BRIDGE] Monitoring DMs from {alexander_npub}") - - # Fetch DMs from last 24 hours using proper Timestamp - since_ts = Timestamp.now().sub_duration(timedelta(hours=24)) - - # Note: relay29 restricts kinds, kind 4 may be blocked - filter_dm = Filter().kind(Kind(4)).since(since_ts) + last_check = Timestamp.now() try: - events = await client.fetch_events(filter_dm, timedelta(seconds=5)) - print(f"[BRIDGE] Found {len(events)} DM events") - - for event in events: - author = event.author().to_hex()[:32] - print(f" - Event {event.id().to_hex()[:16]}... from {author}") + 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() - except Exception as e: - print(f"[BRIDGE] DM fetch issue (may be relay restriction): {e}") - - await client.disconnect() - return True + 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() -# Main entry def main(): print("="*60) print("NOSTUR → GITEA BRIDGE MVP") + print("Continuous DM → Issue Bridge Service") print("="*60) # Verify keystore @@ -122,15 +296,20 @@ def main(): sys.exit(1) print(f"[INIT] Gitea token loaded: {token[:8]}...") - # Run DM poll - try: - result = asyncio.run(poll_dms()) - print("\n[STATUS] DM bridge client functional") - except Exception as e: - print(f"\n[STATUS] DM bridge needs config: {e}") - return False + # Load allowed pubkeys + allowed = load_allowed_pubkeys() + print(f"[INIT] Allowed operators: {len(allowed)}") + for pk in allowed: + print(f" - {pk[:32]}...") - return True + # 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()