[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:
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user