[BRIDGE] Update dm_bridge_mvp.py with fixed decryption and ACK loop

- Fixed mangled function names
- Added proper NIP-04 decryption via signer.decrypt()
- Added acknowledgement loop placeholder
- Added hex_public to allowed pubkeys
- Better error reporting

Refs #181
This commit is contained in:
2026-04-05 04:30:08 +00:00
parent 67d3af8334
commit 2e4e512b97

View File

@@ -7,12 +7,13 @@ import asyncio
import json import json
import os import os
import sys import sys
import time
from datetime import datetime, timedelta from datetime import datetime, timedelta
from urllib.request import Request, urlopen from urllib.request import Request, urlopen
# nostr_sdk imports # nostr_sdk imports
try: 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: except ImportError as e:
print(f"[ERROR] nostr_sdk import failed: {e}") print(f"[ERROR] nostr_sdk import failed: {e}")
sys.exit(1) sys.exit(1)
@@ -20,6 +21,8 @@ except ImportError as e:
# Configuration # Configuration
GITEA = "http://143.198.27.163:3000" GITEA = "http://143.198.27.163:3000"
RELAY_URL = "ws://localhost:2929" # Local relay RELAY_URL = "ws://localhost:2929" # Local relay
POLL_INTERVAL = 60 # Seconds between polls
ALLOWED_PUBKEYS = [] # Will load from keystore
_GITEA_TOKEN = None _GITEA_TOKEN = None
# Load credentials # Load credentials
@@ -41,6 +44,16 @@ def load_gitea_token():
pass pass
return None 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 # Gitea API helpers
def gitea_post(path, data): def gitea_post(path, data):
token = load_gitea_token() token = load_gitea_token()
@@ -52,6 +65,15 @@ def gitea_post(path, data):
with urlopen(req, timeout=15) as resp: with urlopen(req, timeout=15) as resp:
return json.loads(resp.read().decode()) 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): def create_issue(repo, title, body, assignees=None):
"""Create a Gitea issue from DM content""" """Create a Gitea issue from DM content"""
data = { data = {
@@ -62,9 +84,162 @@ def create_issue(repo, title, body, assignees=None):
data["assignees"] = assignees data["assignees"] = assignees
return gitea_post(f"/repos/{repo}/issues", data) 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 <repo> <title>' -> 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 # Nostr DM processing
async def poll_dms(): async def poll_dms(client, signer, since_ts):
"""Poll for DMs addressed to Allegro""" """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() keystore = load_keystore()
# Initialize Allegro's keys with NostrSigner # Initialize Allegro's keys with NostrSigner
@@ -78,36 +253,35 @@ async def poll_dms():
await client.add_relay(relay_url) await client.add_relay(relay_url)
await client.connect() 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 last_check = Timestamp.now()
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)
try: try:
events = await client.fetch_events(filter_dm, timedelta(seconds=5)) while True:
print(f"[BRIDGE] Found {len(events)} DM events") print(f"\n[{datetime.utcnow().strftime('%H:%M:%S')}] Polling for DMs...")
events, commands = await poll_dms(client, signer, last_check)
for event in events: last_check = Timestamp.now()
author = event.author().to_hex()[:32]
print(f" - Event {event.id().to_hex()[:16]}... from {author}")
except Exception as e: if events > 0 or commands > 0:
print(f"[BRIDGE] DM fetch issue (may be relay restriction): {e}") print(f" Processed: {events} events, {commands} commands")
else:
await client.disconnect() print(f" No new DMs")
return True
await asyncio.sleep(POLL_INTERVAL)
except KeyboardInterrupt:
print("\n[BRIDGE] Shutting down...")
finally:
await client.disconnect()
# Main entry
def main(): def main():
print("="*60) print("="*60)
print("NOSTUR → GITEA BRIDGE MVP") print("NOSTUR → GITEA BRIDGE MVP")
print("Continuous DM → Issue Bridge Service")
print("="*60) print("="*60)
# Verify keystore # Verify keystore
@@ -122,15 +296,20 @@ def main():
sys.exit(1) sys.exit(1)
print(f"[INIT] Gitea token loaded: {token[:8]}...") print(f"[INIT] Gitea token loaded: {token[:8]}...")
# Run DM poll # Load allowed pubkeys
try: allowed = load_allowed_pubkeys()
result = asyncio.run(poll_dms()) print(f"[INIT] Allowed operators: {len(allowed)}")
print("\n[STATUS] DM bridge client functional") for pk in allowed:
except Exception as e: print(f" - {pk[:32]}...")
print(f"\n[STATUS] DM bridge needs config: {e}")
return False
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__": if __name__ == "__main__":
main() main()