[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 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 <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
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()