Skip to content
28 changes: 28 additions & 0 deletions api/migrations/0058_add_nostr_forward_fields.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Generated by Django 5.1.15 on 2026-02-05 14:55

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('api', '0057_robot_webhook_enabled_alter_order_escrow_duration'),
]

operations = [
migrations.AddField(
model_name='robot',
name='nostr_forward_enabled',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='robot',
name='nostr_forward_pubkey',
field=models.CharField(blank=True, max_length=64, null=True),
),
migrations.AddField(
model_name='robot',
name='nostr_forward_relay',
field=models.CharField(blank=True, max_length=500, null=True),
),
]
18 changes: 18 additions & 0 deletions api/models/robot.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@ class Robot(models.Model):
webhook_api_key = models.CharField(max_length=256, null=True, blank=True)
webhook_enabled = models.BooleanField(default=False, null=False)

# Nostr forwarding to main account
nostr_forward_pubkey = models.CharField(max_length=64, null=True, blank=True)
nostr_forward_relay = models.CharField(max_length=500, null=True, blank=True)
nostr_forward_enabled = models.BooleanField(default=False, null=False)

# Claimable rewards
earned_rewards = models.PositiveIntegerField(null=False, default=0)
# Total claimed rewards
Expand Down Expand Up @@ -105,5 +110,18 @@ def is_valid_onion_url(url):
except Exception:
return False

@staticmethod
def is_valid_onion_relay_url(url):
"""Validates that the URL is a websocket .onion relay."""
if not Robot.is_valid_onion_url(url):
return False
try:
from urllib.parse import urlparse

parsed = urlparse(url)
return parsed.scheme in ["ws", "wss"]
except Exception:
return False

def __str__(self):
return self.user.username
104 changes: 103 additions & 1 deletion api/nostr.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,22 @@

from secp256k1 import PrivateKey
from asgiref.sync import sync_to_async
from nostr_sdk import Keys, Client, EventBuilder, NostrSigner, Kind, Tag, PublicKey
from nostr_sdk import (
Keys,
Client,
ClientBuilder,
Connection,
ConnectionMode,
ConnectionTarget,
EventBuilder,
NostrSigner,
Options,
Kind,
Tag,
PublicKey,
)
from api.models import Order
from api.utils import TOR_PROXY, USE_TOR
from decouple import config


Expand Down Expand Up @@ -64,6 +78,78 @@ async def send_notification_event(self, robot, order, text):
await client.send_private_msg(PublicKey.parse(robot.nostr_pubkey), text, tags)
print("Nostr NOTIFICATION event sent")

try:
await self.send_forward_notification(robot, order, text)
except Exception as e:
print(f"Nostr FORWARD notification failed: {e}")

async def send_forward_notification(self, robot, order, text):
"""Sends notification to user's main account via their .onion relay"""
from api.models import Robot

if not robot.nostr_forward_enabled:
return
if not robot.nostr_forward_pubkey or not robot.nostr_forward_relay:
return
if not Robot.is_valid_onion_relay_url(robot.nostr_forward_relay):
return

print(f"Forwarding nostr notification to {robot.nostr_forward_relay}")

keys = Keys.parse(config("NOSTR_NSEC", cast=str))
client = self.initialize_onion_client(keys)

await client.add_relay(robot.nostr_forward_relay)
await client.connect()

tags = [
Tag.parse(
[
"order_id",
f"{config('COORDINATOR_ALIAS', cast=str).lower()}/{order.id}",
]
),
Tag.parse(["status", str(order.status)]),
]

output = await client.send_private_msg(
PublicKey.parse(robot.nostr_forward_pubkey), text, tags
)
if robot.nostr_forward_relay not in output.success:
raise Exception(output.failed.get(robot.nostr_forward_relay, "relay failed"))
print("Nostr FORWARD notification sent")

async def send_forward_test(self, robot):
"""Sends a test notification to user's main nostr account via their .onion relay"""
from api.models import Robot

if config("NOSTR_NSEC", cast=str, default="") == "":
return

if not robot.nostr_forward_pubkey or not robot.nostr_forward_relay:
return

if not Robot.is_valid_onion_relay_url(robot.nostr_forward_relay):
return

print(f"Sending nostr FORWARD TEST to {robot.nostr_forward_relay}")

keys = Keys.parse(config("NOSTR_NSEC", cast=str))
client = self.initialize_onion_client(keys)

await client.add_relay(robot.nostr_forward_relay)
await client.connect()

coordinator_alias = config("COORDINATOR_ALIAS", cast=str, default="RoboSats")
text = f"🔔 Hey {robot.user.username}, your Nostr forwarding is configured! You will receive order notifications from {coordinator_alias}."

output = await client.send_private_msg(
PublicKey.parse(robot.nostr_forward_pubkey), text, []
)
if robot.nostr_forward_relay not in output.success:
raise Exception(output.failed.get(robot.nostr_forward_relay, "relay failed"))
print("Nostr FORWARD TEST event sent")

async def initialize_client(self, keys):
# Initialize with coordinator Keys
signer = NostrSigner.keys(keys)
Expand All @@ -77,6 +163,22 @@ async def initialize_client(self, keys):

return client

def initialize_onion_client(self, keys):
signer = NostrSigner.keys(keys)

if not USE_TOR:
return Client(signer)

host, port = TOR_PROXY.rsplit(":", 1)
connection = (
Connection()
.mode(ConnectionMode.PROXY(host, int(port)))
.target(ConnectionTarget.ONION)
)
options = Options().connection(connection)

return ClientBuilder().signer(signer).opts(options).build()

@sync_to_async
def get_user_name(self, order):
return order.maker.username
Expand Down
28 changes: 28 additions & 0 deletions api/notifications.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ def get_context(user):
context["webhook_enabled"] = user.robot.webhook_enabled
context["webhook_url"] = user.robot.webhook_url or ""

context["nostr_forward_enabled"] = user.robot.nostr_forward_enabled
context["nostr_forward_relay"] = user.robot.nostr_forward_relay or ""
context["nostr_forward_pubkey"] = user.robot.nostr_forward_pubkey or ""

return context

def send_message(
Expand Down Expand Up @@ -173,6 +177,30 @@ def send_webhook_test(self, robot):
logger.error(f"Webhook test failed for robot {robot.id}: {e}")
return False

def send_nostr_forward_test(self, robot):
"""Sends a test nostr notification to user's main account via their .onion relay"""
from api.models import Robot
from api.tasks import nostr_send_forward_test

relay_url = robot.nostr_forward_relay
pubkey = robot.nostr_forward_pubkey

if not relay_url or not pubkey:
logger.warning(
f"Nostr forward test rejected: missing relay or pubkey for robot {robot.id}"
)
return False

if not Robot.is_valid_onion_relay_url(relay_url):
logger.warning(
f"Nostr forward test rejected: not a websocket .onion relay for robot {robot.id}"
)
return False

nostr_send_forward_test.delay(robot_id=robot.id)
logger.info(f"Nostr forward test queued for robot {robot.id}")
return True

def welcome(self, user):
"""User enabled Telegram Notifications"""
lang = user.robot.telegram_lang_code
Expand Down
76 changes: 74 additions & 2 deletions api/oas_schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -559,6 +559,21 @@ class RobotViewSchema:
"nullable": True,
"description": "API key sent in X-API-Key header for webhook authentication",
},
"nostr_forward_pubkey": {
"type": "string",
"nullable": True,
"description": "Nostr public key (hex or npub) for forwarding notifications",
},
"nostr_forward_relay": {
"type": "string",
"nullable": True,
"description": "Nostr relay websocket URL for forwarding notifications (.onion only)",
},
"nostr_forward_enabled": {
"type": "boolean",
"default": False,
"description": "Whether Nostr forwarding notifications are enabled",
},
},
},
},
Expand All @@ -579,10 +594,10 @@ class RobotViewSchema:
}

put = {
"summary": "Update robot webhook settings",
"summary": "Update robot notification settings",
"description": textwrap.dedent(
"""
Update the robot's webhook notification settings.
Update the robot's webhook and Nostr forwarding notification settings.

Webhooks allow you to receive HTTP POST notifications to your own server
when order events occur. **Only `.onion` URLs are accepted** for privacy.
Expand All @@ -599,6 +614,12 @@ class RobotViewSchema:

**Note:** Webhook is enabled automatically when a valid .onion URL is set.
A test notification will be sent when the webhook URL is configured.

### Nostr Forwarding

You can also configure Nostr direct message forwarding. Provide your main Nostr
public key (hex or npub) and a .onion relay websocket URL to receive trade notifications
as encrypted DMs from the coordinator's Nostr identity.
"""
),
"responses": {
Expand All @@ -619,6 +640,20 @@ class RobotViewSchema:
"nullable": True,
"description": "API key sent in X-API-Key header",
},
"nostr_forward_pubkey": {
"type": "string",
"nullable": True,
"description": "Nostr public key (hex or npub) for forwarding notifications",
},
"nostr_forward_relay": {
"type": "string",
"nullable": True,
"description": "Nostr relay websocket URL for forwarding notifications (.onion only)",
},
"nostr_forward_enabled": {
"type": "boolean",
"description": "Whether Nostr forwarding notifications are enabled",
},
},
},
400: {
Expand All @@ -629,6 +664,16 @@ class RobotViewSchema:
"items": {"type": "string"},
"description": "Validation errors for webhook_url field",
},
"nostr_forward_relay": {
"type": "array",
"items": {"type": "string"},
"description": "Validation errors for nostr_forward_relay field",
},
"nostr_forward_pubkey": {
"type": "array",
"items": {"type": "string"},
"description": "Validation errors for nostr_forward_pubkey field",
},
},
},
},
Expand All @@ -642,13 +687,40 @@ class RobotViewSchema:
},
status_codes=[200],
),
OpenApiExample(
"Successfully updated Nostr forwarding settings",
value={
"nostr_forward_pubkey": "abcd1234...",
"nostr_forward_relay": "ws://relay.onion/",
"nostr_forward_enabled": True,
},
status_codes=[200],
),
OpenApiExample(
"Invalid URL (not .onion)",
value={
"webhook_url": ["Webhook URL must be a Tor .onion address"],
},
status_codes=[400],
),
OpenApiExample(
"Invalid relay URL (not .onion)",
value={
"nostr_forward_relay": [
"Nostr relay must be a Tor .onion websocket URL"
],
},
status_codes=[400],
),
OpenApiExample(
"Invalid Nostr forward pubkey",
value={
"nostr_forward_pubkey": [
"Nostr forward pubkey must be a valid hex or npub public key"
],
},
status_codes=[400],
),
],
}

Expand Down
Loading
Loading