Skip to content

Commit bd8ae1f

Browse files
authored
Merge pull request #2405 from kanishka0411/feat/custom-http-notifications
feat: custom http notifications
2 parents 8c14f1f + cc67128 commit bd8ae1f

30 files changed

Lines changed: 870 additions & 5 deletions

api/admin.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -504,6 +504,7 @@ class UserRobotAdmin(AdminChangeLinksMixin, admin.ModelAdmin):
504504
"id",
505505
"user_link",
506506
"telegram_enabled",
507+
"webhook_enabled",
507508
"total_contracts",
508509
"earned_rewards",
509510
"claimed_rewards",
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
2+
from django.db import migrations, models
3+
class Migration(migrations.Migration):
4+
5+
dependencies = [
6+
('api', '0055_order_description'),
7+
]
8+
9+
operations = [
10+
migrations.AddField(
11+
model_name='robot',
12+
name='webhook_url',
13+
field=models.URLField(blank=True, max_length=500, null=True),
14+
),
15+
migrations.AddField(
16+
model_name='robot',
17+
name='webhook_api_key',
18+
field=models.CharField(blank=True, max_length=256, null=True),
19+
),
20+
]
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
2+
3+
import django.core.validators
4+
from django.db import migrations, models
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
dependencies = [
10+
('api', '0056_robot_webhook_fields'),
11+
]
12+
13+
operations = [
14+
migrations.AddField(
15+
model_name='robot',
16+
name='webhook_enabled',
17+
field=models.BooleanField(default=False),
18+
),
19+
migrations.AlterField(
20+
model_name='order',
21+
name='escrow_duration',
22+
field=models.PositiveBigIntegerField(default=10799, validators=[django.core.validators.MinValueValidator(1800), django.core.validators.MaxValueValidator(36000)]),
23+
),
24+
]

api/models/robot.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ class Robot(models.Model):
4545
# nostr
4646
nostr_pubkey = models.CharField(max_length=64, null=True, blank=True)
4747

48+
webhook_url = models.URLField(max_length=500, null=True, blank=True)
49+
webhook_api_key = models.CharField(max_length=256, null=True, blank=True)
50+
webhook_enabled = models.BooleanField(default=False, null=False)
51+
4852
# Claimable rewards
4953
earned_rewards = models.PositiveIntegerField(null=False, default=0)
5054
# Total claimed rewards
@@ -87,5 +91,19 @@ def create_user_robot(sender, instance, created, **kwargs):
8791
def save_user_robot(sender, instance, **kwargs):
8892
instance.robot.save()
8993

94+
@staticmethod
95+
def is_valid_onion_url(url):
96+
"""Validates that the URL is a .onion address (Tor only)"""
97+
if not url:
98+
return False
99+
try:
100+
from urllib.parse import urlparse
101+
102+
parsed = urlparse(url)
103+
hostname = parsed.hostname or ""
104+
return hostname.endswith(".onion")
105+
except Exception:
106+
return False
107+
90108
def __str__(self):
91109
return self.user.username

api/notifications.py

Lines changed: 114 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
import json
2+
import logging
3+
import uuid
4+
from datetime import datetime, timezone
15
from secrets import token_urlsafe
26

37
from decouple import config
@@ -8,6 +12,8 @@
812
from api.utils import get_session
913
from api.tasks import nostr_send_notification_event
1014

15+
logger = logging.getLogger("api.notifications")
16+
1117

1218
class Notifications:
1319
"""Simple telegram messages using TG's API"""
@@ -16,7 +22,7 @@ class Notifications:
1622
site = config("HOST_NAME")
1723

1824
def get_context(user):
19-
"""returns context needed to enable TG notifications"""
25+
"""returns context needed to enable TG and webhook notifications"""
2026
context = {}
2127
if user.robot.telegram_enabled:
2228
context["tg_enabled"] = True
@@ -30,17 +36,24 @@ def get_context(user):
3036
context["tg_token"] = user.robot.telegram_token
3137
context["tg_bot_name"] = config("TELEGRAM_BOT_NAME")
3238

39+
context["webhook_enabled"] = user.robot.webhook_enabled
40+
context["webhook_url"] = user.robot.webhook_url or ""
41+
3342
return context
3443

35-
def send_message(self, order, robot, title, description=""):
36-
"""Save a message for a user and sends it to Telegram and/or Nostr"""
44+
def send_message(
45+
self, order, robot, title, description="", event_type="notification"
46+
):
47+
"""Save a message for a user and sends it to Telegram, Nostr, and/or Webhook"""
3748
self.save_message(order, robot, title, description)
3849
if robot.nostr_pubkey:
3950
nostr_send_notification_event.delay(
4051
robot_id=robot.id, order_id=order.id, text=title
4152
)
4253
if robot.telegram_enabled:
4354
self.send_telegram_message(robot.telegram_chat_id, title, description)
55+
if robot.webhook_enabled:
56+
self.send_webhook_message(order, robot, title, description, event_type)
4457

4558
def save_message(self, order, robot, title, description=""):
4659
"""Save a message for a user"""
@@ -62,6 +75,104 @@ def send_telegram_message(self, chat_id, title, description=""):
6275
except Exception:
6376
pass
6477

78+
def send_webhook_message(
79+
self, order, robot, title, description="", event_type="notification"
80+
):
81+
"""Sends a webhook notification to the user's custom HTTP endpoint (Tor .onion only)"""
82+
from api.models import Robot
83+
84+
webhook_url = robot.webhook_url
85+
if not Robot.is_valid_onion_url(webhook_url):
86+
logger.warning(
87+
f"Webhook URL rejected: not a .onion address for robot {robot.id}"
88+
)
89+
return
90+
payload = {
91+
"event_type": event_type,
92+
"event_id": str(uuid.uuid4()),
93+
"timestamp": datetime.now(timezone.utc).isoformat(),
94+
"robot_hash_id": robot.hash_id,
95+
"order": {
96+
"id": order.id,
97+
"type": "BUY" if order.type == Order.Types.BUY else "SELL",
98+
"status": order.status,
99+
},
100+
"message": {
101+
"title": title,
102+
"description": description,
103+
},
104+
"metadata": {
105+
"coordinator": config("COORDINATOR_ALIAS", cast=str, default="Unknown"),
106+
"platform_version": config("VERSION", cast=str, default="Unknown"),
107+
},
108+
}
109+
110+
headers = {
111+
"Content-Type": "application/json",
112+
}
113+
114+
if robot.webhook_api_key:
115+
headers["X-API-Key"] = robot.webhook_api_key
116+
117+
try:
118+
response = self.session.post(
119+
webhook_url,
120+
data=json.dumps(payload),
121+
headers=headers,
122+
timeout=60,
123+
)
124+
response.raise_for_status()
125+
logger.info(f"Webhook sent successfully to robot {robot.id}")
126+
except Exception as e:
127+
logger.error(f"Webhook failed for robot {robot.id}: {e}")
128+
129+
def send_webhook_test(self, robot):
130+
"""Sends a test webhook notification when webhook is first configured"""
131+
from api.models import Robot
132+
133+
webhook_url = robot.webhook_url
134+
if not Robot.is_valid_onion_url(webhook_url):
135+
logger.warning(
136+
f"Webhook test rejected: not a .onion address for robot {robot.id}"
137+
)
138+
return False
139+
140+
payload = {
141+
"event_type": "webhook_test",
142+
"event_id": str(uuid.uuid4()),
143+
"timestamp": datetime.now(timezone.utc).isoformat(),
144+
"robot_hash_id": robot.hash_id,
145+
"message": {
146+
"title": f"🔔 Hey {robot.user.username}, your webhook is configured!",
147+
"description": "You will receive notifications about your RoboSats orders.",
148+
},
149+
"metadata": {
150+
"coordinator": config("COORDINATOR_ALIAS", cast=str, default="Unknown"),
151+
"platform_version": config("VERSION", cast=str, default="Unknown"),
152+
},
153+
}
154+
155+
headers = {
156+
"Content-Type": "application/json",
157+
}
158+
159+
if robot.webhook_api_key:
160+
headers["X-API-Key"] = robot.webhook_api_key
161+
162+
try:
163+
response = self.session.post(
164+
webhook_url,
165+
data=json.dumps(payload),
166+
headers=headers,
167+
timeout=60,
168+
)
169+
response.raise_for_status()
170+
logger.info(f"Webhook test sent successfully to robot {robot.id}")
171+
return True
172+
except Exception as e:
173+
logger.error(f"Webhook test failed for robot {robot.id}: {e}")
174+
return False
175+
65176
def welcome(self, user):
66177
"""User enabled Telegram Notifications"""
67178
lang = user.robot.telegram_lang_code

api/oas_schemas.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -544,6 +544,21 @@ class RobotViewSchema:
544544
"nullable": True,
545545
"description": "Last time the coordinator saw this robot",
546546
},
547+
"webhook_url": {
548+
"type": "string",
549+
"nullable": True,
550+
"description": "Webhook URL for custom notifications (.onion only)",
551+
},
552+
"webhook_enabled": {
553+
"type": "boolean",
554+
"default": False,
555+
"description": "Whether webhook notifications are enabled",
556+
},
557+
"webhook_api_key": {
558+
"type": "string",
559+
"nullable": True,
560+
"description": "API key sent in X-API-Key header for webhook authentication",
561+
},
547562
},
548563
},
549564
},
@@ -555,9 +570,85 @@ class RobotViewSchema:
555570
"public_key": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\n......\n......",
556571
"encrypted_private_key": "-----BEGIN PGP PRIVATE KEY BLOCK-----\n\n......\n......",
557572
"wants_stealth": True,
573+
"webhook_enabled": False,
574+
"webhook_url": None,
575+
},
576+
status_codes=[200],
577+
),
578+
],
579+
}
580+
581+
put = {
582+
"summary": "Update robot webhook settings",
583+
"description": textwrap.dedent(
584+
"""
585+
Update the robot's webhook notification settings.
586+
587+
Webhooks allow you to receive HTTP POST notifications to your own server
588+
when order events occur. **Only `.onion` URLs are accepted** for privacy.
589+
590+
The webhook payload contains:
591+
- `event`: Type of notification (e.g., "order_taken", "escrow_locked")
592+
- `order_id`: The order ID related to the notification
593+
- `message`: Human-readable notification message
594+
- `robot`: Robot nickname
595+
- `coordinator`: Coordinator short alias
596+
- `timestamp`: ISO format timestamp
597+
598+
If `webhook_api_key` is set, it will be sent in the `X-API-Key` header.
599+
600+
**Note:** Webhook is enabled automatically when a valid .onion URL is set.
601+
A test notification will be sent when the webhook URL is configured.
602+
"""
603+
),
604+
"responses": {
605+
200: {
606+
"type": "object",
607+
"properties": {
608+
"webhook_url": {
609+
"type": "string",
610+
"nullable": True,
611+
"description": "Webhook URL (.onion only)",
612+
},
613+
"webhook_enabled": {
614+
"type": "boolean",
615+
"description": "Whether webhook notifications are enabled",
616+
},
617+
"webhook_api_key": {
618+
"type": "string",
619+
"nullable": True,
620+
"description": "API key sent in X-API-Key header",
621+
},
622+
},
623+
},
624+
400: {
625+
"type": "object",
626+
"properties": {
627+
"webhook_url": {
628+
"type": "array",
629+
"items": {"type": "string"},
630+
"description": "Validation errors for webhook_url field",
631+
},
632+
},
633+
},
634+
},
635+
"examples": [
636+
OpenApiExample(
637+
"Successfully updated webhook settings",
638+
value={
639+
"webhook_url": "http://myserver.onion/webhook",
640+
"webhook_enabled": True,
641+
"webhook_api_key": "my-secret-key",
558642
},
559643
status_codes=[200],
560644
),
645+
OpenApiExample(
646+
"Invalid URL (not .onion)",
647+
value={
648+
"webhook_url": ["Webhook URL must be a Tor .onion address"],
649+
},
650+
status_codes=[400],
651+
),
561652
],
562653
}
563654

0 commit comments

Comments
 (0)