Feat: add notifications api endpoint (#1347)

* Notifications Endpoint

* Fix tests

* CR

* Check tests

* Fix Tests

* Fix Chat

* Remove unused notifications

* Fix chat

* Fix chat

* Fix chat
This commit is contained in:
KoalaSat 2024-06-27 16:47:23 +00:00 committed by GitHub
parent 82b5604ecb
commit 1757a9781a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 535 additions and 131 deletions

View File

@ -6,7 +6,7 @@ from django.core.management.base import BaseCommand
from django.db import transaction from django.db import transaction
from api.models import Robot from api.models import Robot
from api.notifications import Telegram from api.notifications import Notifications
from api.utils import get_session from api.utils import get_session
@ -17,7 +17,7 @@ class Command(BaseCommand):
bot_token = config("TELEGRAM_TOKEN") bot_token = config("TELEGRAM_TOKEN")
updates_url = f"https://api.telegram.org/bot{bot_token}/getUpdates" updates_url = f"https://api.telegram.org/bot{bot_token}/getUpdates"
session = get_session() session = get_session()
telegram = Telegram() notifications = Notifications()
def handle(self, *args, **options): def handle(self, *args, **options):
offset = 0 offset = 0
@ -49,7 +49,7 @@ class Command(BaseCommand):
continue continue
parts = message.split(" ") parts = message.split(" ")
if len(parts) < 2: if len(parts) < 2:
self.telegram.send_message( self.notifications.send_telegram_message(
chat_id=result["message"]["from"]["id"], chat_id=result["message"]["from"]["id"],
text='You must enable the notifications bot using the RoboSats client. Click on your "Robot robot" -> "Enable Telegram" and follow the link or scan the QR code.', text='You must enable the notifications bot using the RoboSats client. Click on your "Robot robot" -> "Enable Telegram" and follow the link or scan the QR code.',
) )
@ -57,7 +57,7 @@ class Command(BaseCommand):
token = parts[-1] token = parts[-1]
robot = Robot.objects.filter(telegram_token=token).first() robot = Robot.objects.filter(telegram_token=token).first()
if not robot: if not robot:
self.telegram.send_message( self.notifications.send_telegram_message(
chat_id=result["message"]["from"]["id"], chat_id=result["message"]["from"]["id"],
text=f'Wops, invalid token! There is no Robot with telegram chat token "{token}"', text=f'Wops, invalid token! There is no Robot with telegram chat token "{token}"',
) )
@ -71,7 +71,7 @@ class Command(BaseCommand):
robot.telegram_lang_code = result["message"]["from"][ robot.telegram_lang_code = result["message"]["from"][
"language_code" "language_code"
] ]
self.telegram.welcome(robot.user) self.notifications.welcome(robot.user)
robot.telegram_enabled = True robot.telegram_enabled = True
robot.save( robot.save(
update_fields=[ update_fields=[

View File

@ -0,0 +1,26 @@
# Generated by Django 5.0.6 on 2024-06-14 18:31
import django.db.models.deletion
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('api', '0046_alter_currency_currency'),
]
operations = [
migrations.CreateModel(
name='Notification',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(default=django.utils.timezone.now)),
('title', models.CharField(default=None, max_length=240)),
('description', models.CharField(blank=True, default=None, max_length=240)),
('order', models.ForeignKey(default=None, on_delete=django.db.models.deletion.CASCADE, to='api.order')),
('robot', models.ForeignKey(default=None, on_delete=django.db.models.deletion.CASCADE, to='api.robot')),
],
),
]

View File

@ -4,5 +4,14 @@ from .market_tick import MarketTick
from .onchain_payment import OnchainPayment from .onchain_payment import OnchainPayment
from .order import Order from .order import Order
from .robot import Robot from .robot import Robot
from .notification import Notification
__all__ = ["Currency", "LNPayment", "MarketTick", "OnchainPayment", "Order", "Robot"] __all__ = [
"Currency",
"LNPayment",
"MarketTick",
"OnchainPayment",
"Order",
"Robot",
"Notification",
]

View File

@ -0,0 +1,35 @@
# We use custom seeded UUID generation during testing
import uuid
from decouple import config
from api.models import Order, Robot
from django.db import models
from django.utils import timezone
if config("TESTING", cast=bool, default=False):
import random
import string
random.seed(1)
chars = string.ascii_lowercase + string.digits
def custom_uuid():
return uuid.uuid5(uuid.NAMESPACE_DNS, "".join(random.choices(chars, k=20)))
else:
custom_uuid = uuid.uuid4
class Notification(models.Model):
# notification info
created_at = models.DateTimeField(default=timezone.now)
robot = models.ForeignKey(Robot, on_delete=models.CASCADE, default=None)
order = models.ForeignKey(Order, on_delete=models.CASCADE, default=None)
# notification details
title = models.CharField(max_length=240, null=False, default=None)
description = models.CharField(max_length=240, default=None, blank=True)
def __str__(self):
return f"{self.title} {self.description}"

View File

@ -1,12 +1,14 @@
from secrets import token_urlsafe from secrets import token_urlsafe
from decouple import config from decouple import config
from api.models import (
from api.models import Order Order,
Notification,
)
from api.utils import get_session from api.utils import get_session
class Telegram: class Notifications:
"""Simple telegram messages using TG's API""" """Simple telegram messages using TG's API"""
session = get_session() session = get_session()
@ -29,13 +31,24 @@ class Telegram:
return context return context
def send_message(self, chat_id, text): def send_message(self, order, robot, title, description=""):
"""Save a message for a user and sends it to Telegram"""
self.save_message(order, robot, title, description)
if robot.telegram_enabled:
self.send_telegram_message(robot.telegram_chat_id, title, description)
def save_message(self, order, robot, title, description):
"""Save a message for a user"""
Notification.objects.create(
title=title, description=description, robot=robot, order=order
)
def send_telegram_message(self, chat_id, title, description):
"""sends a message to a user with telegram notifications enabled""" """sends a message to a user with telegram notifications enabled"""
bot_token = config("TELEGRAM_TOKEN") bot_token = config("TELEGRAM_TOKEN")
text = f"{title} {description}"
message_url = f"https://api.telegram.org/bot{bot_token}/sendMessage?chat_id={chat_id}&text={text}" message_url = f"https://api.telegram.org/bot{bot_token}/sendMessage?chat_id={chat_id}&text={text}"
# if it fails, it should keep trying # if it fails, it should keep trying
while True: while True:
try: try:
@ -49,108 +62,116 @@ class Telegram:
lang = user.robot.telegram_lang_code lang = user.robot.telegram_lang_code
if lang == "es": if lang == "es":
text = f"🔔 Hola {user.username}, te enviaré notificaciones sobre tus órdenes en RoboSats." title = f"🔔 Hola {user.username}, te enviaré notificaciones sobre tus órdenes en RoboSats."
else: else:
text = f"🔔 Hey {user.username}, I will send you notifications about your RoboSats orders." title = f"🔔 Hey {user.username}, I will send you notifications about your RoboSats orders."
self.send_message(user.robot.telegram_chat_id, text) self.send_telegram_message(user.robot.telegram_chat_id, title)
user.robot.telegram_welcomed = True user.robot.telegram_welcomed = True
user.robot.save(update_fields=["telegram_welcomed"]) user.robot.save(update_fields=["telegram_welcomed"])
return return
def order_taken_confirmed(self, order): def order_taken_confirmed(self, order):
if order.maker.robot.telegram_enabled:
lang = order.maker.robot.telegram_lang_code lang = order.maker.robot.telegram_lang_code
if lang == "es": if lang == "es":
text = f"✅ Hey {order.maker.username} ¡Tu orden con ID {order.id} ha sido tomada por {order.taker.username}!🥳 Visita http://{self.site}/order/{order.id} para continuar." title = f"✅ Hey {order.maker.username} ¡Tu orden con ID {order.id} ha sido tomada por {order.taker.username}!🥳"
description = f"Visita http://{self.site}/order/{order.id} para continuar."
else: else:
text = f"✅ Hey {order.maker.username}, your order was taken by {order.taker.username}!🥳 Visit http://{self.site}/order/{order.id} to proceed with the trade." title = f"✅ Hey {order.maker.username}, your order was taken by {order.taker.username}!🥳"
self.send_message(order.maker.robot.telegram_chat_id, text) description = (
f"Visit http://{self.site}/order/{order.id} to proceed with the trade."
)
self.send_message(order, order.maker.robot, title, description)
if order.taker.robot.telegram_enabled:
lang = order.taker.robot.telegram_lang_code lang = order.taker.robot.telegram_lang_code
if lang == "es": if lang == "es":
text = f"✅ Hey {order.taker.username}, acabas de tomar la orden con ID {order.id}." title = f"✅ Hey {order.taker.username}, acabas de tomar la orden con ID {order.id}."
else: else:
text = f"✅ Hey {order.taker.username}, you just took the order with ID {order.id}." title = f"✅ Hey {order.taker.username}, you just took the order with ID {order.id}."
self.send_message(order.taker.robot.telegram_chat_id, text) self.send_message(order, order.taker.robot, title)
return return
def fiat_exchange_starts(self, order): def fiat_exchange_starts(self, order):
for user in [order.maker, order.taker]: for user in [order.maker, order.taker]:
if user.robot.telegram_enabled:
lang = user.robot.telegram_lang_code lang = user.robot.telegram_lang_code
if lang == "es": if lang == "es":
text = f"✅ Hey {user.username}, el depósito de garantía y el recibo del comprador han sido recibidos. Es hora de enviar el dinero fiat. Visita http://{self.site}/order/{order.id} para hablar con tu contraparte." title = f"✅ Hey {user.username}, el depósito de garantía y el recibo del comprador han sido recibidos. Es hora de enviar el dinero fiat."
description = f"Visita http://{self.site}/order/{order.id} para hablar con tu contraparte."
else: else:
text = f"✅ Hey {user.username}, the escrow and invoice have been submitted. The fiat exchange starts now via the platform chat. Visit http://{self.site}/order/{order.id} to talk with your counterpart." title = f"✅ Hey {user.username}, the escrow and invoice have been submitted. The fiat exchange starts now via the platform chat."
self.send_message(user.robot.telegram_chat_id, text) description = f"Visit http://{self.site}/order/{order.id} to talk with your counterpart."
self.send_message(order, user.robot, title, description)
return return
def order_expired_untaken(self, order): def order_expired_untaken(self, order):
if order.maker.robot.telegram_enabled:
lang = order.maker.robot.telegram_lang_code lang = order.maker.robot.telegram_lang_code
if lang == "es": if lang == "es":
text = f"😪 Hey {order.maker.username}, tu orden con ID {order.id} ha expirado sin ser tomada por ningún robot. Visita http://{self.site}/order/{order.id} para renovarla." title = f"😪 Hey {order.maker.username}, tu orden con ID {order.id} ha expirado sin ser tomada por ningún robot."
description = f"Visita http://{self.site}/order/{order.id} para renovarla."
else: else:
text = f"😪 Hey {order.maker.username}, your order with ID {order.id} has expired without a taker. Visit http://{self.site}/order/{order.id} to renew it." title = f"😪 Hey {order.maker.username}, your order with ID {order.id} has expired without a taker."
self.send_message(order.maker.robot.telegram_chat_id, text) description = f"Visit http://{self.site}/order/{order.id} to renew it."
self.send_message(order, order.maker.robot, title, description)
return return
def trade_successful(self, order): def trade_successful(self, order):
for user in [order.maker, order.taker]: for user in [order.maker, order.taker]:
if user.robot.telegram_enabled:
lang = user.robot.telegram_lang_code lang = user.robot.telegram_lang_code
if lang == "es": if lang == "es":
text = f"🥳 ¡Tu orden con ID {order.id} ha finalizado exitosamente!⚡ Únete a nosotros en @robosats_es y ayúdanos a mejorar." title = f"🥳 ¡Tu orden con ID {order.id} ha finalizado exitosamente!"
description = (
"⚡ Únete a nosotros en @robosats_es y ayúdanos a mejorar."
)
else: else:
text = f"🥳 Your order with ID {order.id} has finished successfully!⚡ Join us @robosats and help us improve." title = f"🥳 Your order with ID {order.id} has finished successfully!"
self.send_message(user.robot.telegram_chat_id, text) description = "⚡ Join us @robosats and help us improve."
self.send_message(order, user.robot, title, description)
return return
def public_order_cancelled(self, order): def public_order_cancelled(self, order):
if order.maker.robot.telegram_enabled:
lang = order.maker.robot.telegram_lang_code lang = order.maker.robot.telegram_lang_code
if lang == "es": if lang == "es":
text = f"❌ Hey {order.maker.username}, has cancelado tu orden pública con ID {order.id}." title = f"❌ Hey {order.maker.username}, has cancelado tu orden pública con ID {order.id}."
else: else:
text = f"❌ Hey {order.maker.username}, you have cancelled your public order with ID {order.id}." title = f"❌ Hey {order.maker.username}, you have cancelled your public order with ID {order.id}."
self.send_message(order.maker.robot.telegram_chat_id, text) self.send_message(order, order.maker.robot, title)
return return
def collaborative_cancelled(self, order): def collaborative_cancelled(self, order):
for user in [order.maker, order.taker]: for user in [order.maker, order.taker]:
if user.robot.telegram_enabled:
lang = user.robot.telegram_lang_code lang = user.robot.telegram_lang_code
if lang == "es": if lang == "es":
text = f"❌ Hey {user.username}, tu orden con ID {str(order.id)} fue cancelada colaborativamente." title = f"❌ Hey {user.username}, tu orden con ID {str(order.id)} fue cancelada colaborativamente."
else: else:
text = f"❌ Hey {user.username}, your order with ID {str(order.id)} has been collaboratively cancelled." title = f"❌ Hey {user.username}, your order with ID {str(order.id)} has been collaboratively cancelled."
self.send_message(user.robot.telegram_chat_id, text) self.send_message(order, user.robot, title)
return return
def dispute_opened(self, order): def dispute_opened(self, order):
for user in [order.maker, order.taker]: for user in [order.maker, order.taker]:
if user.robot.telegram_enabled:
lang = user.robot.telegram_lang_code lang = user.robot.telegram_lang_code
if lang == "es": if lang == "es":
text = f"⚖️ Hey {user.username}, la orden con ID {str(order.id)} ha entrado en disputa." title = f"⚖️ Hey {user.username}, la orden con ID {str(order.id)} ha entrado en disputa."
else: else:
text = f"⚖️ Hey {user.username}, a dispute has been opened on your order with ID {str(order.id)}." title = f"⚖️ Hey {user.username}, a dispute has been opened on your order with ID {str(order.id)}."
self.send_message(user.robot.telegram_chat_id, text) self.send_message(order, user.robot, title)
admin_chat_id = config("TELEGRAM_COORDINATOR_CHAT_ID") admin_chat_id = config("TELEGRAM_COORDINATOR_CHAT_ID")
if len(admin_chat_id) == 0: if len(admin_chat_id) == 0:
return return
coordinator_text = f"There is a new dispute opened for the order with ID {str(order.id)}. Visit http://{self.site}/coordinator/api/order/{str(order.id)}/change to proceed." coordinator_text = (
self.send_message(admin_chat_id, coordinator_text) f"There is a new dispute opened for the order with ID {str(order.id)}."
)
coordinator_description = f"Visit http://{self.site}/coordinator/api/order/{str(order.id)}/change to proceed."
self.send_telegram_message(
admin_chat_id, coordinator_text, coordinator_description
)
return return
def order_published(self, order): def order_published(self, order):
if order.maker.robot.telegram_enabled:
lang = order.maker.robot.telegram_lang_code lang = order.maker.robot.telegram_lang_code
# In weird cases the order cannot be found (e.g. it is cancelled) # In weird cases the order cannot be found (e.g. it is cancelled)
queryset = Order.objects.filter(maker=order.maker) queryset = Order.objects.filter(maker=order.maker)
@ -158,10 +179,10 @@ class Telegram:
return return
order = queryset.last() order = queryset.last()
if lang == "es": if lang == "es":
text = f"✅ Hey {order.maker.username}, tu orden con ID {str(order.id)} es pública en el libro de ordenes." title = f"✅ Hey {order.maker.username}, tu orden con ID {str(order.id)} es pública en el libro de ordenes."
else: else:
text = f"✅ Hey {order.maker.username}, your order with ID {str(order.id)} is public in the order book." title = f"✅ Hey {order.maker.username}, your order with ID {str(order.id)} is public in the order book."
self.send_message(order.maker.robot.telegram_chat_id, text) self.send_message(order, order.maker.robot, title)
return return
def new_chat_message(self, order, chat_message): def new_chat_message(self, order, chat_message):
@ -189,14 +210,12 @@ class Telegram:
notification_reason = f"(You receive this notification because this was the first in-chat message. You will only be notified again if there is a gap bigger than {TIMEGAP} minutes between messages)" notification_reason = f"(You receive this notification because this was the first in-chat message. You will only be notified again if there is a gap bigger than {TIMEGAP} minutes between messages)"
user = chat_message.receiver user = chat_message.receiver
if user.robot.telegram_enabled: title = f"💬 Hey {user.username}, a new chat message in-app was sent to you by {chat_message.sender.username} for order ID {str(order.id)}."
text = f"💬 Hey {user.username}, a new chat message in-app was sent to you by {chat_message.sender.username} for order ID {str(order.id)}. {notification_reason}" self.send_message(order, user.robot, title, notification_reason)
self.send_message(user.robot.telegram_chat_id, text)
return return
def coordinator_cancelled(self, order): def coordinator_cancelled(self, order):
if order.maker.robot.telegram_enabled: title = f"🛠️ Your order with ID {order.id} has been cancelled by the coordinator {config('COORDINATOR_ALIAS', cast=str, default='NoAlias')} for the upcoming maintenance stop."
text = f"🛠️ Your order with ID {order.id} has been cancelled by the coordinator {config('COORDINATOR_ALIAS', cast=str, default='NoAlias')} for the upcoming maintenance stop." self.send_message(order, order.maker.robot, title)
self.send_message(order.maker.robot.telegram_chat_id, text)
return return

View File

@ -378,6 +378,21 @@ class BookViewSchema:
} }
class NotificationSchema:
get = {
"summary": "Get robot notifications",
"description": "Get a list of notifications sent to the robot.",
"parameters": [
OpenApiParameter(
name="created_at",
location=OpenApiParameter.QUERY,
description=("Shows notifications created AFTER this date."),
type=str,
),
],
}
class RobotViewSchema: class RobotViewSchema:
get = { get = {
"summary": "Get robot info", "summary": "Get robot info",

View File

@ -2,7 +2,7 @@ from decouple import config
from decimal import Decimal from decimal import Decimal
from rest_framework import serializers from rest_framework import serializers
from .models import MarketTick, Order from .models import MarketTick, Order, Notification
RETRY_TIME = int(config("RETRY_TIME")) RETRY_TIME = int(config("RETRY_TIME"))
@ -490,6 +490,12 @@ class OrderDetailSerializer(serializers.ModelSerializer):
) )
class ListNotificationSerializer(serializers.ModelSerializer):
class Meta:
model = Notification
fields = ("title", "description", "order_id")
class OrderPublicSerializer(serializers.ModelSerializer): class OrderPublicSerializer(serializers.ModelSerializer):
maker_nick = serializers.CharField(required=False) maker_nick = serializers.CharField(required=False)
maker_hash_id = serializers.CharField(required=False) maker_hash_id = serializers.CharField(required=False)

View File

@ -263,48 +263,44 @@ def send_notification(order_id=None, chat_message_id=None, message=None):
chat_message = Message.objects.get(id=chat_message_id) chat_message = Message.objects.get(id=chat_message_id)
order = chat_message.order order = chat_message.order
taker_enabled = False if order.taker is None else order.taker.robot.telegram_enabled from api.notifications import Notifications
if not (order.maker.robot.telegram_enabled or taker_enabled):
return
from api.notifications import Telegram notifications = Notifications()
telegram = Telegram()
if message == "welcome": if message == "welcome":
telegram.welcome(order) notifications.welcome(order)
elif message == "order_expired_untaken": elif message == "order_expired_untaken":
telegram.order_expired_untaken(order) notifications.order_expired_untaken(order)
elif message == "trade_successful": elif message == "trade_successful":
telegram.trade_successful(order) notifications.trade_successful(order)
elif message == "public_order_cancelled": elif message == "public_order_cancelled":
telegram.public_order_cancelled(order) notifications.public_order_cancelled(order)
elif message == "taker_expired_b4bond": elif message == "taker_expired_b4bond":
telegram.taker_expired_b4bond(order) notifications.taker_expired_b4bond(order)
elif message == "order_published": elif message == "order_published":
telegram.order_published(order) notifications.order_published(order)
elif message == "order_taken_confirmed": elif message == "order_taken_confirmed":
telegram.order_taken_confirmed(order) notifications.order_taken_confirmed(order)
elif message == "fiat_exchange_starts": elif message == "fiat_exchange_starts":
telegram.fiat_exchange_starts(order) notifications.fiat_exchange_starts(order)
elif message == "dispute_opened": elif message == "dispute_opened":
telegram.dispute_opened(order) notifications.dispute_opened(order)
elif message == "collaborative_cancelled": elif message == "collaborative_cancelled":
telegram.collaborative_cancelled(order) notifications.collaborative_cancelled(order)
elif message == "new_chat_message": elif message == "new_chat_message":
telegram.new_chat_message(order, chat_message) notifications.new_chat_message(order, chat_message)
elif message == "coordinator_cancelled": elif message == "coordinator_cancelled":
telegram.coordinator_cancelled(order) notifications.coordinator_cancelled(order)
return return

View File

@ -15,6 +15,7 @@ from .views import (
RobotView, RobotView,
StealthView, StealthView,
TickView, TickView,
NotificationsView,
) )
urlpatterns = [ urlpatterns = [
@ -36,4 +37,5 @@ urlpatterns = [
path("ticks/", TickView.as_view(), name="ticks"), path("ticks/", TickView.as_view(), name="ticks"),
path("stealth/", StealthView.as_view(), name="stealth"), path("stealth/", StealthView.as_view(), name="stealth"),
path("chat/", ChatView.as_view({"get": "get", "post": "post"}), name="chat"), path("chat/", ChatView.as_view({"get": "get", "post": "post"}), name="chat"),
path("notifications/", NotificationsView.as_view(), name="notifications"),
] ]

View File

@ -5,6 +5,8 @@ from django.conf import settings
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.db.models import Q, Sum from django.db.models import Q, Sum
from django.utils import timezone from django.utils import timezone
from django.utils.dateparse import parse_datetime
from django.http import HttpResponseBadRequest
from drf_spectacular.utils import extend_schema from drf_spectacular.utils import extend_schema
from rest_framework import status, viewsets from rest_framework import status, viewsets
from rest_framework.authentication import TokenAuthentication from rest_framework.authentication import TokenAuthentication
@ -14,8 +16,15 @@ from rest_framework.response import Response
from rest_framework.views import APIView from rest_framework.views import APIView
from api.logics import Logics from api.logics import Logics
from api.models import Currency, LNPayment, MarketTick, OnchainPayment, Order from api.models import (
from api.notifications import Telegram Currency,
LNPayment,
MarketTick,
OnchainPayment,
Order,
Notification,
)
from api.notifications import Notifications
from api.oas_schemas import ( from api.oas_schemas import (
BookViewSchema, BookViewSchema,
HistoricalViewSchema, HistoricalViewSchema,
@ -28,6 +37,7 @@ from api.oas_schemas import (
RobotViewSchema, RobotViewSchema,
StealthViewSchema, StealthViewSchema,
TickViewSchema, TickViewSchema,
NotificationSchema,
) )
from api.serializers import ( from api.serializers import (
ClaimRewardSerializer, ClaimRewardSerializer,
@ -39,6 +49,7 @@ from api.serializers import (
StealthSerializer, StealthSerializer,
TickSerializer, TickSerializer,
UpdateOrderSerializer, UpdateOrderSerializer,
ListNotificationSerializer,
) )
from api.utils import ( from api.utils import (
compute_avg_premium, compute_avg_premium,
@ -659,7 +670,7 @@ class RobotView(APIView):
context["last_login"] = user.last_login context["last_login"] = user.last_login
# Adds/generate telegram token and whether it is enabled # Adds/generate telegram token and whether it is enabled
context = {**context, **Telegram.get_context(user)} context = {**context, **Notifications.get_context(user)}
# return active order or last made order if any # return active order or last made order if any
has_no_active_order, _, order = Logics.validate_already_maker_or_taker( has_no_active_order, _, order = Logics.validate_already_maker_or_taker(
@ -730,6 +741,35 @@ class BookView(ListAPIView):
return Response(book_data, status=status.HTTP_200_OK) return Response(book_data, status=status.HTTP_200_OK)
class NotificationsView(ListAPIView):
authentication_classes = [TokenAuthentication]
permission_classes = [IsAuthenticated]
serializer_class = ListNotificationSerializer
@extend_schema(**NotificationSchema.get)
def get(self, request, format=None):
robot = request.user.robot
queryset = Notification.objects.filter(robot=robot).order_by("-created_at")
created_at = request.GET.get("created_at")
if created_at:
created_at = parse_datetime(created_at)
if not created_at:
return HttpResponseBadRequest("Invalid date format")
queryset = queryset.filter(created_at__gte=created_at)
notification_data = []
for notification in queryset:
data = self.serializer_class(notification).data
data["title"] = str(notification.title)
data["description"] = str(notification.description)
data["order_id"] = notification.order.id
notification_data.append(data)
return Response(notification_data, status=status.HTTP_200_OK)
class InfoView(viewsets.ViewSet): class InfoView(viewsets.ViewSet):
serializer_class = InfoSerializer serializer_class = InfoSerializer

View File

@ -284,6 +284,30 @@ paths:
type: string type: string
description: Reason for the failure description: Reason for the failure
description: '' description: ''
/api/notifications/:
get:
operationId: notifications_list
description: Get a list of notifications sent to the robot.
summary: Get robot notifications
parameters:
- in: query
name: created_at
schema:
type: string
description: Shows notifications created AFTER this date.
tags:
- notifications
security:
- tokenAuth: []
responses:
'200':
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/ListNotification'
description: ''
/api/order/: /api/order/:
get: get:
operationId: order_retrieve operationId: order_retrieve
@ -1070,6 +1094,20 @@ components:
- swap_enabled - swap_enabled
- taker_fee - taker_fee
- version - version
ListNotification:
type: object
properties:
title:
type: string
maxLength: 240
description:
type: string
maxLength: 240
order_id:
type: integer
readOnly: true
required:
- order_id
ListOrder: ListOrder:
type: object type: object
properties: properties:

View File

@ -239,6 +239,16 @@ class TradeTest(BaseAPITestCase):
self.assertIsNone(data["taker"], "New order's taker is not null") self.assertIsNone(data["taker"], "New order's taker is not null")
self.assert_order_logs(data["id"]) self.assert_order_logs(data["id"])
maker_headers = trade.get_robot_auth(trade.maker_index)
response = self.client.get(reverse("notifications"), **maker_headers)
self.assertResponse(response)
notifications_data = list(response.json())
self.assertEqual(
len(notifications_data),
0,
"User has no notification",
)
def test_make_order_on_blocked_country(self): def test_make_order_on_blocked_country(self):
""" """
Test the creation of an F2F order on a geoblocked location Test the creation of an F2F order on a geoblocked location
@ -342,6 +352,16 @@ class TradeTest(BaseAPITestCase):
self.assertIsInstance(public_data["price_now"], float) self.assertIsInstance(public_data["price_now"], float)
self.assertIsInstance(data["satoshis_now"], int) self.assertIsInstance(data["satoshis_now"], int)
maker_headers = trade.get_robot_auth(trade.maker_index)
response = self.client.get(reverse("notifications"), **maker_headers)
self.assertResponse(response)
notifications_data = list(response.json())
self.assertEqual(notifications_data[0]["order_id"], trade.order_id)
self.assertEqual(
notifications_data[0]["title"],
f"✅ Hey {data['maker_nick']}, your order with ID {trade.order_id} is public in the order book.",
)
# Cancel order to avoid leaving pending HTLCs after a successful test # Cancel order to avoid leaving pending HTLCs after a successful test
trade.cancel_order() trade.cancel_order()
@ -506,6 +526,25 @@ class TradeTest(BaseAPITestCase):
self.assertEqual(data["status_message"], Order.Status(Order.Status.CHA).label) self.assertEqual(data["status_message"], Order.Status(Order.Status.CHA).label)
self.assertFalse(data["is_fiat_sent"]) self.assertFalse(data["is_fiat_sent"])
maker_headers = trade.get_robot_auth(trade.maker_index)
response = self.client.get(reverse("notifications"), **maker_headers)
self.assertResponse(response)
notifications_data = list(response.json())
self.assertEqual(notifications_data[0]["order_id"], trade.order_id)
self.assertEqual(
notifications_data[0]["title"],
f"✅ Hey {data['maker_nick']}, the escrow and invoice have been submitted. The fiat exchange starts now via the platform chat.",
)
taker_headers = trade.get_robot_auth(trade.taker_index)
response = self.client.get(reverse("notifications"), **taker_headers)
self.assertResponse(response)
notifications_data = list(response.json())
self.assertEqual(notifications_data[0]["order_id"], trade.order_id)
self.assertEqual(
notifications_data[0]["title"],
f"✅ Hey {data['taker_nick']}, the escrow and invoice have been submitted. The fiat exchange starts now via the platform chat.",
)
# Cancel order to avoid leaving pending HTLCs after a successful test # Cancel order to avoid leaving pending HTLCs after a successful test
trade.cancel_order(trade.maker_index) trade.cancel_order(trade.maker_index)
trade.cancel_order(trade.taker_index) trade.cancel_order(trade.taker_index)
@ -532,6 +571,27 @@ class TradeTest(BaseAPITestCase):
self.assertEqual(data["status_message"], Order.Status(Order.Status.CHA).label) self.assertEqual(data["status_message"], Order.Status(Order.Status.CHA).label)
self.assertFalse(data["is_fiat_sent"]) self.assertFalse(data["is_fiat_sent"])
maker_headers = trade.get_robot_auth(trade.maker_index)
maker_nick = read_file(f"tests/robots/{trade.maker_index}/nickname")
response = self.client.get(reverse("notifications"), **maker_headers)
self.assertResponse(response)
notifications_data = list(response.json())
self.assertEqual(notifications_data[0]["order_id"], trade.order_id)
self.assertEqual(
notifications_data[0]["title"],
f"✅ Hey {maker_nick}, the escrow and invoice have been submitted. The fiat exchange starts now via the platform chat.",
)
taker_headers = trade.get_robot_auth(trade.taker_index)
taker_nick = read_file(f"tests/robots/{trade.taker_index}/nickname")
response = self.client.get(reverse("notifications"), **taker_headers)
self.assertResponse(response)
notifications_data = list(response.json())
self.assertEqual(notifications_data[0]["order_id"], trade.order_id)
self.assertEqual(
notifications_data[0]["title"],
f"✅ Hey {taker_nick}, the escrow and invoice have been submitted. The fiat exchange starts now via the platform chat.",
)
# Cancel order to avoid leaving pending HTLCs after a successful test # Cancel order to avoid leaving pending HTLCs after a successful test
trade.cancel_order(trade.maker_index) trade.cancel_order(trade.maker_index)
trade.cancel_order(trade.taker_index) trade.cancel_order(trade.taker_index)
@ -595,6 +655,25 @@ class TradeTest(BaseAPITestCase):
self.assert_order_logs(data["id"]) self.assert_order_logs(data["id"])
maker_headers = trade.get_robot_auth(trade.maker_index)
response = self.client.get(reverse("notifications"), **maker_headers)
self.assertResponse(response)
notifications_data = list(response.json())
self.assertEqual(notifications_data[0]["order_id"], trade.order_id)
self.assertEqual(
notifications_data[0]["title"],
f"🥳 Your order with ID {str(trade.order_id)} has finished successfully!",
)
taker_headers = trade.get_robot_auth(trade.taker_index)
response = self.client.get(reverse("notifications"), **taker_headers)
self.assertResponse(response)
notifications_data = list(response.json())
self.assertEqual(notifications_data[0]["order_id"], trade.order_id)
self.assertEqual(
notifications_data[0]["title"],
f"🥳 Your order with ID {str(trade.order_id)} has finished successfully!",
)
def test_successful_LN(self): def test_successful_LN(self):
""" """
Tests a trade from order creation until Sats sent to buyer Tests a trade from order creation until Sats sent to buyer
@ -670,6 +749,17 @@ class TradeTest(BaseAPITestCase):
data["bad_request"], "This order has been cancelled by the maker" data["bad_request"], "This order has been cancelled by the maker"
) )
maker_headers = trade.get_robot_auth(trade.maker_index)
maker_nick = read_file(f"tests/robots/{trade.maker_index}/nickname")
response = self.client.get(reverse("notifications"), **maker_headers)
self.assertResponse(response)
notifications_data = list(response.json())
self.assertEqual(notifications_data[0]["order_id"], trade.order_id)
self.assertEqual(
notifications_data[0]["title"],
f"❌ Hey {maker_nick}, you have cancelled your public order with ID {trade.order_id}.",
)
def test_collaborative_cancel_order_in_chat(self): def test_collaborative_cancel_order_in_chat(self):
""" """
Tests the collaborative cancellation of an order in the chat state Tests the collaborative cancellation of an order in the chat state
@ -702,6 +792,27 @@ class TradeTest(BaseAPITestCase):
"This order has been cancelled collaborativelly", "This order has been cancelled collaborativelly",
) )
maker_headers = trade.get_robot_auth(trade.maker_index)
maker_nick = read_file(f"tests/robots/{trade.maker_index}/nickname")
response = self.client.get(reverse("notifications"), **maker_headers)
self.assertResponse(response)
notifications_data = list(response.json())
self.assertEqual(notifications_data[0]["order_id"], trade.order_id)
self.assertEqual(
notifications_data[0]["title"],
f"❌ Hey {maker_nick}, your order with ID {trade.order_id} has been collaboratively cancelled.",
)
taker_headers = trade.get_robot_auth(trade.taker_index)
taker_nick = read_file(f"tests/robots/{trade.taker_index}/nickname")
response = self.client.get(reverse("notifications"), **taker_headers)
self.assertResponse(response)
notifications_data = list(response.json())
self.assertEqual(notifications_data[0]["order_id"], trade.order_id)
self.assertEqual(
notifications_data[0]["title"],
f"❌ Hey {taker_nick}, your order with ID {trade.order_id} has been collaboratively cancelled.",
)
def test_created_order_expires(self): def test_created_order_expires(self):
""" """
Tests the expiration of a public order Tests the expiration of a public order
@ -740,11 +851,7 @@ class TradeTest(BaseAPITestCase):
""" """
trade = Trade(self.client) trade = Trade(self.client)
trade.publish_order() trade.publish_order()
trade.expire_order()
# Change order expiry to now
order = Order.objects.get(id=trade.response.json()["id"])
order.expires_at = datetime.now()
order.save()
# Make orders expire # Make orders expire
trade.clean_orders() trade.clean_orders()
@ -767,6 +874,16 @@ class TradeTest(BaseAPITestCase):
self.assert_order_logs(data["id"]) self.assert_order_logs(data["id"])
maker_headers = trade.get_robot_auth(trade.maker_index)
response = self.client.get(reverse("notifications"), **maker_headers)
self.assertResponse(response)
notifications_data = list(response.json())
self.assertEqual(notifications_data[0]["order_id"], trade.order_id)
self.assertEqual(
notifications_data[0]["title"],
f"😪 Hey {data['maker_nick']}, your order with ID {str(trade.order_id)} has expired without a taker.",
)
def test_taken_order_expires(self): def test_taken_order_expires(self):
""" """
Tests the expiration of a public order Tests the expiration of a public order
@ -777,9 +894,7 @@ class TradeTest(BaseAPITestCase):
trade.lock_taker_bond() trade.lock_taker_bond()
# Change order expiry to now # Change order expiry to now
order = Order.objects.get(id=trade.response.json()["id"]) trade.expire_order()
order.expires_at = datetime.now()
order.save()
# Make orders expire # Make orders expire
trade.clean_orders() trade.clean_orders()
@ -876,19 +991,41 @@ class TradeTest(BaseAPITestCase):
self.assertTrue(response.json()["peer_connected"]) self.assertTrue(response.json()["peer_connected"])
# Post new message as maker # Post new message as maker
body = {"PGP_message": message, "order_id": trade.order_id, "offset": 0} trade.send_chat_message(message, trade.maker_index)
response = self.client.post(path, data=body, **maker_headers) self.assertResponse(trade.response)
self.assertEqual(trade.response.status_code, 200)
self.assertEqual(trade.response.json()["messages"][0]["message"], message)
self.assertTrue(trade.response.json()["peer_connected"])
taker_headers = trade.get_robot_auth(trade.taker_index)
response = self.client.get(reverse("notifications"), **taker_headers)
self.assertResponse(response) self.assertResponse(response)
self.assertEqual(response.status_code, 200) notifications_data = list(response.json())
self.assertEqual(response.json()["messages"][0]["message"], message) self.assertEqual(notifications_data[0]["order_id"], trade.order_id)
self.assertTrue(response.json()["peer_connected"]) self.assertEqual(
notifications_data[0]["title"],
f"💬 Hey {taker_nick}, a new chat message in-app was sent to you by {maker_nick} for order ID {trade.order_id}.",
)
# Post new message as taker without offset, so response should not have messages. # Post new message as taker without offset, so response should not have messages.
body = {"PGP_message": message + " 2", "order_id": trade.order_id} trade.send_chat_message(message + " 2", trade.taker_index)
response = self.client.post(path, data=body, **taker_headers) self.assertResponse(trade.response)
self.assertEqual(trade.response.status_code, 200)
self.assertEqual(trade.response.json()["messages"][0]["message"], message)
self.assertEqual(
trade.response.json()["messages"][1]["message"], message + " 2"
)
maker_headers = trade.get_robot_auth(trade.maker_index)
response = self.client.get(reverse("notifications"), **maker_headers)
self.assertResponse(response) self.assertResponse(response)
self.assertEqual(response.status_code, 200) notifications_data = list(response.json())
self.assertEqual(response.json(), {}) # Nothing in the response self.assertEqual(notifications_data[0]["order_id"], trade.order_id)
# Does not receive notification because user is online
self.assertEqual(
notifications_data[0]["title"],
f"✅ Hey {maker_nick}, the escrow and invoice have been submitted. The fiat exchange starts now via the platform chat.",
)
# Get the two chatroom messages as maker # Get the two chatroom messages as maker
response = self.client.get(path + params, **maker_headers) response = self.client.get(path + params, **maker_headers)
@ -946,6 +1083,25 @@ class TradeTest(BaseAPITestCase):
self.assert_order_logs(data["id"]) self.assert_order_logs(data["id"])
maker_headers = trade.get_robot_auth(trade.maker_index)
response = self.client.get(reverse("notifications"), **maker_headers)
self.assertResponse(response)
notifications_data = list(response.json())
self.assertEqual(notifications_data[0]["order_id"], trade.order_id)
self.assertEqual(
notifications_data[0]["title"],
f"⚖️ Hey {data['maker_nick']}, a dispute has been opened on your order with ID {str(trade.order_id)}.",
)
taker_headers = trade.get_robot_auth(trade.taker_index)
response = self.client.get(reverse("notifications"), **taker_headers)
self.assertResponse(response)
notifications_data = list(response.json())
self.assertEqual(notifications_data[0]["order_id"], trade.order_id)
self.assertEqual(
notifications_data[0]["title"],
f"⚖️ Hey {data['taker_nick']}, a dispute has been opened on your order with ID {str(trade.order_id)}.",
)
def test_order_expires_after_only_maker_messaged(self): def test_order_expires_after_only_maker_messaged(self):
""" """
Tests the expiration of an order in chat where taker never messaged Tests the expiration of an order in chat where taker never messaged
@ -988,6 +1144,25 @@ class TradeTest(BaseAPITestCase):
self.assert_order_logs(data["id"]) self.assert_order_logs(data["id"])
maker_headers = trade.get_robot_auth(trade.maker_index)
response = self.client.get(reverse("notifications"), **maker_headers)
self.assertResponse(response)
notifications_data = list(response.json())
self.assertEqual(notifications_data[0]["order_id"], trade.order_id)
self.assertEqual(
notifications_data[0]["title"],
f"⚖️ Hey {data['maker_nick']}, a dispute has been opened on your order with ID {str(trade.order_id)}.",
)
taker_headers = trade.get_robot_auth(trade.taker_index)
response = self.client.get(reverse("notifications"), **taker_headers)
self.assertResponse(response)
notifications_data = list(response.json())
self.assertEqual(notifications_data[0]["order_id"], trade.order_id)
self.assertEqual(
notifications_data[0]["title"],
f"⚖️ Hey {data['taker_nick']}, a dispute has been opened on your order with ID {str(trade.order_id)}.",
)
def test_withdraw_reward_after_unilateral_cancel(self): def test_withdraw_reward_after_unilateral_cancel(self):
""" """
Tests withdraw rewards as taker after maker cancels order unilaterally Tests withdraw rewards as taker after maker cancels order unilaterally
@ -1058,6 +1233,25 @@ class TradeTest(BaseAPITestCase):
self.assert_order_logs(data["id"]) self.assert_order_logs(data["id"])
maker_headers = trade.get_robot_auth(trade.maker_index)
response = self.client.get(reverse("notifications"), **maker_headers)
self.assertResponse(response)
notifications_data = list(response.json())
self.assertEqual(notifications_data[0]["order_id"], trade.order_id)
self.assertEqual(
notifications_data[0]["title"],
f"⚖️ Hey {data['maker_nick']}, a dispute has been opened on your order with ID {str(trade.order_id)}.",
)
taker_headers = trade.get_robot_auth(trade.taker_index)
response = self.client.get(reverse("notifications"), **taker_headers)
self.assertResponse(response)
notifications_data = list(response.json())
self.assertEqual(notifications_data[0]["order_id"], trade.order_id)
self.assertEqual(
notifications_data[0]["title"],
f"⚖️ Hey {data['taker_nick']}, a dispute has been opened on your order with ID {str(trade.order_id)}.",
)
def test_ticks(self): def test_ticks(self):
""" """
Tests the historical ticks serving endpoint after creating a contract Tests the historical ticks serving endpoint after creating a contract

View File

@ -1,5 +1,5 @@
from unittest.mock import patch from unittest.mock import patch
from datetime import datetime
from django.urls import reverse from django.urls import reverse
from api.management.commands.clean_orders import Command as CleanOrders from api.management.commands.clean_orders import Command as CleanOrders
@ -119,6 +119,14 @@ class Trade:
body = {"action": "cancel"} body = {"action": "cancel"}
self.response = self.client.post(path + params, body, **headers) self.response = self.client.post(path + params, body, **headers)
@patch("api.tasks.send_notification.delay", send_notification)
def send_chat_message(self, message, robot_index=1):
path = reverse("chat")
headers = self.get_robot_auth(robot_index)
body = {"PGP_message": message, "order_id": self.order_id, "offset": 0}
self.response = self.client.post(path, data=body, **headers)
@patch("api.tasks.send_notification.delay", send_notification)
def pause_order(self, robot_index=1): def pause_order(self, robot_index=1):
path = reverse("order") path = reverse("order")
params = f"?order_id={self.order_id}" params = f"?order_id={self.order_id}"
@ -126,11 +134,13 @@ class Trade:
body = {"action": "pause"} body = {"action": "pause"}
self.response = self.client.post(path + params, body, **headers) self.response = self.client.post(path + params, body, **headers)
@patch("api.tasks.send_notification.delay", send_notification)
def follow_hold_invoices(self): def follow_hold_invoices(self):
# A background thread checks every 5 second the status of invoices. We invoke directly during test. # A background thread checks every 5 second the status of invoices. We invoke directly during test.
follower = FollowInvoices() follower = FollowInvoices()
follower.follow_hold_invoices() follower.follow_hold_invoices()
@patch("api.tasks.send_notification.delay", send_notification)
def clean_orders(self): def clean_orders(self):
# A background thread checks every 5 second order expirations. We invoke directly during test. # A background thread checks every 5 second order expirations. We invoke directly during test.
cleaner = CleanOrders() cleaner = CleanOrders()
@ -160,6 +170,7 @@ class Trade:
# Get order # Get order
self.get_order() self.get_order()
@patch("api.tasks.send_notification.delay", send_notification)
def take_order(self): def take_order(self):
path = reverse("order") path = reverse("order")
params = f"?order_id={self.order_id}" params = f"?order_id={self.order_id}"
@ -167,6 +178,7 @@ class Trade:
body = {"action": "take", "amount": self.take_amount} body = {"action": "take", "amount": self.take_amount}
self.response = self.client.post(path + params, body, **headers) self.response = self.client.post(path + params, body, **headers)
@patch("api.tasks.send_notification.delay", send_notification)
def lock_taker_bond(self): def lock_taker_bond(self):
# Takers's first order fetch. Should trigger maker bond hold invoice generation. # Takers's first order fetch. Should trigger maker bond hold invoice generation.
self.get_order(self.taker_index) self.get_order(self.taker_index)
@ -181,6 +193,7 @@ class Trade:
# Get order # Get order
self.get_order(self.taker_index) self.get_order(self.taker_index)
@patch("api.tasks.send_notification.delay", send_notification)
def lock_escrow(self, robot_index): def lock_escrow(self, robot_index):
# Takers's order fetch. Should trigger trade escrow bond hold invoice generation. # Takers's order fetch. Should trigger trade escrow bond hold invoice generation.
self.get_order(robot_index) self.get_order(robot_index)
@ -195,6 +208,7 @@ class Trade:
# Get order # Get order
self.get_order() self.get_order()
@patch("api.tasks.send_notification.delay", send_notification)
def submit_payout_address(self, robot_index=1): def submit_payout_address(self, robot_index=1):
path = reverse("order") path = reverse("order")
params = f"?order_id={self.order_id}" params = f"?order_id={self.order_id}"
@ -213,6 +227,7 @@ class Trade:
} }
self.response = self.client.post(path + params, body, **headers) self.response = self.client.post(path + params, body, **headers)
@patch("api.tasks.send_notification.delay", send_notification)
def submit_payout_invoice(self, robot_index=1, routing_budget=0): def submit_payout_invoice(self, robot_index=1, routing_budget=0):
path = reverse("order") path = reverse("order")
params = f"?order_id={self.order_id}" params = f"?order_id={self.order_id}"
@ -234,6 +249,7 @@ class Trade:
self.response = self.client.post(path + params, body, **headers) self.response = self.client.post(path + params, body, **headers)
@patch("api.tasks.send_notification.delay", send_notification)
def confirm_fiat(self, robot_index=1): def confirm_fiat(self, robot_index=1):
path = reverse("order") path = reverse("order")
params = f"?order_id={self.order_id}" params = f"?order_id={self.order_id}"
@ -241,9 +257,17 @@ class Trade:
body = {"action": "confirm"} body = {"action": "confirm"}
self.response = self.client.post(path + params, body, **headers) self.response = self.client.post(path + params, body, **headers)
@patch("api.tasks.send_notification.delay", send_notification)
def undo_confirm_sent(self, robot_index=1): def undo_confirm_sent(self, robot_index=1):
path = reverse("order") path = reverse("order")
params = f"?order_id={self.order_id}" params = f"?order_id={self.order_id}"
headers = self.get_robot_auth(robot_index) headers = self.get_robot_auth(robot_index)
body = {"action": "undo_confirm"} body = {"action": "undo_confirm"}
self.response = self.client.post(path + params, body, **headers) self.response = self.client.post(path + params, body, **headers)
@patch("api.tasks.send_notification.delay", send_notification)
def expire_order(self):
# Change order expiry to now
order = Order.objects.get(id=self.order_id)
order.expires_at = datetime.now()
order.save()