diff --git a/workspace/timmy-config/nostur-bridge/nostr_gitea_bridge.py b/workspace/timmy-config/nostur-bridge/nostr_gitea_bridge.py
new file mode 100644
index 00000000..bd37bc04
--- /dev/null
+++ b/workspace/timmy-config/nostur-bridge/nostr_gitea_bridge.py
@@ -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
- Create new issue with title
+ COMMENT # - 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 # "
+
+ elif cmd == "HELP":
+ return """π€ TIMMY NOSTUR BRIDGE
+
+Commands:
+ STATUS - Show priority queue summary
+ CREATE - Create new issue (authorized keys only)
+ COMMENT # - 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()
diff --git a/workspace/timmy-config/nostur-bridge/requirements.txt b/workspace/timmy-config/nostur-bridge/requirements.txt
new file mode 100644
index 00000000..aebb6a0c
--- /dev/null
+++ b/workspace/timmy-config/nostur-bridge/requirements.txt
@@ -0,0 +1 @@
+websocket-client>=1.7.0
diff --git a/workspace/timmy-config/nostur-bridge/timmy-nostur-bridge.service b/workspace/timmy-config/nostur-bridge/timmy-nostur-bridge.service
new file mode 100644
index 00000000..22177505
--- /dev/null
+++ b/workspace/timmy-config/nostur-bridge/timmy-nostur-bridge.service
@@ -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