Compare commits

...

1 Commits

Author SHA1 Message Date
Alexander Whitestone
029c24a100 feat: import sanitized nostr dm bridge 2026-04-05 13:21:24 -04:00
6 changed files with 590 additions and 0 deletions

View File

@@ -0,0 +1,21 @@
# Gitea
GITEA_URL=http://143.198.27.163:3000
# Prefer setting GITEA_TOKEN directly in deployment. If omitted, GITEA_TOKEN_FILE is used.
GITEA_TOKEN_FILE=~/.config/gitea/timmy-token
# Nostr relay
RELAY_URL=wss://alexanderwhitestone.com/relay/
# Bridge identity
BRIDGE_IDENTITY=allegro
KEYSTORE_PATH=~/.timmy/nostr/agent_keys.json
# Optional: set BRIDGE_NSEC directly instead of using KEYSTORE_PATH + BRIDGE_IDENTITY
# Useful when the deployment keystore does not contain the default identity name.
# BRIDGE_NSEC=
# Gitea routing
DEFAULT_REPO=Timmy_Foundation/timmy-config
STATUS_ASSIGNEE=allegro
# Comma-separated list of allowed operator npubs
AUTHORIZED_NPUBS=npub1t8exnw6sp7vtxar8q5teyr0ueq0rvtgqpq5jkzylegupqulxfqwq4j66p5

3
bridge/nostr-dm-bridge/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
__pycache__/
*.pyc
.env

View File

@@ -0,0 +1,97 @@
# Nostr DM → Gitea Bridge
Imported into repo truth from the live Allegro VPS bridge and sanitized for reproducible deployment.
This bridge lets an authorized Nostr operator send encrypted DMs from Nostur that create or update Gitea issues. Gitea remains the system of record. Nostr is operator ingress only.
## What it does
- `!status` returns the configured assignee queue from Gitea
- `!issue "Title" "Body"` creates a new Gitea issue
- `!comment #123 "Text"` comments on an existing issue
- freeform text creates an issue in the configured default repo
- every mutation replies with the canonical Gitea URL
## Repo truth vs live origin
The original running bridge on Allegro proved the concept, but it contained machine-local assumptions:
- root-only token path
- root-only keystore path
- hardcoded bridge identity
- hardcoded assignee and repo
- VPS-specific systemd paths
This repo copy removes those assumptions and makes deployment explicit through environment variables.
## Configuration
Copy `.env.example` to `.env` and set the values for your host.
Required at runtime:
- `GITEA_TOKEN` or `GITEA_TOKEN_FILE`
- `BRIDGE_NSEC` or `KEYSTORE_PATH` + `BRIDGE_IDENTITY`
Common settings:
- `GITEA_URL` default: `http://143.198.27.163:3000`
- `RELAY_URL` default: `wss://alexanderwhitestone.com/relay/`
- `DEFAULT_REPO` default: `Timmy_Foundation/timmy-config`
- `AUTHORIZED_NPUBS` default: Alexander's operator npub
- `STATUS_ASSIGNEE` default: same as `BRIDGE_IDENTITY`
## Files
- `bridge_allegro.py` — bridge daemon
- `test_bridge.py` — component validation script
- `nostr-dm-bridge.service` — example systemd unit
- `.env.example` — deployment template
## Manual run
```bash
cd /opt/timmy/nostr-dm-bridge
cp .env.example .env
# edit .env
python3 bridge_allegro.py
```
## Validation
```bash
cd /opt/timmy/nostr-dm-bridge
python3 test_bridge.py
```
If the configured `BRIDGE_IDENTITY` is not present in the local keystore, the test script generates an ephemeral bridge key so parser/encryption validation still works without production secrets.
## Systemd
```bash
sudo cp nostr-dm-bridge.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now nostr-dm-bridge
sudo systemctl status nostr-dm-bridge
```
The unit expects the repo to live at `/opt/timmy/nostr-dm-bridge` and reads optional runtime config from `/opt/timmy/nostr-dm-bridge/.env`.
## Security model
1. Only configured `AUTHORIZED_NPUBS` can trigger mutations.
2. All durable work objects live in Gitea.
3. Nostr only carries commands and acknowledgments.
4. Every successful action replies with the canonical Gitea link.
5. Bridge identity is explicit and re-keyable without code edits.
## Operator flow
```text
Nostur DM (encrypted kind 4)
-> relay subscription
-> bridge decrypts and validates sender
-> bridge parses command
-> bridge calls Gitea API
-> bridge replies with result + canonical URL
```

View File

@@ -0,0 +1,317 @@
#!/usr/bin/env python3
"""
Nostr DM → Gitea Bridge MVP for Issue #181
Imported from the live Allegro VPS bridge and sanitized for repo truth.
Uses a configurable bridge identity (defaults to Allegro) and explicit env/config
rather than hardcoded machine-local paths.
"""
import json
import os
import sys
import time
import urllib.request
import urllib.error
from pathlib import Path
# Nostr imports
from nostr.event import Event
from nostr.key import PrivateKey, PublicKey
from nostr.relay_manager import RelayManager
# === CONFIGURATION ===
GITEA_URL = os.environ.get("GITEA_URL", "http://143.198.27.163:3000").rstrip("/")
GITEA_TOKEN = os.environ.get("GITEA_TOKEN", "").strip()
GITEA_TOKEN_FILE = Path(os.environ.get("GITEA_TOKEN_FILE", "~/.config/gitea/timmy-token")).expanduser()
RELAY_URL = os.environ.get("RELAY_URL", "wss://alexanderwhitestone.com/relay/")
KEYSTORE_PATH = Path(os.environ.get("KEYSTORE_PATH", "~/.timmy/nostr/agent_keys.json")).expanduser()
BRIDGE_IDENTITY = os.environ.get("BRIDGE_IDENTITY", "allegro")
DEFAULT_REPO = os.environ.get("DEFAULT_REPO", "Timmy_Foundation/timmy-config")
AUTHORIZED_NPUBS = [x.strip() for x in os.environ.get("AUTHORIZED_NPUBS", "npub1t8exnw6sp7vtxar8q5teyr0ueq0rvtgqpq5jkzylegupqulxfqwq4j66p5").split(",") if x.strip()]
STATUS_ASSIGNEE = os.environ.get("STATUS_ASSIGNEE", BRIDGE_IDENTITY)
if not GITEA_TOKEN and GITEA_TOKEN_FILE.exists():
GITEA_TOKEN = GITEA_TOKEN_FILE.read_text().strip()
if not GITEA_TOKEN:
raise RuntimeError(f"Missing Gitea token. Set GITEA_TOKEN or provide {GITEA_TOKEN_FILE}")
BRIDGE_NSEC = os.environ.get("BRIDGE_NSEC", "").strip()
if not BRIDGE_NSEC:
with open(KEYSTORE_PATH) as f:
ks = json.load(f)
if BRIDGE_IDENTITY not in ks:
raise RuntimeError(f"Bridge identity '{BRIDGE_IDENTITY}' not found in {KEYSTORE_PATH}")
BRIDGE_NSEC = ks[BRIDGE_IDENTITY]["nsec"]
bridge_key = PrivateKey.from_nsec(BRIDGE_NSEC)
BRIDGE_NPUB = bridge_key.public_key.bech32()
BRIDGE_HEX = bridge_key.public_key.hex()
AUTHORIZED_HEX = {PublicKey.from_npub(npub).hex(): npub for npub in AUTHORIZED_NPUBS}
print(f"[Bridge] Identity: {BRIDGE_IDENTITY} {BRIDGE_NPUB}")
print(f"[Bridge] Authorized operators: {', '.join(AUTHORIZED_NPUBS)}")
# === GITEA API HELPERS ===
def gitea_get(path):
headers = {"Authorization": f"token {GITEA_TOKEN}"}
req = urllib.request.Request(f"{GITEA_URL}/api/v1{path}", headers=headers)
try:
with urllib.request.urlopen(req, timeout=10) as resp:
return json.loads(resp.read().decode())
except urllib.error.HTTPError as e:
return {"error": str(e)}
def gitea_post(path, data):
headers = {"Authorization": f"token {GITEA_TOKEN}", "Content-Type": "application/json"}
body = json.dumps(data).encode()
req = urllib.request.Request(f"{GITEA_URL}/api/v1{path}", data=body, headers=headers, method="POST")
try:
with urllib.request.urlopen(req, timeout=10) as resp:
return json.loads(resp.read().decode())
except urllib.error.HTTPError as e:
return {"error": str(e), "code": e.code}
# === COMMAND PARSERS ===
def parse_command(text: str) -> dict:
"""Parse DM text for commands."""
text = text.strip()
# !issue "Title" "Body" - create new issue
if text.startswith("!issue"):
parts = text[6:].strip()
if '"' in parts:
try:
quotes = []
in_quote = False
current = ""
for c in parts:
if c == '"':
if in_quote:
quotes.append(current)
current = ""
in_quote = not in_quote
elif in_quote:
current += c
if len(quotes) >= 2:
return {
"action": "create_issue",
"repo": DEFAULT_REPO,
"title": quotes[0],
"body": quotes[1]
}
elif len(quotes) == 1:
return {
"action": "create_issue",
"repo": DEFAULT_REPO,
"title": quotes[0],
"body": f"Created via Nostr DM bridge ({BRIDGE_IDENTITY} operator)"
}
except:
pass
return {
"action": "create_issue",
"repo": DEFAULT_REPO,
"title": parts or "Issue from Nostr",
"body": f"Created via Nostr DM bridge ({BRIDGE_IDENTITY} operator)"
}
# !comment #123 "Text" - append to existing issue
if text.startswith("!comment"):
parts = text[8:].strip()
if parts.startswith("#"):
try:
num_end = 1
while num_end < len(parts) and parts[num_end].isdigit():
num_end += 1
issue_num = int(parts[1:num_end])
rest = parts[num_end:].strip()
if '"' in rest:
body = rest.split('"')[1]
else:
body = rest
return {
"action": "add_comment",
"repo": DEFAULT_REPO,
"issue": issue_num,
"body": body
}
except:
pass
# !status - get queue summary
if text.startswith("!status"):
return {"action": "get_status"}
# Default: treat as freeform issue creation
if text and not text.startswith("!"):
return {
"action": "create_issue",
"repo": DEFAULT_REPO,
"title": text[:80] + ("..." if len(text) > 80 else ""),
"body": f"Operator message via Nostr DM:\n\n{text}\n\n---\n*Via Nostur → Gitea bridge ({BRIDGE_IDENTITY})*"
}
return None
# === ACTION HANDLERS ===
def handle_create_issue(cmd: dict) -> str:
result = gitea_post(f"/repos/{cmd['repo']}/issues", {
"title": cmd["title"],
"body": cmd["body"]
})
if "error" in result:
return f"❌ Failed to create issue: {result.get('error')}"
url = f"{GITEA_URL}/{cmd['repo']}/issues/{result['number']}"
return f"✅ Created issue #{result['number']}: {result['title']}\n🔗 {url}"
def handle_add_comment(cmd: dict) -> str:
result = gitea_post(f"/repos/{cmd['repo']}/issues/{cmd['issue']}/comments", {
"body": cmd["body"] + f"\n\n---\n*Via Nostur → Gitea bridge ({BRIDGE_IDENTITY})*"
})
if "error" in result:
return f"❌ Failed to comment on #{cmd['issue']}: {result.get('error')}"
return f"✅ Commented on issue #{cmd['issue']}\n🔗 {GITEA_URL}/{cmd['repo']}/issues/{cmd['issue']}"
def handle_get_status() -> str:
try:
issues = gitea_get(f"/repos/{DEFAULT_REPO}/issues?state=open&assignee={STATUS_ASSIGNEE}")
if isinstance(issues, dict) and "error" in issues:
return f"⚠️ Status fetch failed: {issues['error']}"
lines = [f"📊 Current {STATUS_ASSIGNEE} Queue:", ""]
for i in issues[:5]:
lines.append(f"#{i['number']}: {i['title'][:50]}")
if len(issues) > 5:
lines.append(f"... and {len(issues) - 5} more")
lines.append("")
lines.append(f"🔗 {GITEA_URL}/{DEFAULT_REPO}/issues?q=assignee%3A{STATUS_ASSIGNEE}")
return "\n".join(lines)
except Exception as e:
return f"⚠️ Status error: {e}"
def execute_command(cmd: dict) -> str:
action = cmd.get("action")
if action == "create_issue":
return handle_create_issue(cmd)
elif action == "add_comment":
return handle_add_comment(cmd)
elif action == "get_status":
return handle_get_status()
return "❓ Unknown command"
# === NOSTR EVENT HANDLING ===
def decrypt_dm(event: Event) -> str:
"""Decrypt DM content using the bridge identity's private key."""
try:
content = bridge_key.decrypt_message(event.content, event.public_key)
return content
except Exception as e:
print(f"[Decrypt Error] {e}")
return None
def send_dm(recipient_hex: str, message: str):
"""Send encrypted DM to recipient."""
try:
encrypted = bridge_key.encrypt_message(message, recipient_hex)
dm_event = Event(
kind=4,
content=encrypted,
tags=[["p", recipient_hex]],
public_key=BRIDGE_HEX
)
bridge_key.sign_event(dm_event)
relay_manager = RelayManager()
relay_manager.add_relay(RELAY_URL)
relay_manager.open_connections()
time.sleep(1)
relay_manager.publish_event(dm_event)
time.sleep(1)
relay_manager.close_connections()
print(f"[Out] DM sent to {recipient_hex[:16]}...")
return True
except Exception as e:
print(f"[Send Error] {e}")
return False
# === MAIN LOOP ===
def process_event(event: Event):
"""Process an incoming Nostr event."""
if event.kind != 4:
return
p_tags = [t[1] for t in event.tags if t[0] == "p"]
if BRIDGE_HEX not in p_tags:
return
sender = event.public_key
if sender not in AUTHORIZED_HEX:
print(f"[Reject] DM from unauthorized key: {sender[:16]}...")
return
plaintext = decrypt_dm(event)
if not plaintext:
print("[Error] Failed to decrypt DM")
return
print(f"[In] DM from authorized operator: {plaintext[:60]}...")
cmd = parse_command(plaintext)
if not cmd:
send_dm(sender, "❓ Commands:\n!status\n!issue \"Title\" \"Body\"\n!comment #123 \"Text\"\nOr send freeform text to create issue")
return
print(f"[Exec] {cmd['action']}")
response = execute_command(cmd)
send_dm(sender, response)
print(f"[Out] Response: {response[:60]}...")
def run_bridge():
print("=" * 60)
print(f"Nostr DM → Gitea Bridge MVP ({BRIDGE_IDENTITY} identity)")
print("=" * 60)
print(f"Relay: {RELAY_URL}")
print(f"Listening for DMs to: {BRIDGE_NPUB}")
print(f"Authorized operators: {', '.join(AUTHORIZED_NPUBS)}")
print("-" * 60)
relay_manager = RelayManager()
relay_manager.add_relay(RELAY_URL)
filter_json = {
"kinds": [4],
"#p": [BRIDGE_HEX],
"since": int(time.time())
}
relay_manager.add_subscription("dm_listener", filter_json)
relay_manager.open_connections()
print("[Bridge] Listening for operator DMs... (Ctrl+C to exit)")
print(f"[Bridge] npub for Nostur contact: {BRIDGE_NPUB}")
try:
print("[Bridge] Event loop started. Waiting for DMs...")
while True:
# Poll for events without run_sync (API compatibility)
while relay_manager.message_pool.has_events():
event_msg = relay_manager.message_pool.get_event()
if event_msg:
process_event(event_msg.event)
time.sleep(2)
except KeyboardInterrupt:
print("\n[Bridge] Shutting down...")
finally:
relay_manager.close_connections()
if __name__ == "__main__":
run_bridge()

View File

@@ -0,0 +1,19 @@
[Unit]
Description=Nostr DM to Gitea Bridge
Documentation=https://gitea.com/Timmy_Foundation/timmy-config/issues/186
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=root
WorkingDirectory=/opt/timmy/nostr-dm-bridge
EnvironmentFile=-/opt/timmy/nostr-dm-bridge/.env
Environment="HOME=/root"
Environment="PYTHONUNBUFFERED=1"
ExecStart=/usr/bin/python3 /opt/timmy/nostr-dm-bridge/bridge_allegro.py
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,133 @@
#!/usr/bin/env python3
"""Validate the Nostr DM bridge configuration and core behaviors."""
import json
import os
import sys
import urllib.request
from pathlib import Path
GITEA_URL = os.environ.get("GITEA_URL", "http://143.198.27.163:3000").rstrip("/")
GITEA_TOKEN = os.environ.get("GITEA_TOKEN", "").strip()
GITEA_TOKEN_FILE = Path(os.environ.get("GITEA_TOKEN_FILE", "~/.config/gitea/timmy-token")).expanduser()
KEYSTORE_PATH = Path(os.environ.get("KEYSTORE_PATH", "~/.timmy/nostr/agent_keys.json")).expanduser()
BRIDGE_IDENTITY = os.environ.get("BRIDGE_IDENTITY", "allegro")
BRIDGE_NSEC = os.environ.get("BRIDGE_NSEC", "").strip()
DEFAULT_REPO = os.environ.get("DEFAULT_REPO", "Timmy_Foundation/timmy-config")
AUTHORIZED_NPUBS = [x.strip() for x in os.environ.get("AUTHORIZED_NPUBS", "npub1t8exnw6sp7vtxar8q5teyr0ueq0rvtgqpq5jkzylegupqulxfqwq4j66p5").split(",") if x.strip()]
print("=" * 60)
print("Nostr DM Bridge Component Test")
print("=" * 60)
if not GITEA_TOKEN and GITEA_TOKEN_FILE.exists():
GITEA_TOKEN = GITEA_TOKEN_FILE.read_text().strip()
if not GITEA_TOKEN:
print(f"✗ Missing Gitea token. Set GITEA_TOKEN or create {GITEA_TOKEN_FILE}")
sys.exit(1)
print("✓ Gitea token loaded")
try:
from nostr.key import PrivateKey, PublicKey
print("✓ nostr library imported")
except ImportError as e:
print(f"✗ Failed to import nostr: {e}")
sys.exit(1)
if not BRIDGE_NSEC:
try:
with open(KEYSTORE_PATH) as f:
keystore = json.load(f)
BRIDGE_NSEC = keystore[BRIDGE_IDENTITY]["nsec"]
print(f"{BRIDGE_IDENTITY} nsec loaded from keystore")
except Exception as e:
bridge_key = PrivateKey()
BRIDGE_NSEC = bridge_key.bech32()
print(f"! Bridge identity {BRIDGE_IDENTITY!r} not available in {KEYSTORE_PATH}: {e}")
print("✓ Generated ephemeral bridge key for local validation")
else:
print("✓ Bridge nsec loaded from BRIDGE_NSEC")
try:
bridge_key = PrivateKey.from_nsec(BRIDGE_NSEC)
bridge_npub = bridge_key.public_key.bech32()
print(f"✓ Bridge npub: {bridge_npub}")
except Exception as e:
print(f"✗ Key derivation failed: {e}")
sys.exit(1)
try:
authorized_hex = [PublicKey.from_npub(npub).hex() for npub in AUTHORIZED_NPUBS]
print(f"✓ Authorized operators parsed: {len(authorized_hex)}")
except Exception as e:
print(f"✗ Failed to parse AUTHORIZED_NPUBS: {e}")
sys.exit(1)
try:
headers = {"Authorization": f"token {GITEA_TOKEN}"}
req = urllib.request.Request(f"{GITEA_URL}/api/v1/user", headers=headers)
with urllib.request.urlopen(req, timeout=5) as resp:
user = json.loads(resp.read().decode())
print(f"✓ Gitea API connected as: {user.get('login')}")
except Exception as e:
print(f"✗ Gitea API failed: {e}")
sys.exit(1)
os.environ.setdefault("GITEA_TOKEN", GITEA_TOKEN)
os.environ.setdefault("BRIDGE_NSEC", BRIDGE_NSEC)
os.environ.setdefault("DEFAULT_REPO", DEFAULT_REPO)
os.environ.setdefault("AUTHORIZED_NPUBS", ",".join(AUTHORIZED_NPUBS))
print("\n" + "-" * 60)
print("Testing command parsers...")
try:
from bridge_allegro import parse_command
except Exception as e:
print(f"✗ Failed to import bridge_allegro: {e}")
sys.exit(1)
cases = [
("!status", "get_status"),
('!issue "Test Title" "Test Body"', "create_issue"),
('!comment #123 "Hello"', "add_comment"),
("This is a freeform message", "create_issue"),
]
for text, expected_action in cases:
cmd = parse_command(text)
if not cmd or cmd.get("action") != expected_action:
print(f"✗ Parser mismatch for {text!r}: {cmd}")
sys.exit(1)
if expected_action in {"create_issue", "add_comment"} and cmd.get("repo") != DEFAULT_REPO:
print(f"✗ Parser repo mismatch for {text!r}: {cmd.get('repo')} != {DEFAULT_REPO}")
sys.exit(1)
print(f"{text!r} -> {expected_action}")
print("✓ All parser tests passed")
print("\n" + "-" * 60)
print("Testing encryption round-trip...")
try:
test_message = "Test DM content for round-trip validation"
recipient_hex = authorized_hex[0]
encrypted = bridge_key.encrypt_message(test_message, recipient_hex)
decrypted = bridge_key.decrypt_message(encrypted, recipient_hex)
if decrypted != test_message:
print(f"✗ Decryption mismatch: {decrypted!r}")
sys.exit(1)
print("✓ Encryption round-trip successful")
except Exception as e:
print(f"✗ Encryption test failed: {e}")
sys.exit(1)
print("\n" + "=" * 60)
print("ALL TESTS PASSED")
print("=" * 60)
print("\nBridge is ready to run:")
print(" python3 bridge_allegro.py")
print("\nFor operator testing:")
print(f" 1. Open Nostur")
print(f" 2. Send DM to: {bridge_npub}")
print(" 3. Try: !status")