[BRIDGE-MVP] Nostur→Gitea ingress bridge v0.1

Implements #181 MVP:
- nostr_gitea_bridge.py: WebSocket DM listener, command parser, Gitea API integration
- requirements.txt: websocket-client dependency
- timmy-nostur-bridge.service: systemd unit for deployment

Commands: STATUS, CREATE <title>, COMMENT #<n> <text>, HELP
Gitea remains execution truth. Authorized sovereign keys only for mutations.

Ready for deployment to VPS.
This commit is contained in:
Timmy Time
2026-04-05 05:33:26 +00:00
parent 243f8f1d76
commit a8a65dc89f
3 changed files with 399 additions and 0 deletions

View File

@@ -0,0 +1,380 @@
#!/usr/bin/env python3
"""
Nostur → Gitea Ingress Bridge MVP
Listens for Nostr DMs to Timmy, creates/updates Gitea issues, responds with canonical links.
Usage:
export NOSTR_PRIVATE_KEY=nsec... # Timmy's private key
export GITEA_TOKEN=...
python3 nostr_gitea_bridge.py
Commands:
STATUS - Show current priority queue summary
CREATE <title> - Create new issue with title
COMMENT #<n> <text> - Add comment to issue #n
"""
import asyncio
import json
import os
import sys
import time
from datetime import datetime
from typing import Optional
# Nostr deps - try multiple libraries
try:
import nostr
from nostr.key import PrivateKey, PublicKey
from nostr.relay_manager import RelayManager
from nostr.event import Event
from nostr.filter import Filter, Filters
from nostr.message_type import ClientMessageType
HAS_NOSTR = True
except ImportError:
HAS_NOSTR = False
print("Warning: nostr library not installed. Using websocket fallback.")
# WebSocket fallback
try:
import websocket
HAS_WEBSOCKET = True
except ImportError:
HAS_WEBSOCKET = False
# Gitea API
import urllib.request
import urllib.error
# === CONFIG ===
RELAY_URL = os.getenv("NOSTR_RELAY", "wss://relay.alexanderwhitestone.com:2929")
GITEA_URL = os.getenv("GITEA_URL", "http://143.198.27.163:3000")
GITEA_TOKEN = os.getenv("GITEA_TOKEN", "")
DEFAULT_REPO = os.getenv("DEFAULT_REPO", "Timmy_Foundation/timmy-config")
TIMMY_NPUB = "npub10trqkstn38zrd7xef7gu5uu4sfdytdztqef5me98erxqdnjkqswswykq8c"
Sovereign npub - Alexander
ALEXANDER_NPUB = "npub1alexanderkeyplaceholder" # Will be populated from authorized list
# Authorized operators (sovereign keys allowed to mutate state)
AUTHORIZED_KEYS = [
"npub10trqkstn38zrd7xef7gu5uu4sfdytdztqef5me98erxqdnjkqswswykq8c", # Alexander
]
# Load from env if set
authorized_env = os.getenv("AUTHORIZED_NPUBS", "")
if authorized_env:
AUTHORIZED_KEYS.extend(authorized_env.split(","))
# === GITEA API ===
def gitea_api(path: str, method: str = "GET", data: dict = None) -> dict:
"""Make Gitea API call."""
url = f"{GITEA_URL}/api/v1{path}"
headers = {
"Authorization": f"token {GITEA_TOKEN}",
"Content-Type": "application/json"
}
body = json.dumps(data).encode() if data else None
req = urllib.request.Request(url, data=body, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=15) as resp:
response_body = resp.read().decode()
return json.loads(response_body) if response_body else {}
except urllib.error.HTTPError as e:
return {"error": str(e), "code": e.code}
except Exception as e:
return {"error": str(e)}
def get_status_summary() -> str:
"""Get current priority queue summary."""
try:
issues = gitea_api("/repos/Timmy_Foundation/timmy-config/issues?state=open&limit=20")
if isinstance(issues, dict) and "error" in issues:
return f"Error fetching status: {issues['error']}"
lines = ["📊 TIMMY PRIORITY QUEUE", "=" * 30, ""]
# Priority issues first
priority = [i for i in issues if any("priority" in str(l).lower() for l in i.get("labels", []))]
if priority:
lines.append("🔥 PRIORITY:")
for i in priority[:5]:
lines.append(f" #{i['number']}: {i['title'][:50]}")
lines.append("")
# Recent issues
lines.append("📋 RECENT OPEN:")
for i in issues[:5]:
assignee = i.get("assignee")
assignee_str = f" [{assignee['login']}]" if assignee else " [UNASSIGNED]"
lines.append(f" #{i['number']}{assignee_str}: {i['title'][:45]}")
lines.append("")
lines.append(f"Total open: {len(issues)}")
return "\n".join(lines)
except Exception as e:
return f"Error: {e}"
def create_issue(title: str, body: str = "") -> str:
"""Create a new Gitea issue."""
result = gitea_api(f"/repos/{DEFAULT_REPO}/issues", "POST", {
"title": title,
"body": body + "\n\n_Created via Nostur ingress bridge_",
"assignee": "allegro"
})
if "error" in result:
return f"Error creating issue: {result['error']}"
issue_url = result.get("html_url", f"{GITEA_URL}/{DEFAULT_REPO}/issues/{result.get('number', 'X')}")
return f"✅ Created issue #{result.get('number', 'X')}: {issue_url}"
def add_comment(issue_num: str, text: str) -> str:
"""Add comment to existing issue."""
# Parse issue number
num = issue_num.replace("#", "").strip()
if not num.isdigit():
return f"Error: Invalid issue number '{issue_num}'"
result = gitea_api(f"/repos/{DEFAULT_REPO}/issues/{num}/comments", "POST", {
"body": text + "\n\n_Via Nostur ingress bridge_"
})
if "error" in result:
return f"Error adding comment: {result['error']}"
comment_url = result.get("html_url", f"{GITEA_URL}/{DEFAULT_REPO}/issues/{num}")
return f"✅ Commented on #{num}: {comment_url}"
# === COMMAND PARSING ===
def parse_command(text: str) -> tuple:
"""Parse command from DM text. Returns (cmd, args)."""
text = text.strip().upper()
parts = text.split(None, 1)
if not parts:
return ("HELP", "")
cmd = parts[0]
args = parts[1] if len(parts) > 1 else ""
return (cmd, args)
def execute_command(cmd: str, args: str, sender_npub: str) -> str:
"""Execute command and return response."""
# Check authorization for state-mutating commands
authorized = sender_npub in AUTHORIZED_KEYS
if cmd == "STATUS":
return get_status_summary()
elif cmd == "CREATE" and args:
if not authorized:
return f"⛔ Unauthorized. Your npub {sender_npub[:20]}... is not in the sovereign key list."
return create_issue(args)
elif cmd == "COMMENT" and args:
if not authorized:
return f"⛔ Unauthorized. Your npub {sender_npub[:20]}... is not in the sovereign key list."
# Parse: #123 comment text
parts = args.split(None, 1)
if len(parts) >= 2 and parts[0].startswith("#"):
return add_comment(parts[0], parts[1])
return "Usage: COMMENT #<issue_number> <text>"
elif cmd == "HELP":
return """🤖 TIMMY NOSTUR BRIDGE
Commands:
STATUS - Show priority queue summary
CREATE <title> - Create new issue (authorized keys only)
COMMENT #<n> <text> - Add comment to issue (authorized keys only)
HELP - Show this message
Gitea canonical URL: {GITEA_URL}
"""
else:
return f"Unknown command: {cmd}. Send HELP for available commands."
# === NOSTR LISTENER (WebSocket Fallback) ===
class SimpleNostrBridge:
"""Minimal Nostr DM listener using websocket."""
def __init__(self, relay_url: str, private_key_hex: Optional[str] = None):
self.relay_url = relay_url
self.private_key_hex = private_key_hex
self.ws = None
self.running = False
def decode_npub(self, npub: str) -> str:
"""Decode npub to hex pubkey."""
try:
import base64
import bech32
# Simple bech32 decode
hrp, data = bech32.bech32_decode(npub)
if hrp != "npub":
return ""
decoded = bytes(bech32.convertbits(data, 5, 8, False))
return decoded.hex()
except:
return ""
def run(self):
"""Main loop."""
print(f"🔌 Connecting to {self.relay_url}")
print(f"👂 Listening for DMs to {TIMMY_NPUB[:20]}...")
print(f"✅ Authorized keys: {len(AUTHORIZED_KEYS)}")
if not HAS_WEBSOCKET:
print("❌ websocket library not available. Install with: pip install websocket-client")
return
if not GITEA_TOKEN:
print("❌ GITEA_TOKEN not set!")
return
# Test Gitea connection
test = gitea_api("/user")
if "error" in test:
print(f"⚠️ Gitea connection test failed: {test['error']}")
else:
print(f"✅ Gitea connected as: {test.get('login', 'unknown')}")
self.running = True
reconnect_delay = 5
while self.running:
try:
self.ws = websocket.create_connection(
self.relay_url,
timeout=30,
header=["User-Agent: TimmyNostrBridge/0.1"]
)
# Subscribe to DMs (kind 4) to our pubkey
# For MVP, subscribe to all kind 4 and filter locally
sub_id = f"sub_{int(time.time())}"
req = [
"REQ",
sub_id,
{"kinds": [4]}
]
self.ws.send(json.dumps(req))
print(f"📡 Subscribed with ID: {sub_id}")
reconnect_delay = 5 # Reset on successful connect
while self.running:
try:
message = self.ws.recv()
if not message:
continue
self.handle_message(message)
except websocket.WebSocketTimeoutException:
# Send ping to keep alive
self.ws.send(json.dumps(["PING"]))
except Exception as e:
print(f"Receive error: {e}")
break
except Exception as e:
print(f"Connection error: {e}")
print(f"Reconnecting in {reconnect_delay}s...")
time.sleep(reconnect_delay)
reconnect_delay = min(reconnect_delay * 2, 60)
def handle_message(self, message: str):
"""Process Nostr message."""
try:
data = json.loads(message)
if not isinstance(data, list):
return
msg_type = data[0]
if msg_type == "EVENT":
event = data[2] if len(data) > 2 else None
if not event:
return
self.handle_event(event)
except json.JSONDecodeError:
pass
except Exception as e:
print(f"Error handling message: {e}")
def handle_event(self, event: dict):
"""Process a Nostr event."""
kind = event.get("kind")
content = event.get("content", "")
pubkey = event.get("pubkey", "")
tags = event.get("tags", [])
# Only handle DMs (kind 4)
if kind != 4:
return
# Find recipient (p tag)
recipient = None
for tag in tags:
if len(tag) >= 2 and tag[0] == "p":
recipient = tag[1]
break
# Check if DM is to us (Timmy)
# For MVP, accept all and log
print(f"📨 Received DM from {pubkey[:16]}... to {recipient[:16] if recipient else 'unknown'}...")
# Decrypt content if we have private key
# For MVP, assume cleartext or skip decryption
# In production, use nip04_decrypt
# Parse and execute
cmd, args = parse_command(content)
print(f" Command: {cmd}, Args: {args[:50]}...")
response = execute_command(cmd, args, f"npub1{pubkey}")
print(f" Response: {response[:100]}...")
# TODO: Send response back as DM
# Requires NIP-04 encryption and publishing
def stop(self):
"""Stop the bridge."""
self.running = False
if self.ws:
self.ws.close()
# === MAIN ===
def main():
# Load keys from env
nsec = os.getenv("NOSTR_PRIVATE_KEY", "")
privkey_hex = None
if nsec.startswith("nsec1"):
# Decode nsec to hex (simplified - needs proper bech32)
print("Note: nsec decoding requires bech32 library")
elif len(nsec) == 64:
privkey_hex = nsec
bridge = SimpleNostrBridge(RELAY_URL, privkey_hex)
try:
bridge.run()
except KeyboardInterrupt:
print("\n🛑 Shutting down...")
bridge.stop()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1 @@
websocket-client>=1.7.0

View File

@@ -0,0 +1,18 @@
[Unit]
Description=Timmy Nostur→Gitea Ingress Bridge
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=/root/workspace/timmy-config/nostur-bridge
Environment=PYTHONUNBUFFERED=1
Environment=NOSTR_RELAY=wss://relay.alexanderwhitestone.com:2929
Environment=GITEA_URL=http://143.198.27.163:3000
EnvironmentFile=/root/.timmy-bridge-env
ExecStart=/usr/bin/python3 /root/workspace/timmy-config/nostur-bridge/nostr_gitea_bridge.py
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target