Add Notification Backend Protocol (#24)
All checks were successful
CI / ci (push) Successful in 21s
All checks were successful
CI / ci (push) Successful in 21s
Reviewed-on: #24
This commit was merged in pull request #24.
This commit is contained in:
108
utils/check_notifications.py
Normal file
108
utils/check_notifications.py
Normal file
@@ -0,0 +1,108 @@
|
||||
# /// script
|
||||
# requires-python = ">=3.12"
|
||||
# dependencies = ["redis>=5.0", "pydantic>=2.0"]
|
||||
# ///
|
||||
"""Check pending notifications for a phone number.
|
||||
|
||||
Usage:
|
||||
REDIS_HOST=10.33.22.4 uv run utils/check_notifications.py <phone>
|
||||
REDIS_HOST=10.33.22.4 uv run utils/check_notifications.py <phone> --since 2026-01-01
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import redis
|
||||
from pydantic import AliasChoices, BaseModel, Field, ValidationError
|
||||
|
||||
|
||||
class Notification(BaseModel):
|
||||
id_notificacion: str = Field(
|
||||
validation_alias=AliasChoices("id_notificacion", "idNotificacion"),
|
||||
)
|
||||
telefono: str
|
||||
timestamp_creacion: datetime = Field(
|
||||
validation_alias=AliasChoices("timestamp_creacion", "timestampCreacion"),
|
||||
)
|
||||
texto: str
|
||||
nombre_evento_dialogflow: str = Field(
|
||||
validation_alias=AliasChoices(
|
||||
"nombre_evento_dialogflow", "nombreEventoDialogflow"
|
||||
),
|
||||
)
|
||||
codigo_idioma_dialogflow: str = Field(
|
||||
default="es",
|
||||
validation_alias=AliasChoices(
|
||||
"codigo_idioma_dialogflow", "codigoIdiomaDialogflow"
|
||||
),
|
||||
)
|
||||
parametros: dict = Field(default_factory=dict)
|
||||
status: str
|
||||
|
||||
|
||||
class NotificationSession(BaseModel):
|
||||
session_id: str = Field(
|
||||
validation_alias=AliasChoices("session_id", "sessionId"),
|
||||
)
|
||||
telefono: str
|
||||
fecha_creacion: datetime = Field(
|
||||
validation_alias=AliasChoices("fecha_creacion", "fechaCreacion"),
|
||||
)
|
||||
ultima_actualizacion: datetime = Field(
|
||||
validation_alias=AliasChoices("ultima_actualizacion", "ultimaActualizacion"),
|
||||
)
|
||||
notificaciones: list[Notification]
|
||||
|
||||
|
||||
HOST = os.environ.get("REDIS_HOST", "127.0.0.1")
|
||||
PORT = int(os.environ.get("REDIS_PORT", "6379"))
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if len(sys.argv) < 2:
|
||||
print(f"Usage: {sys.argv[0]} <phone> [--since YYYY-MM-DD]")
|
||||
sys.exit(1)
|
||||
|
||||
phone = sys.argv[1]
|
||||
since = None
|
||||
if "--since" in sys.argv:
|
||||
idx = sys.argv.index("--since")
|
||||
since = datetime.fromisoformat(sys.argv[idx + 1]).replace(tzinfo=UTC)
|
||||
|
||||
r = redis.Redis(host=HOST, port=PORT, decode_responses=True, socket_connect_timeout=5)
|
||||
raw = r.get(f"notification:{phone}")
|
||||
|
||||
if not raw:
|
||||
print(f"📭 No notifications found for {phone}")
|
||||
sys.exit(0)
|
||||
|
||||
try:
|
||||
session = NotificationSession.model_validate(json.loads(raw))
|
||||
except ValidationError as e:
|
||||
print(f"❌ Invalid notification data for {phone}:\n{e}")
|
||||
sys.exit(1)
|
||||
|
||||
active = [n for n in session.notificaciones if n.status == "active"]
|
||||
|
||||
if since:
|
||||
active = [n for n in active if n.timestamp_creacion >= since]
|
||||
|
||||
if not active:
|
||||
print(f"📭 No {'new ' if since else ''}active notifications for {phone}")
|
||||
sys.exit(0)
|
||||
|
||||
print(f"🔔 {len(active)} active notification(s) for {phone}\n")
|
||||
for i, n in enumerate(active, 1):
|
||||
categoria = n.parametros.get("notification_po_Categoria", "")
|
||||
print(f" [{i}] {n.timestamp_creacion.isoformat()}")
|
||||
print(f" ID: {n.id_notificacion}")
|
||||
if categoria:
|
||||
print(f" Category: {categoria}")
|
||||
print(f" {n.texto[:120]}{'…' if len(n.texto) > 120 else ''}")
|
||||
print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
107
utils/check_notifications_firestore.py
Normal file
107
utils/check_notifications_firestore.py
Normal file
@@ -0,0 +1,107 @@
|
||||
# /// script
|
||||
# requires-python = ">=3.12"
|
||||
# dependencies = ["google-cloud-firestore>=2.0", "pyyaml>=6.0"]
|
||||
# ///
|
||||
"""Check recent notifications in Firestore for a phone number.
|
||||
|
||||
Usage:
|
||||
uv run utils/check_notifications_firestore.py <phone>
|
||||
uv run utils/check_notifications_firestore.py <phone> --hours 24
|
||||
"""
|
||||
|
||||
import sys
|
||||
import time
|
||||
|
||||
import yaml
|
||||
from google.cloud.firestore import Client
|
||||
|
||||
_SECONDS_PER_HOUR = 3600
|
||||
_DEFAULT_WINDOW_HOURS = 48
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if len(sys.argv) < 2:
|
||||
print(f"Usage: {sys.argv[0]} <phone> [--hours N]")
|
||||
sys.exit(1)
|
||||
|
||||
phone = sys.argv[1]
|
||||
window_hours = _DEFAULT_WINDOW_HOURS
|
||||
if "--hours" in sys.argv:
|
||||
idx = sys.argv.index("--hours")
|
||||
window_hours = float(sys.argv[idx + 1])
|
||||
|
||||
with open("config.yaml") as f:
|
||||
cfg = yaml.safe_load(f)
|
||||
|
||||
db = Client(
|
||||
project=cfg["google_cloud_project"],
|
||||
database=cfg["firestore_db"],
|
||||
)
|
||||
|
||||
collection_path = cfg["notifications_collection_path"]
|
||||
doc_ref = db.collection(collection_path).document(phone)
|
||||
doc = doc_ref.get()
|
||||
|
||||
if not doc.exists:
|
||||
print(f"📭 No notifications found for {phone}")
|
||||
sys.exit(0)
|
||||
|
||||
data = doc.to_dict() or {}
|
||||
all_notifications = data.get("notificaciones", [])
|
||||
|
||||
if not all_notifications:
|
||||
print(f"📭 No notifications found for {phone}")
|
||||
sys.exit(0)
|
||||
|
||||
cutoff = time.time() - (window_hours * _SECONDS_PER_HOUR)
|
||||
|
||||
def _ts(n: dict) -> float:
|
||||
return n.get("timestamp_creacion", n.get("timestampCreacion", 0))
|
||||
|
||||
recent = [n for n in all_notifications if _ts(n) >= cutoff]
|
||||
recent.sort(key=_ts, reverse=True)
|
||||
|
||||
if not recent:
|
||||
print(
|
||||
f"📭 No notifications within the last"
|
||||
f" {window_hours:.0f}h for {phone}"
|
||||
)
|
||||
sys.exit(0)
|
||||
|
||||
print(
|
||||
f"🔔 {len(recent)} notification(s) for {phone}"
|
||||
f" (last {window_hours:.0f}h)\n"
|
||||
)
|
||||
now = time.time()
|
||||
for i, n in enumerate(recent, 1):
|
||||
ts = _ts(n)
|
||||
ago = _format_time_ago(now, ts)
|
||||
categoria = n.get("parametros", {}).get(
|
||||
"notification_po_Categoria", ""
|
||||
)
|
||||
texto = n.get("texto", "")
|
||||
print(f" [{i}] {ago}")
|
||||
print(f" ID: {n.get('id_notificacion', '?')}")
|
||||
if categoria:
|
||||
print(f" Category: {categoria}")
|
||||
print(f" {texto[:120]}{'…' if len(texto) > 120 else ''}")
|
||||
print()
|
||||
|
||||
|
||||
def _format_time_ago(now: float, ts: float) -> str:
|
||||
diff = max(now - ts, 0)
|
||||
minutes = int(diff // 60)
|
||||
hours = int(diff // _SECONDS_PER_HOUR)
|
||||
|
||||
if minutes < 1:
|
||||
return "justo ahora"
|
||||
if minutes < 60:
|
||||
return f"hace {minutes} min"
|
||||
if hours < 24:
|
||||
return f"hace {hours}h"
|
||||
days = hours // 24
|
||||
return f"hace {days}d"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
159
utils/register_notification.py
Normal file
159
utils/register_notification.py
Normal file
@@ -0,0 +1,159 @@
|
||||
# /// script
|
||||
# requires-python = ">=3.12"
|
||||
# dependencies = ["redis>=5.0"]
|
||||
# ///
|
||||
"""Register a new notification in Redis for a given phone number.
|
||||
|
||||
Usage:
|
||||
REDIS_HOST=10.33.22.4 uv run utils/register_notification.py <phone>
|
||||
|
||||
The notification content is randomly picked from a predefined set based on
|
||||
existing entries in Memorystore.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import sys
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import redis
|
||||
|
||||
HOST = os.environ.get("REDIS_HOST", "127.0.0.1")
|
||||
PORT = int(os.environ.get("REDIS_PORT", "6379"))
|
||||
TTL_SECONDS = 18 * 24 * 3600 # ~18 days, matching existing keys
|
||||
|
||||
NOTIFICATION_TEMPLATES = [
|
||||
{
|
||||
"texto": (
|
||||
"Se detectó un cargo de $1,500 en tu cuenta"
|
||||
),
|
||||
"parametros": {
|
||||
"notification_po_transaction_id": "TXN15367",
|
||||
"notification_po_amount": 5814,
|
||||
},
|
||||
},
|
||||
{
|
||||
"texto": (
|
||||
"💡 Recuerda que puedes obtener tu Adelanto de Nómina en cualquier"
|
||||
" momento, sólo tienes que seleccionar Solicitud adelanto de Nómina"
|
||||
" en tu app."
|
||||
),
|
||||
"parametros": {
|
||||
"notification_po_Categoria": "Adelanto de Nómina solicitud",
|
||||
"notification_po_caption": "Adelanto de Nómina",
|
||||
"notification_po_CTA": "Realiza la solicitud desde tu app",
|
||||
"notification_po_Descripcion": (
|
||||
"Notificación para incentivar la solicitud de Adelanto de"
|
||||
" Nómina desde la APP"
|
||||
),
|
||||
"notification_po_link": (
|
||||
"https://public-media.yalochat.com/banorte/"
|
||||
"1764025754-10e06fb8-b4e6-484c-ad0b-7f677429380e-03-ADN-Toque-1.jpg"
|
||||
),
|
||||
"notification_po_Beneficios": (
|
||||
"Tasa de interés de 0%: Solicita tu Adelanto sin preocuparte"
|
||||
" por los intereses, así de fácil. No requiere garantías o aval."
|
||||
),
|
||||
"notification_po_Requisitos": (
|
||||
"Tener Cuenta Digital o Cuenta Digital Ilimitada con dispersión"
|
||||
" de Nómina No tener otro Adelanto vigente Ingreso neto mensual"
|
||||
" mayor a $2,000"
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
"texto": (
|
||||
"Estás a un clic de Programa de Lealtad, entra a tu app y finaliza"
|
||||
" Tu contratación en instantes. ⏱ 🤳"
|
||||
),
|
||||
"parametros": {
|
||||
"notification_po_Categoria": "Tarjeta de Crédito Contratación",
|
||||
"notification_po_caption": "Tarjeta de Crédito",
|
||||
"notification_po_CTA": "Entra a tu app y contrata en instantes",
|
||||
"notification_po_Descripcion": (
|
||||
"Notificación para terminar el proceso de contratación de la"
|
||||
" Tarjeta de Crédito, desde la app"
|
||||
),
|
||||
"notification_po_link": (
|
||||
"https://public-media.yalochat.com/banorte/"
|
||||
"1764363798-05dadc23-6e47-447c-8e38-0346f25e31c0-15-TDC-Toque-1.jpg"
|
||||
),
|
||||
"notification_po_Beneficios": (
|
||||
"Acceso al Programa de Lealtad: Cada compra suma, gana"
|
||||
" experiencias exclusivas"
|
||||
),
|
||||
"notification_po_Requisitos": (
|
||||
"Ser persona física o física con actividad empresarial."
|
||||
" Ingresos mínimos de $2,000 pesos mensuales. Sin historial de"
|
||||
" crédito o con buró positivo"
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
"texto": (
|
||||
"🚀 ¿Listo para obtener tu Cápsula Plus? Continúa en tu app y"
|
||||
" termina al instante. Conoce más en: va.app"
|
||||
),
|
||||
"parametros": {},
|
||||
},
|
||||
{
|
||||
"texto": (
|
||||
"🚀 ¿Listo para obtener tu Cuenta Digital ilimitada? Continúa en"
|
||||
" tu app y termina al instante. Conoce más en: va.app"
|
||||
),
|
||||
"parametros": {},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if len(sys.argv) < 2:
|
||||
print(f"Usage: {sys.argv[0]} <phone>")
|
||||
sys.exit(1)
|
||||
|
||||
phone = sys.argv[1]
|
||||
r = redis.Redis(host=HOST, port=PORT, decode_responses=True, socket_connect_timeout=5)
|
||||
|
||||
now = datetime.now(UTC).isoformat()
|
||||
template = random.choice(NOTIFICATION_TEMPLATES)
|
||||
notification = {
|
||||
"id_notificacion": str(uuid.uuid4()),
|
||||
"telefono": phone,
|
||||
"timestamp_creacion": now,
|
||||
"texto": template["texto"],
|
||||
"nombre_evento_dialogflow": "notificacion",
|
||||
"codigo_idioma_dialogflow": "es",
|
||||
"parametros": template["parametros"],
|
||||
"status": "active",
|
||||
}
|
||||
|
||||
session_key = f"notification:{phone}"
|
||||
existing = r.get(session_key)
|
||||
|
||||
if existing:
|
||||
session = json.loads(existing)
|
||||
session["ultima_actualizacion"] = now
|
||||
session["notificaciones"].append(notification)
|
||||
else:
|
||||
session = {
|
||||
"session_id": phone,
|
||||
"telefono": phone,
|
||||
"fecha_creacion": now,
|
||||
"ultima_actualizacion": now,
|
||||
"notificaciones": [notification],
|
||||
}
|
||||
|
||||
r.set(session_key, json.dumps(session, ensure_ascii=False), ex=TTL_SECONDS)
|
||||
r.set(f"notification:phone_to_notification:{phone}", phone, ex=TTL_SECONDS)
|
||||
|
||||
total = len(session["notificaciones"])
|
||||
print(f"✅ Registered notification for {phone}")
|
||||
print(f" ID: {notification['id_notificacion']}")
|
||||
print(f" Text: {template['texto'][:80]}...")
|
||||
print(f" Total notifications for this phone: {total}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
108
utils/register_notification_firestore.py
Normal file
108
utils/register_notification_firestore.py
Normal file
@@ -0,0 +1,108 @@
|
||||
# /// script
|
||||
# requires-python = ">=3.12"
|
||||
# dependencies = ["google-cloud-firestore>=2.0", "pyyaml>=6.0"]
|
||||
# ///
|
||||
"""Register a new notification in Firestore for a given phone number.
|
||||
|
||||
Usage:
|
||||
uv run utils/register_notification_firestore.py <phone>
|
||||
|
||||
Reads project/database/collection settings from config.yaml.
|
||||
"""
|
||||
|
||||
import random
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import yaml
|
||||
from google.cloud.firestore import Client
|
||||
|
||||
NOTIFICATION_TEMPLATES = [
|
||||
{
|
||||
"texto": "Se detectó un cargo de $1,500 en tu cuenta",
|
||||
"parametros": {
|
||||
"notification_po_transaction_id": "TXN15367",
|
||||
"notification_po_amount": 5814,
|
||||
},
|
||||
},
|
||||
{
|
||||
"texto": (
|
||||
"💡 Recuerda que puedes obtener tu Adelanto de Nómina en"
|
||||
" cualquier momento, sólo tienes que seleccionar Solicitud"
|
||||
" adelanto de Nómina en tu app."
|
||||
),
|
||||
"parametros": {
|
||||
"notification_po_Categoria": "Adelanto de Nómina solicitud",
|
||||
"notification_po_caption": "Adelanto de Nómina",
|
||||
},
|
||||
},
|
||||
{
|
||||
"texto": (
|
||||
"Estás a un clic de Programa de Lealtad, entra a tu app y"
|
||||
" finaliza Tu contratación en instantes. ⏱ 🤳"
|
||||
),
|
||||
"parametros": {
|
||||
"notification_po_Categoria": "Tarjeta de Crédito Contratación",
|
||||
"notification_po_caption": "Tarjeta de Crédito",
|
||||
},
|
||||
},
|
||||
{
|
||||
"texto": (
|
||||
"🚀 ¿Listo para obtener tu Cápsula Plus? Continúa en tu app"
|
||||
" y termina al instante. Conoce más en: va.app"
|
||||
),
|
||||
"parametros": {},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if len(sys.argv) < 2:
|
||||
print(f"Usage: {sys.argv[0]} <phone>")
|
||||
sys.exit(1)
|
||||
|
||||
phone = sys.argv[1]
|
||||
|
||||
with open("config.yaml") as f:
|
||||
cfg = yaml.safe_load(f)
|
||||
|
||||
db = Client(
|
||||
project=cfg["google_cloud_project"],
|
||||
database=cfg["firestore_db"],
|
||||
)
|
||||
|
||||
collection_path = cfg["notifications_collection_path"]
|
||||
doc_ref = db.collection(collection_path).document(phone)
|
||||
|
||||
template = random.choice(NOTIFICATION_TEMPLATES)
|
||||
notification = {
|
||||
"id_notificacion": str(uuid.uuid4()),
|
||||
"telefono": phone,
|
||||
"timestamp_creacion": time.time(),
|
||||
"texto": template["texto"],
|
||||
"nombre_evento_dialogflow": "notificacion",
|
||||
"codigo_idioma_dialogflow": "es",
|
||||
"parametros": template["parametros"],
|
||||
"status": "active",
|
||||
}
|
||||
|
||||
doc = doc_ref.get()
|
||||
if doc.exists:
|
||||
data = doc.to_dict() or {}
|
||||
notifications = data.get("notificaciones", [])
|
||||
notifications.append(notification)
|
||||
doc_ref.update({"notificaciones": notifications})
|
||||
else:
|
||||
doc_ref.set({"notificaciones": [notification]})
|
||||
|
||||
total = len(doc_ref.get().to_dict().get("notificaciones", []))
|
||||
print(f"✅ Registered notification for {phone}")
|
||||
print(f" ID: {notification['id_notificacion']}")
|
||||
print(f" Text: {template['texto'][:80]}...")
|
||||
print(f" Collection: {collection_path}")
|
||||
print(f" Total notifications for this phone: {total}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user