From 3d1b673df81cdc9bfc8535b4b229dcefe9086748 Mon Sep 17 00:00:00 2001 From: KoalaSat Date: Fri, 7 Jun 2024 15:31:22 +0200 Subject: [PATCH] Notifications Endpoint --- api/management/commands/telegram_watcher.py | 10 +- api/models/notification.py | 35 ++++++ api/notifications.py | 116 +++++++++++++------- api/oas_schemas.py | 15 +++ api/serializers.py | 17 ++- api/tasks.py | 28 ++--- api/urls.py | 2 + api/views.py | 47 +++++++- 8 files changed, 207 insertions(+), 63 deletions(-) create mode 100644 api/models/notification.py diff --git a/api/management/commands/telegram_watcher.py b/api/management/commands/telegram_watcher.py index 40e3cb4c..44ce5c69 100644 --- a/api/management/commands/telegram_watcher.py +++ b/api/management/commands/telegram_watcher.py @@ -6,7 +6,7 @@ from django.core.management.base import BaseCommand from django.db import transaction from api.models import Robot -from api.notifications import Telegram +from api.notifications import Notifications from api.utils import get_session @@ -17,7 +17,7 @@ class Command(BaseCommand): bot_token = config("TELEGRAM_TOKEN") updates_url = f"https://api.telegram.org/bot{bot_token}/getUpdates" session = get_session() - telegram = Telegram() + notifications = Notifications() def handle(self, *args, **options): offset = 0 @@ -49,7 +49,7 @@ class Command(BaseCommand): continue parts = message.split(" ") if len(parts) < 2: - self.telegram.send_message( + self.notifications.send_telegram_message( 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.', ) @@ -57,7 +57,7 @@ class Command(BaseCommand): token = parts[-1] robot = Robot.objects.filter(telegram_token=token).first() if not robot: - self.telegram.send_message( + self.notifications.send_telegram_message( chat_id=result["message"]["from"]["id"], 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"][ "language_code" ] - self.telegram.welcome(robot.user) + self.notifications.welcome(robot.user) robot.telegram_enabled = True robot.save( update_fields=[ diff --git a/api/models/notification.py b/api/models/notification.py new file mode 100644 index 00000000..4fb80d87 --- /dev/null +++ b/api/models/notification.py @@ -0,0 +1,35 @@ +# We use custom seeded UUID generation during testing +import uuid + +from decouple import config +from django.contrib.auth.models import User +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 + reference = models.UUIDField(default=custom_uuid, editable=False) + created_at = models.DateTimeField(default=timezone.now) + + user = models.ForeignKey(User, on_delete=models.SET_NULL, default=None) + + # notification details + title = models.CharField(max_length=120, null=False, default=None) + description = models.CharField(max_length=120, default=None, blank=True) + + def __str__(self): + return f"{self.title} {self.description}" diff --git a/api/notifications.py b/api/notifications.py index 02319197..bd724443 100644 --- a/api/notifications.py +++ b/api/notifications.py @@ -3,10 +3,11 @@ from secrets import token_urlsafe from decouple import config from api.models import Order +from api.models import Notification from api.utils import get_session -class Telegram: +class Notifications: """Simple telegram messages using TG's API""" session = get_session() @@ -29,13 +30,25 @@ class Telegram: return context - def send_message(self, chat_id, text): + def send_message(self, robot, title, description): + """Save a message for a user and sends it to Telegram""" + self.save_message(robot, title, description) + self.send_telegram_message(robot.telegram_chat_id, title, description) + + def save_message(self, robot, title, description): + """Save a message for a user""" + notification = Notification() + notification.title = title + notification.description = description + notification.user = robot + notification.save() + + def send_telegram_message(self, chat_id, title, description): """sends a message to a user with telegram notifications enabled""" 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}" - # if it fails, it should keep trying while True: try: @@ -49,10 +62,10 @@ class Telegram: lang = user.robot.telegram_lang_code 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: - text = f"🔔 Hey {user.username}, I will send you notifications about your RoboSats orders." - self.send_message(user.robot.telegram_chat_id, text) + title = f"🔔 Hey {user.username}, I will send you notifications about your RoboSats orders." + self.send_message(user.robot.telegram_chat_id, title) user.robot.telegram_welcomed = True user.robot.save(update_fields=["telegram_welcomed"]) return @@ -61,18 +74,22 @@ class Telegram: if order.maker.robot.telegram_enabled: lang = order.maker.robot.telegram_lang_code 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: - 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." - self.send_message(order.maker.robot.telegram_chat_id, text) + title = f"✅ Hey {order.maker.username}, your order was taken by {order.taker.username}!🥳" + description = f"Visit http://{self.site}/order/{order.id} to proceed with the trade." + self.send_message(order.maker.robot.telegram_chat_id, title, description) if order.taker.robot.telegram_enabled: lang = order.taker.robot.telegram_lang_code 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: - text = f"✅ Hey {order.taker.username}, you just took the order with ID {order.id}." - self.send_message(order.taker.robot.telegram_chat_id, text) + title = f"✅ Hey {order.taker.username}, you just took the order with ID {order.id}." + self.send_message(order.taker.robot.telegram_chat_id, title) return @@ -81,20 +98,26 @@ class Telegram: if user.robot.telegram_enabled: lang = user.robot.telegram_lang_code 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: - 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." - self.send_message(user.robot.telegram_chat_id, text) + title = f"✅ Hey {user.username}, the escrow and invoice have been submitted. The fiat exchange starts now via the platform chat." + description = f"Visit http://{self.site}/order/{order.id} to talk with your counterpart." + self.send_message(user.robot.telegram_chat_id, title, description) return def order_expired_untaken(self, order): if order.maker.robot.telegram_enabled: lang = order.maker.robot.telegram_lang_code 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: - 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." - self.send_message(order.maker.robot.telegram_chat_id, text) + title = f"😪 Hey {order.maker.username}, your order with ID {order.id} has expired without a taker." + description = f"Visit http://{self.site}/order/{order.id} to renew it." + self.send_message(order.maker.robot.telegram_chat_id, title, description) return def trade_successful(self, order): @@ -102,20 +125,28 @@ class Telegram: if user.robot.telegram_enabled: lang = user.robot.telegram_lang_code 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: - text = f"🥳 Your order with ID {order.id} has finished successfully!⚡ Join us @robosats and help us improve." - self.send_message(user.robot.telegram_chat_id, text) + title = ( + f"🥳 Your order with ID {order.id} has finished successfully!" + ) + description = "⚡ Join us @robosats and help us improve." + self.send_message(user.robot.telegram_chat_id, title, description) return def public_order_cancelled(self, order): if order.maker.robot.telegram_enabled: lang = order.maker.robot.telegram_lang_code 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: - text = 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) + 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, title) return def collaborative_cancelled(self, order): @@ -123,10 +154,10 @@ class Telegram: if user.robot.telegram_enabled: lang = user.robot.telegram_lang_code 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: - text = f"❌ Hey {user.username}, your order with ID {str(order.id)} has been collaboratively cancelled." - self.send_message(user.robot.telegram_chat_id, text) + title = f"❌ Hey {user.username}, your order with ID {str(order.id)} has been collaboratively cancelled." + self.send_message(user.robot.telegram_chat_id, title) return def dispute_opened(self, order): @@ -134,18 +165,23 @@ class Telegram: if user.robot.telegram_enabled: lang = user.robot.telegram_lang_code 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: - text = 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) + 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, title) admin_chat_id = config("TELEGRAM_COORDINATOR_CHAT_ID") if len(admin_chat_id) == 0: 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." - self.send_message(admin_chat_id, coordinator_text) + 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 @@ -158,10 +194,10 @@ class Telegram: return order = queryset.last() 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: - text = 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) + 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, title) return def new_chat_message(self, order, chat_message): @@ -190,13 +226,13 @@ class Telegram: user = chat_message.receiver if user.robot.telegram_enabled: - 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(user.robot.telegram_chat_id, text) + 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)}. {notification_reason}" + self.send_message(user.robot.telegram_chat_id, title) return def coordinator_cancelled(self, order): if order.maker.robot.telegram_enabled: - 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.maker.robot.telegram_chat_id, text) + 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." + self.send_message(order.maker.robot.telegram_chat_id, title) return diff --git a/api/oas_schemas.py b/api/oas_schemas.py index 8b6412e6..220f604d 100644 --- a/api/oas_schemas.py +++ b/api/oas_schemas.py @@ -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: get = { "summary": "Get robot info", diff --git a/api/serializers.py b/api/serializers.py index 4bd41fd2..02404260 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -2,7 +2,7 @@ from decouple import config from decimal import Decimal from rest_framework import serializers -from .models import MarketTick, Order +from .models import MarketTick, Order, Notification RETRY_TIME = int(config("RETRY_TIME")) @@ -490,6 +490,21 @@ class OrderDetailSerializer(serializers.ModelSerializer): ) +class NotificationSerializer(serializers.ModelSerializer): + title = serializers.CharField(required=True) + description = serializers.CharField(required=False) + + class Meta: + model = Notification + fields = ("title", "description") + + +class ListNotificationSerializer(serializers.ModelSerializer): + class Meta: + model = Notification + fields = ("title", "description") + + class OrderPublicSerializer(serializers.ModelSerializer): maker_nick = serializers.CharField(required=False) maker_hash_id = serializers.CharField(required=False) diff --git a/api/tasks.py b/api/tasks.py index dee5709a..afb90da0 100644 --- a/api/tasks.py +++ b/api/tasks.py @@ -267,44 +267,44 @@ def send_notification(order_id=None, chat_message_id=None, message=None): if not (order.maker.robot.telegram_enabled or taker_enabled): return - from api.notifications import Telegram + from api.notifications import Notifications - telegram = Telegram() + notifications = Notifications() if message == "welcome": - telegram.welcome(order) + notifications.welcome(order) elif message == "order_expired_untaken": - telegram.order_expired_untaken(order) + notifications.order_expired_untaken(order) elif message == "trade_successful": - telegram.trade_successful(order) + notifications.trade_successful(order) elif message == "public_order_cancelled": - telegram.public_order_cancelled(order) + notifications.public_order_cancelled(order) elif message == "taker_expired_b4bond": - telegram.taker_expired_b4bond(order) + notifications.taker_expired_b4bond(order) elif message == "order_published": - telegram.order_published(order) + notifications.order_published(order) elif message == "order_taken_confirmed": - telegram.order_taken_confirmed(order) + notifications.order_taken_confirmed(order) elif message == "fiat_exchange_starts": - telegram.fiat_exchange_starts(order) + notifications.fiat_exchange_starts(order) elif message == "dispute_opened": - telegram.dispute_opened(order) + notifications.dispute_opened(order) elif message == "collaborative_cancelled": - telegram.collaborative_cancelled(order) + notifications.collaborative_cancelled(order) elif message == "new_chat_message": - telegram.new_chat_message(order, chat_message) + notifications.new_chat_message(order, chat_message) elif message == "coordinator_cancelled": - telegram.coordinator_cancelled(order) + notifications.coordinator_cancelled(order) return diff --git a/api/urls.py b/api/urls.py index 7d8b19c6..7264c1e6 100644 --- a/api/urls.py +++ b/api/urls.py @@ -15,6 +15,7 @@ from .views import ( RobotView, StealthView, TickView, + NotificationsView, ) urlpatterns = [ @@ -36,4 +37,5 @@ urlpatterns = [ path("ticks/", TickView.as_view(), name="ticks"), path("stealth/", StealthView.as_view(), name="stealth"), path("chat/", ChatView.as_view({"get": "get", "post": "post"}), name="chat"), + path("notifications/", NotificationsView.as_view(), name="notifications"), ] diff --git a/api/views.py b/api/views.py index 49a21a2c..069df6a6 100644 --- a/api/views.py +++ b/api/views.py @@ -5,6 +5,8 @@ from django.conf import settings from django.contrib.auth.models import User from django.db.models import Q, Sum 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 rest_framework import status, viewsets from rest_framework.authentication import TokenAuthentication @@ -14,8 +16,15 @@ from rest_framework.response import Response from rest_framework.views import APIView from api.logics import Logics -from api.models import Currency, LNPayment, MarketTick, OnchainPayment, Order -from api.notifications import Telegram +from api.models import ( + Currency, + LNPayment, + MarketTick, + OnchainPayment, + Order, + Notification, +) +from api.notifications import Notifications from api.oas_schemas import ( BookViewSchema, HistoricalViewSchema, @@ -28,6 +37,7 @@ from api.oas_schemas import ( RobotViewSchema, StealthViewSchema, TickViewSchema, + NotificationSchema, ) from api.serializers import ( ClaimRewardSerializer, @@ -39,6 +49,8 @@ from api.serializers import ( StealthSerializer, TickSerializer, UpdateOrderSerializer, + NotificationSerializer, + ListNotificationSerializer, ) from api.utils import ( compute_avg_premium, @@ -659,7 +671,7 @@ class RobotView(APIView): context["last_login"] = user.last_login # 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 has_no_active_order, _, order = Logics.validate_already_maker_or_taker( @@ -730,6 +742,35 @@ class BookView(ListAPIView): return Response(book_data, status=status.HTTP_200_OK) +class NotificationsView(ListAPIView): + authentication_classes = [TokenAuthentication] + permission_classes = [IsAuthenticated] + serializer_class = NotificationSerializer + + @extend_schema(**NotificationSchema.get) + def get(self, request, format=None): + user = request.user + queryset = Notification.objects.filter(user=user) + 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 = Order.objects.filter(created_at__gte=created_at) + + notification_data = [] + for notification in queryset: + data = ListNotificationSerializer(notification).data + data["title"] = str(notification.title) + data["description"] = str(notification.description) + + notification_data.append(data) + + return Response(notification_data, status=status.HTTP_200_OK) + + class InfoView(viewsets.ViewSet): serializer_class = InfoSerializer