hermes/sofia: arreglo de herramientas (plugins skeen_odoo y skeen_wacrm por HTTP con la llave del entorno, odoo_api_call reescrito sin XML-RPC, 5 skills v2.0.0 con los parametros reales de la API publica); el agente ya consulta servicios y disponibilidad reales y puede agendar
This commit is contained in:
@@ -1,108 +1,97 @@
|
||||
"""Cliente HTTP (compat) para la API pública de SKEEN/Odoo.
|
||||
|
||||
NOTA IMPORTANTE (2026-10):
|
||||
Hermes **no** carga archivos ``.py`` sueltos de este directorio. La
|
||||
herramienta real que usa el agente es el plugin ``skeen_odoo`` en
|
||||
``plugins/skeen_odoo/`` (expuesto a Hermes vía
|
||||
``~/.hermes/plugins/skeen_odoo`` -> symlink a este repo). Este módulo se
|
||||
conserva solo por compatibilidad para scripts que importaban el nombre
|
||||
histórico ``odoo_api_call``.
|
||||
|
||||
Este archivo ANTES usaba XML-RPC con ``common.authenticate(ODOO_DB, 1, ...)``
|
||||
(login ``1`` inválido, ODOO_API_KEY inexistente). Ahora habla HTTP contra
|
||||
``/skeen/api/v1/*`` con ``Authorization: Bearer $ODOO_API_KEY``.
|
||||
|
||||
Endpoints reales (ver skeen_odoo/addons/skeen_whatsapp/controllers/main.py):
|
||||
GET /skeen/api/v1/health
|
||||
GET /skeen/api/v1/services
|
||||
POST /skeen/api/v1/available_slots
|
||||
POST /skeen/api/v1/create_appointment
|
||||
POST /skeen/api/v1/cancel_appointment
|
||||
POST /skeen/api/v1/patient_balance
|
||||
POST /skeen/api/v1/wallet_redeem
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import xmlrpc.client
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
ODOO_URL = os.environ.get("ODOO_URL", "http://odoo:8069")
|
||||
ODOO_DB = os.environ.get("ODOO_DB", "skeen")
|
||||
ODOO_API_KEY = os.environ.get("ODOO_API_KEY")
|
||||
import requests
|
||||
|
||||
DEFAULT_TIMEOUT = 30
|
||||
|
||||
|
||||
def odoo_api_call(model: str, method: str, args: List = None, kwargs: Dict = None) -> Any:
|
||||
"""
|
||||
Plugin de Hermes para comunicarse con Odoo via XML-RPC.
|
||||
|
||||
Uso desde skills:
|
||||
odoo_api_call(model="salon.booking", method="get_available_slots",
|
||||
args=[service_id, date_from, date_to])
|
||||
def odoo_api_call(endpoint: str, payload: Optional[Dict[str, Any]] = None,
|
||||
method: str = "POST", timeout: int = DEFAULT_TIMEOUT) -> Any:
|
||||
"""Llama un endpoint de la API pública y devuelve el JSON.
|
||||
|
||||
Args:
|
||||
model: Nombre del modelo Odoo (ej: 'res.partner', 'salon.booking')
|
||||
method: Metodo a ejecutar (ej: 'search_read', 'create', 'write')
|
||||
args: Lista de argumentos posicionales
|
||||
kwargs: Diccionario de argumentos nombrados
|
||||
|
||||
Returns:
|
||||
Resultado de la llamada XML-RPC
|
||||
endpoint: Ruta relativa, p. ej. ``/skeen/api/v1/services``.
|
||||
payload: Cuerpo JSON (None para GET).
|
||||
method: Verbo HTTP (GET/POST).
|
||||
"""
|
||||
if args is None:
|
||||
args = []
|
||||
if kwargs is None:
|
||||
kwargs = {}
|
||||
base = (os.environ.get("ODOO_URL") or "http://localhost:8069").rstrip("/")
|
||||
key = os.environ.get("ODOO_API_KEY")
|
||||
if not key:
|
||||
raise RuntimeError("ODOO_API_KEY no está configurada en el entorno.")
|
||||
|
||||
if not ODOO_API_KEY:
|
||||
raise ValueError("ODOO_API_KEY no configurada en variables de entorno")
|
||||
|
||||
# Autenticacion
|
||||
common = xmlrpc.client.ServerProxy(f"{ODOO_URL}/xmlrpc/2/common")
|
||||
uid = common.authenticate(ODOO_DB, 1, ODOO_API_KEY, {})
|
||||
|
||||
if not uid:
|
||||
raise ValueError("Autenticacion con Odoo fallida")
|
||||
|
||||
# Ejecutar metodo
|
||||
models = xmlrpc.client.ServerProxy(f"{ODOO_URL}/xmlrpc/2/object")
|
||||
result = models.execute_kw(
|
||||
ODOO_DB, uid, ODOO_API_KEY,
|
||||
model, method, args, kwargs
|
||||
resp = requests.request(
|
||||
method,
|
||||
base + endpoint,
|
||||
json=payload,
|
||||
headers={"Authorization": f"Bearer {key}", "Accept": "application/json"},
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
return result
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
def odoo_search_read(model: str, domain: List = None, fields: List[str] = None,
|
||||
limit: int = 100) -> List[Dict]:
|
||||
"""
|
||||
Atajo para search_read en Odoo.
|
||||
|
||||
Uso:
|
||||
odoo_search_read(model="res.partner",
|
||||
domain=[["phone", "=", "+526641234567"]],
|
||||
fields=["name", "email", "phone"])
|
||||
"""
|
||||
if domain is None:
|
||||
domain = []
|
||||
if fields is None:
|
||||
fields = []
|
||||
|
||||
return odoo_api_call(
|
||||
model=model,
|
||||
method="search_read",
|
||||
args=[domain, fields],
|
||||
kwargs={"limit": limit}
|
||||
)
|
||||
def health() -> Any:
|
||||
return odoo_api_call("/skeen/api/v1/health", method="GET")
|
||||
|
||||
|
||||
def odoo_create(model: str, values: Dict) -> int:
|
||||
"""
|
||||
Atajo para create en Odoo.
|
||||
|
||||
Uso:
|
||||
odoo_create(model="res.partner",
|
||||
values={"name": "Sofia Garcia", "phone": "+526641234567"})
|
||||
"""
|
||||
return odoo_api_call(model=model, method="create", args=[values])
|
||||
def services() -> Any:
|
||||
return odoo_api_call("/skeen/api/v1/services", method="GET")
|
||||
|
||||
|
||||
def odoo_write(model: str, ids: List[int], values: Dict) -> bool:
|
||||
"""
|
||||
Atajo para write en Odoo.
|
||||
|
||||
Uso:
|
||||
odoo_write(model="salon.booking", ids=[123],
|
||||
values={"state": "cancelled"})
|
||||
"""
|
||||
return odoo_api_call(model=model, method="write", args=[ids, values])
|
||||
def available_slots(service_id: int, date_from: str, date_to: Optional[str] = None) -> Any:
|
||||
return odoo_api_call("/skeen/api/v1/available_slots", {
|
||||
"service_id": int(service_id),
|
||||
"date_from": date_from,
|
||||
"date_to": date_to or date_from,
|
||||
})
|
||||
|
||||
|
||||
# Registro del plugin para Hermes
|
||||
PLUGIN_NAME = "odoo_api_call"
|
||||
PLUGIN_VERSION = "1.0.0"
|
||||
PLUGIN_DESCRIPTION = "Comunicacion con Odoo ERP via XML-RPC"
|
||||
def create_appointment(phone: str, name: str, service_id: int, date: str, time: str,
|
||||
**extra: Any) -> Any:
|
||||
payload = {"phone": phone, "name": name, "service_id": int(service_id),
|
||||
"date": date, "time": time}
|
||||
payload.update({k: v for k, v in extra.items() if v is not None})
|
||||
return odoo_api_call("/skeen/api/v1/create_appointment", payload)
|
||||
|
||||
# Herramientas expuestas (para hermes tools)
|
||||
TOOLS = {
|
||||
"odoo_api_call": odoo_api_call,
|
||||
"odoo_search_read": odoo_search_read,
|
||||
"odoo_create": odoo_create,
|
||||
"odoo_write": odoo_write,
|
||||
}
|
||||
|
||||
def cancel_appointment(phone: str, booking_id: Optional[int] = None) -> Any:
|
||||
payload: Dict[str, Any] = {"phone": phone}
|
||||
if booking_id is not None:
|
||||
payload["booking_id"] = int(booking_id)
|
||||
return odoo_api_call("/skeen/api/v1/cancel_appointment", payload)
|
||||
|
||||
|
||||
def patient_balance(phone: str) -> Any:
|
||||
return odoo_api_call("/skeen/api/v1/patient_balance", {"phone": phone})
|
||||
|
||||
|
||||
def wallet_redeem(phone: str, points: int) -> Any:
|
||||
return odoo_api_call("/skeen/api/v1/wallet_redeem",
|
||||
{"phone": phone, "points": int(points)})
|
||||
|
||||
380
hermes/hermes/plugins/skeen_odoo/__init__.py
Normal file
380
hermes/hermes/plugins/skeen_odoo/__init__.py
Normal file
@@ -0,0 +1,380 @@
|
||||
"""Plugin Hermes ``skeen_odoo`` — acceso HTTP a la API pública de Odoo.
|
||||
|
||||
Registra el toolset ``skeen_odoo`` en la toolset registry de Hermes. Cada tool
|
||||
es una llamada HTTP acotada a un endpoint de ``/skeen/api/v1/*``; la llave
|
||||
(``ODOO_API_KEY``) y la URL (``ODOO_URL``) se leen del entorno de Hermes
|
||||
(``~/.hermes/.env``). No hay acceso a shell ni a modelos arbitrarios de Odoo.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from tools.registry import tool_error, tool_result
|
||||
|
||||
from . import client
|
||||
from .client import SkeenOdooError
|
||||
|
||||
TOOLSET = "skeen_odoo"
|
||||
|
||||
|
||||
def _check_configured() -> bool:
|
||||
"""Los tools solo aparecen cuando hay URL y llave configuradas."""
|
||||
try:
|
||||
return client.is_configured()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _fail(exc: Exception) -> str:
|
||||
if isinstance(exc, SkeenOdooError):
|
||||
extra = {}
|
||||
if exc.status_code is not None:
|
||||
extra["status_code"] = exc.status_code
|
||||
return tool_error(str(exc), **extra)
|
||||
return tool_error(f"Error inesperado del plugin skeen_odoo: {exc}")
|
||||
|
||||
|
||||
def _body(resp: Any) -> Dict[str, Any]:
|
||||
"""Normaliza la respuesta; si la API reportó ``status: error``, lo señala."""
|
||||
if isinstance(resp, dict):
|
||||
return resp
|
||||
return {"status": "success", "data": resp}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Handlers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def handle_health(args: Dict[str, Any], **kw: Any) -> str:
|
||||
try:
|
||||
resp = _body(client.call("GET", "/skeen/api/v1/health"))
|
||||
return tool_result(success=True, **resp)
|
||||
except Exception as exc:
|
||||
return _fail(exc)
|
||||
|
||||
|
||||
def handle_services(args: Dict[str, Any], **kw: Any) -> str:
|
||||
"""Lista el catálogo real de servicios, con filtro y tope de resultados."""
|
||||
search = str(args.get("search") or "").strip().lower()
|
||||
category = str(args.get("category") or "").strip().lower()
|
||||
try:
|
||||
min_price = float(args.get("min_price") or 0)
|
||||
except (TypeError, ValueError):
|
||||
min_price = 0.0
|
||||
try:
|
||||
limit = int(args.get("limit") or 20)
|
||||
except (TypeError, ValueError):
|
||||
limit = 20
|
||||
limit = max(1, min(limit, 50))
|
||||
|
||||
try:
|
||||
resp = _body(client.call("GET", "/skeen/api/v1/services"))
|
||||
except Exception as exc:
|
||||
return _fail(exc)
|
||||
|
||||
if resp.get("status") == "error":
|
||||
return tool_error(resp.get("message") or "La API devolvió un error")
|
||||
|
||||
services: List[Dict[str, Any]] = resp.get("services") or []
|
||||
total_active = len(services)
|
||||
sellable = sum(1 for s in services if (s.get("price") or 0) > 0)
|
||||
|
||||
def matches(s: Dict[str, Any]) -> bool:
|
||||
if (s.get("price") or 0) < min_price:
|
||||
return False
|
||||
if category and str(s.get("category") or "").lower() != category:
|
||||
return False
|
||||
if search:
|
||||
haystack = " ".join(str(s.get(k) or "") for k in ("name", "code", "category")).lower()
|
||||
return search in haystack
|
||||
return True
|
||||
|
||||
filtered = [s for s in services if matches(s)]
|
||||
|
||||
def compact(s: Dict[str, Any]) -> Dict[str, Any]:
|
||||
out = {
|
||||
"id": s.get("id"),
|
||||
"code": s.get("code"),
|
||||
"name": s.get("name"),
|
||||
"category": s.get("category"),
|
||||
"price": s.get("price"),
|
||||
"duration_min": s.get("duration_min"),
|
||||
}
|
||||
if (s.get("package_sessions") or 0) > 0:
|
||||
out["package_price"] = s.get("package_price")
|
||||
out["package_sessions"] = s.get("package_sessions")
|
||||
desc = str(s.get("description") or "").strip()
|
||||
if desc:
|
||||
out["description"] = desc[:140]
|
||||
return out
|
||||
|
||||
return tool_result(
|
||||
success=True,
|
||||
total_active=total_active,
|
||||
sellable_services=sellable,
|
||||
matching=len(filtered),
|
||||
returned=len(filtered[:limit]),
|
||||
note="Usa search/category/min_price para acotar; la lista va recortada.",
|
||||
services=[compact(s) for s in filtered[:limit]],
|
||||
)
|
||||
|
||||
|
||||
def handle_available_slots(args: Dict[str, Any], **kw: Any) -> str:
|
||||
service_id = args.get("service_id")
|
||||
date_from = args.get("date_from") or args.get("date")
|
||||
date_to = args.get("date_to") or date_from
|
||||
if service_id in (None, "", 0, "0"):
|
||||
return tool_error("service_id es obligatorio (ID numérico del servicio).")
|
||||
if not date_from:
|
||||
return tool_error("date_from es obligatorio (formato AAAA-MM-DD).")
|
||||
payload = {
|
||||
"service_id": int(service_id),
|
||||
"date_from": str(date_from),
|
||||
"date_to": str(date_to),
|
||||
}
|
||||
if args.get("doctor_id") not in (None, ""):
|
||||
payload["doctor_id"] = int(args["doctor_id"])
|
||||
try:
|
||||
resp = _body(client.call("POST", "/skeen/api/v1/available_slots", payload))
|
||||
except Exception as exc:
|
||||
return _fail(exc)
|
||||
if resp.get("status") == "error":
|
||||
return tool_error(resp.get("message") or "La API devolvió un error")
|
||||
slots = resp.get("slots") or []
|
||||
return tool_result(success=True, count=len(slots), slots=slots)
|
||||
|
||||
|
||||
def handle_create_appointment(args: Dict[str, Any], **kw: Any) -> str:
|
||||
phone = str(args.get("phone") or "").strip()
|
||||
name = str(args.get("name") or "").strip()
|
||||
service_id = args.get("service_id")
|
||||
date = str(args.get("date") or "").strip()
|
||||
time = str(args.get("time") or "").strip()
|
||||
missing = [k for k, v in (
|
||||
("phone", phone), ("name", name), ("service_id", service_id),
|
||||
("date", date), ("time", time),
|
||||
) if not v]
|
||||
if missing:
|
||||
return tool_error("Faltan campos obligatorios: " + ", ".join(missing))
|
||||
|
||||
payload = {
|
||||
"phone": phone,
|
||||
"name": name,
|
||||
"service_id": int(service_id),
|
||||
"date": date,
|
||||
"time": time,
|
||||
}
|
||||
if args.get("doctor_id") not in (None, ""):
|
||||
payload["doctor_id"] = int(args["doctor_id"])
|
||||
if args.get("duration") not in (None, ""):
|
||||
payload["duration"] = int(args["duration"])
|
||||
try:
|
||||
resp = _body(client.call("POST", "/skeen/api/v1/create_appointment", payload))
|
||||
except Exception as exc:
|
||||
return _fail(exc)
|
||||
if resp.get("status") == "error":
|
||||
return tool_error(resp.get("message") or "La API devolvió un error")
|
||||
return tool_result(
|
||||
success=True,
|
||||
booking_id=resp.get("booking_id"),
|
||||
reference=resp.get("reference"),
|
||||
patient_id=resp.get("patient_id"),
|
||||
)
|
||||
|
||||
|
||||
def handle_cancel_appointment(args: Dict[str, Any], **kw: Any) -> str:
|
||||
phone = str(args.get("phone") or "").strip()
|
||||
if not phone:
|
||||
return tool_error("phone es obligatorio (teléfono del paciente).")
|
||||
payload: Dict[str, Any] = {"phone": phone}
|
||||
if args.get("booking_id") not in (None, ""):
|
||||
payload["booking_id"] = int(args["booking_id"])
|
||||
try:
|
||||
resp = _body(client.call("POST", "/skeen/api/v1/cancel_appointment", payload))
|
||||
except Exception as exc:
|
||||
return _fail(exc)
|
||||
if resp.get("status") == "error":
|
||||
return tool_error(resp.get("message") or "La API devolvió un error")
|
||||
return tool_result(success=True, cancelled_booking=resp.get("cancelled_booking"))
|
||||
|
||||
|
||||
def handle_patient_balance(args: Dict[str, Any], **kw: Any) -> str:
|
||||
phone = str(args.get("phone") or "").strip()
|
||||
if not phone:
|
||||
return tool_error("phone es obligatorio (teléfono del paciente).")
|
||||
try:
|
||||
resp = _body(client.call("POST", "/skeen/api/v1/patient_balance", {"phone": phone}))
|
||||
except Exception as exc:
|
||||
return _fail(exc)
|
||||
if resp.get("status") == "error":
|
||||
return tool_error(resp.get("message") or "La API devolvió un error")
|
||||
return tool_result(
|
||||
success=True,
|
||||
pending_balance=resp.get("pending_balance"),
|
||||
pending_invoices=resp.get("pending_invoices") or [],
|
||||
wallet_points=resp.get("wallet_points"),
|
||||
wallet_mxn=resp.get("wallet_mxn"),
|
||||
)
|
||||
|
||||
|
||||
def handle_wallet_redeem(args: Dict[str, Any], **kw: Any) -> str:
|
||||
phone = str(args.get("phone") or "").strip()
|
||||
points = args.get("points")
|
||||
if not phone:
|
||||
return tool_error("phone es obligatorio (teléfono del paciente).")
|
||||
if points in (None, ""):
|
||||
return tool_error("points es obligatorio (puntos a redimir).")
|
||||
try:
|
||||
resp = _body(client.call(
|
||||
"POST", "/skeen/api/v1/wallet_redeem",
|
||||
{"phone": phone, "points": int(points)},
|
||||
))
|
||||
except Exception as exc:
|
||||
return _fail(exc)
|
||||
if resp.get("status") == "error":
|
||||
return tool_error(resp.get("message") or "La API devolvió un error")
|
||||
return tool_result(
|
||||
success=True,
|
||||
remaining_points=resp.get("remaining_points"),
|
||||
remaining_mxn=resp.get("remaining_mxn"),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_STR = {"type": "string"}
|
||||
|
||||
HEALTH_SCHEMA = {
|
||||
"name": "skeen_odoo_health",
|
||||
"description": "Verifica que la API pública de SKEEN/Odoo responde (health check).",
|
||||
"parameters": {"type": "object", "properties": {}, "required": []},
|
||||
}
|
||||
|
||||
SERVICES_SCHEMA = {
|
||||
"name": "skeen_odoo_services",
|
||||
"description": (
|
||||
"Lista el catálogo REAL de servicios de la clínica desde Odoo, con ids "
|
||||
"numéricos vivos. Devuelve `total_active` (todos los servicios activos, "
|
||||
"incl. históricos sin precio), `sellable_services` (con precio > 0) y la "
|
||||
"lista filtrada en `services`. Úsalo para responder cuántos servicios hay "
|
||||
"(`total_active`), buscar un tratamiento o resolver el service_id antes de "
|
||||
"agendar. Para citas usa `min_price=1` y así ignorar entradas históricas."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"search": {"type": "string", "description": "Texto a buscar en nombre, código o categoría."},
|
||||
"category": {"type": "string", "description": "Categoría exacta (ej. consulta, tratamiento, procedimiento, paquete)."},
|
||||
"min_price": {"type": "number", "description": "Precio mínimo (usa 1 para excluir servicios históricos sin precio)."},
|
||||
"limit": {"type": "integer", "description": "Máximo de servicios a devolver (1-50, por defecto 20). Usa filtros para acotar."},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
}
|
||||
|
||||
AVAILABLE_SLOTS_SCHEMA = {
|
||||
"name": "skeen_odoo_available_slots",
|
||||
"description": (
|
||||
"Consulta horarios disponibles reales para un servicio en un rango de "
|
||||
"fechas. Requiere el ID numérico del servicio (obtenlo con "
|
||||
"skeen_odoo_services)."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"service_id": {"type": "integer", "description": "ID numérico del servicio."},
|
||||
"date_from": {"type": "string", "description": "Fecha inicial AAAA-MM-DD."},
|
||||
"date_to": {"type": "string", "description": "Fecha final AAAA-MM-DD (por defecto igual a date_from)."},
|
||||
"doctor_id": {"type": "integer", "description": "Opcional: ID del médico."},
|
||||
},
|
||||
"required": ["service_id", "date_from"],
|
||||
},
|
||||
}
|
||||
|
||||
CREATE_APPOINTMENT_SCHEMA = {
|
||||
"name": "skeen_odoo_create_appointment",
|
||||
"description": (
|
||||
"Crea una cita real en Odoo. Si el teléfono no existe, crea al paciente. "
|
||||
"Verifica antes la disponibilidad con skeen_odoo_available_slots."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"phone": {"type": "string", "description": "Teléfono del paciente en formato E.164 (ej. +526641234567)."},
|
||||
"name": {"type": "string", "description": "Nombre completo del paciente."},
|
||||
"service_id": {"type": "integer", "description": "ID numérico del servicio."},
|
||||
"date": {"type": "string", "description": "Fecha de la cita AAAA-MM-DD."},
|
||||
"time": {"type": "string", "description": "Hora de inicio HH:MM (24h)."},
|
||||
"doctor_id": {"type": "integer", "description": "Opcional: ID del médico."},
|
||||
"duration": {"type": "integer", "description": "Opcional: duración en minutos."},
|
||||
},
|
||||
"required": ["phone", "name", "service_id", "date", "time"],
|
||||
},
|
||||
}
|
||||
|
||||
CANCEL_APPOINTMENT_SCHEMA = {
|
||||
"name": "skeen_odoo_cancel_appointment",
|
||||
"description": (
|
||||
"Cancela la cita activa de un paciente (la más próxima). Si se indica "
|
||||
"booking_id, cancela esa cita en particular."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"phone": {"type": "string", "description": "Teléfono del paciente."},
|
||||
"booking_id": {"type": "integer", "description": "Opcional: ID de la cita a cancelar."},
|
||||
},
|
||||
"required": ["phone"],
|
||||
},
|
||||
}
|
||||
|
||||
PATIENT_BALANCE_SCHEMA = {
|
||||
"name": "skeen_odoo_patient_balance",
|
||||
"description": "Consulta el saldo pendiente, las citas por pagar y los puntos del monedero de un paciente.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"phone": {"type": "string", "description": "Teléfono del paciente."}},
|
||||
"required": ["phone"],
|
||||
},
|
||||
}
|
||||
|
||||
WALLET_REDEEM_SCHEMA = {
|
||||
"name": "skeen_odoo_wallet_redeem",
|
||||
"description": "Redime puntos del monedero de un paciente. Confirma el saldo antes con skeen_odoo_patient_balance.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"phone": {"type": "string", "description": "Teléfono del paciente."},
|
||||
"points": {"type": "integer", "description": "Puntos a redimir."},
|
||||
},
|
||||
"required": ["phone", "points"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
_TOOLS = [
|
||||
("skeen_odoo_health", HEALTH_SCHEMA, handle_health, "🩺"),
|
||||
("skeen_odoo_services", SERVICES_SCHEMA, handle_services, "📋"),
|
||||
("skeen_odoo_available_slots", AVAILABLE_SLOTS_SCHEMA, handle_available_slots, "📅"),
|
||||
("skeen_odoo_create_appointment", CREATE_APPOINTMENT_SCHEMA, handle_create_appointment, "✅"),
|
||||
("skeen_odoo_cancel_appointment", CANCEL_APPOINTMENT_SCHEMA, handle_cancel_appointment, "🚫"),
|
||||
("skeen_odoo_patient_balance", PATIENT_BALANCE_SCHEMA, handle_patient_balance, "💳"),
|
||||
("skeen_odoo_wallet_redeem", WALLET_REDEEM_SCHEMA, handle_wallet_redeem, "🎁"),
|
||||
]
|
||||
|
||||
|
||||
def register(ctx) -> None:
|
||||
"""Registra los tools del plugin. Llamado una vez por el cargador de Hermes."""
|
||||
for name, schema, handler, emoji in _TOOLS:
|
||||
ctx.register_tool(
|
||||
name=name,
|
||||
toolset=TOOLSET,
|
||||
schema=schema,
|
||||
handler=handler,
|
||||
check_fn=_check_configured,
|
||||
requires_env=["ODOO_URL", "ODOO_API_KEY"],
|
||||
emoji=emoji,
|
||||
)
|
||||
88
hermes/hermes/plugins/skeen_odoo/client.py
Normal file
88
hermes/hermes/plugins/skeen_odoo/client.py
Normal file
@@ -0,0 +1,88 @@
|
||||
"""Cliente HTTP para la API pública de SKEEN en Odoo.
|
||||
|
||||
Contrato (fuente de verdad: ``skeen_odoo/addons/skeen_whatsapp/controllers/main.py``):
|
||||
|
||||
* Base: ``$ODOO_URL/skeen/api/v1``
|
||||
* Autenticación: ``Authorization: Bearer $ODOO_API_KEY``
|
||||
* Respuestas JSON planas con ``status`` = ``success`` | ``error``.
|
||||
|
||||
Los endpoints de escritura (crear/cancelar cita, monedero) son los únicos que
|
||||
mutan datos; el resto son de solo lectura.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any, Optional
|
||||
|
||||
import requests
|
||||
|
||||
DEFAULT_TIMEOUT = 30
|
||||
|
||||
|
||||
class SkeenOdooError(RuntimeError):
|
||||
"""Error de la API de SKEEN/Odoo, con el código HTTP cuando aplica."""
|
||||
|
||||
def __init__(self, message: str, *, status_code: Optional[int] = None,
|
||||
payload: Any = None) -> None:
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
self.payload = payload
|
||||
|
||||
|
||||
def base_url() -> str:
|
||||
return (os.environ.get("ODOO_URL") or "http://localhost:8069").rstrip("/")
|
||||
|
||||
|
||||
def api_key() -> Optional[str]:
|
||||
return os.environ.get("ODOO_API_KEY")
|
||||
|
||||
|
||||
def is_configured() -> bool:
|
||||
"""True cuando hay URL y llave: habilita los tools de este plugin."""
|
||||
return bool(base_url()) and bool(api_key())
|
||||
|
||||
|
||||
def call(method: str, path: str, payload: Optional[dict] = None,
|
||||
*, timeout: int = DEFAULT_TIMEOUT) -> Any:
|
||||
"""Ejecuta una llamada autenticada y devuelve el JSON de la respuesta."""
|
||||
key = api_key()
|
||||
if not key:
|
||||
raise SkeenOdooError(
|
||||
"ODOO_API_KEY no está configurada en el entorno de Hermes "
|
||||
"(~/.hermes/.env)."
|
||||
)
|
||||
|
||||
url = base_url() + path
|
||||
headers = {"Authorization": f"Bearer {key}", "Accept": "application/json"}
|
||||
if payload is not None:
|
||||
headers["Content-Type"] = "application/json"
|
||||
|
||||
try:
|
||||
resp = requests.request(method, url, json=payload, headers=headers,
|
||||
timeout=timeout)
|
||||
except requests.RequestException as exc:
|
||||
raise SkeenOdooError(
|
||||
f"No se pudo contactar la API de Odoo en {url}: {exc}"
|
||||
) from exc
|
||||
|
||||
if not resp.ok:
|
||||
raise SkeenOdooError(_error_detail(resp), status_code=resp.status_code,
|
||||
payload=_safe_json(resp))
|
||||
return _safe_json(resp)
|
||||
|
||||
|
||||
def _safe_json(resp: requests.Response) -> Any:
|
||||
try:
|
||||
return resp.json()
|
||||
except ValueError:
|
||||
return {"raw": resp.text[:500]}
|
||||
|
||||
|
||||
def _error_detail(resp: requests.Response) -> str:
|
||||
body = _safe_json(resp)
|
||||
if isinstance(body, dict):
|
||||
msg = body.get("message") or body.get("error")
|
||||
if msg:
|
||||
return f"HTTP {resp.status_code}: {msg}"
|
||||
return f"HTTP {resp.status_code} al llamar la API de Odoo"
|
||||
20
hermes/hermes/plugins/skeen_odoo/plugin.yaml
Normal file
20
hermes/hermes/plugins/skeen_odoo/plugin.yaml
Normal file
@@ -0,0 +1,20 @@
|
||||
name: skeen_odoo
|
||||
version: 1.0.0
|
||||
description: >-
|
||||
SKEEN — herramientas HTTP contra la API pública de Odoo (/skeen/api/v1/*):
|
||||
salud, catálogo de servicios, disponibilidad, agendar/cancelar cita, saldo de
|
||||
paciente y redención de monedero. Lee ODOO_URL y ODOO_API_KEY del entorno de
|
||||
Hermes (Authorization: Bearer). Sin acceso a shell ni a modelos arbitrarios.
|
||||
author: SKEEN
|
||||
kind: standalone
|
||||
requires_env:
|
||||
- ODOO_URL
|
||||
- ODOO_API_KEY
|
||||
provides_tools:
|
||||
- skeen_odoo_health
|
||||
- skeen_odoo_services
|
||||
- skeen_odoo_available_slots
|
||||
- skeen_odoo_create_appointment
|
||||
- skeen_odoo_cancel_appointment
|
||||
- skeen_odoo_patient_balance
|
||||
- skeen_odoo_wallet_redeem
|
||||
99
hermes/hermes/plugins/skeen_wacrm/__init__.py
Normal file
99
hermes/hermes/plugins/skeen_wacrm/__init__.py
Normal file
@@ -0,0 +1,99 @@
|
||||
"""Plugin Hermes ``skeen_wacrm`` — envío de WhatsApp vía WACRM.
|
||||
|
||||
Un solo tool (``skeen_wacrm_send``) que hace ``POST {WACRM_URL}/api/v1/messages``
|
||||
con ``Authorization: Bearer $WACRM_API_KEY``. Con ``simulate=True`` usa
|
||||
``/api/v1/messages/simulate``, que NO envía nada real (útil para pruebas).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any, Dict
|
||||
|
||||
import requests
|
||||
|
||||
from tools.registry import tool_error, tool_result
|
||||
|
||||
TOOLSET = "skeen_wacrm"
|
||||
DEFAULT_TIMEOUT = 30
|
||||
|
||||
|
||||
def _base_url() -> str:
|
||||
return (os.environ.get("WACRM_URL") or "http://localhost:3000").rstrip("/")
|
||||
|
||||
|
||||
def _api_key() -> str:
|
||||
return os.environ.get("WACRM_API_KEY") or ""
|
||||
|
||||
|
||||
def _check_configured() -> bool:
|
||||
return bool(_base_url()) and bool(_api_key())
|
||||
|
||||
|
||||
SEND_SCHEMA = {
|
||||
"name": "skeen_wacrm_send",
|
||||
"description": (
|
||||
"Envía un mensaje de texto de WhatsApp a un número mediante WACRM. "
|
||||
"Úsalo solo para mensajes proactivos (recordatorios, confirmaciones "
|
||||
"fuera del turno). Nunca inventes el número: usa el teléfono del "
|
||||
"paciente que ya conoces."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"to": {"type": "string", "description": "Teléfono destino en formato E.164 (ej. +526641234567)."},
|
||||
"text": {"type": "string", "description": "Contenido del mensaje."},
|
||||
"simulate": {"type": "boolean", "description": "Prueba: no envía nada real (endpoint simulate). Por defecto false."},
|
||||
},
|
||||
"required": ["to", "text"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def handle_send(args: Dict[str, Any], **kw: Any) -> str:
|
||||
to = str(args.get("to") or "").strip()
|
||||
text = str(args.get("text") or "").strip()
|
||||
if not to or not text:
|
||||
return tool_error("'to' y 'text' son obligatorios.")
|
||||
simulate = bool(args.get("simulate"))
|
||||
endpoint = "/api/v1/messages/simulate" if simulate else "/api/v1/messages"
|
||||
|
||||
try:
|
||||
resp = requests.post(
|
||||
_base_url() + endpoint,
|
||||
headers={
|
||||
"Authorization": f"Bearer {_api_key()}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={"to": to, "type": "text", "text": text},
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
return tool_error(f"No se pudo contactar WACRM: {exc}")
|
||||
|
||||
if not resp.ok:
|
||||
detail = ""
|
||||
try:
|
||||
detail = (resp.json().get("error") or {}).get("message") or ""
|
||||
except ValueError:
|
||||
detail = resp.text[:200]
|
||||
return tool_error(f"WACRM respondió HTTP {resp.status_code}: {detail}",
|
||||
status_code=resp.status_code)
|
||||
|
||||
try:
|
||||
body = resp.json()
|
||||
except ValueError:
|
||||
body = {"raw": resp.text[:300]}
|
||||
return tool_result(success=True, simulated=simulate, response=body)
|
||||
|
||||
|
||||
def register(ctx) -> None:
|
||||
ctx.register_tool(
|
||||
name="skeen_wacrm_send",
|
||||
toolset=TOOLSET,
|
||||
schema=SEND_SCHEMA,
|
||||
handler=handle_send,
|
||||
check_fn=_check_configured,
|
||||
requires_env=["WACRM_URL", "WACRM_API_KEY"],
|
||||
emoji="📤",
|
||||
)
|
||||
14
hermes/hermes/plugins/skeen_wacrm/plugin.yaml
Normal file
14
hermes/hermes/plugins/skeen_wacrm/plugin.yaml
Normal file
@@ -0,0 +1,14 @@
|
||||
name: skeen_wacrm
|
||||
version: 1.0.0
|
||||
description: >-
|
||||
SKEEN — envío de mensajes de WhatsApp vía WACRM (§/api/v1/messages). Lee
|
||||
WACRM_URL y WACRM_API_KEY del entorno de Hermes. Pensado para mensajes
|
||||
proactivos (recordatorios, confirmaciones fuera del flujo de respuesta);
|
||||
las respuestas normales del agente ya las entrega el bridge.
|
||||
author: SKEEN
|
||||
kind: standalone
|
||||
requires_env:
|
||||
- WACRM_URL
|
||||
- WACRM_API_KEY
|
||||
provides_tools:
|
||||
- skeen_wacrm_send
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"archive": "skills.tar.gz",
|
||||
"archive_bytes": 6646,
|
||||
"created_at": "2026-09-11T09:33:23.968508+00:00",
|
||||
"cron_jobs": {
|
||||
"backed_up": false,
|
||||
"jobs_count": 0,
|
||||
"reason": "no cron/jobs.json present"
|
||||
},
|
||||
"id": "2026-09-11T09-33-23Z",
|
||||
"reason": "pre-curator-run",
|
||||
"skill_files": 5
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"archive": "skills.tar.gz",
|
||||
"archive_bytes": 6646,
|
||||
"created_at": "2026-09-18T09:52:18.364653+00:00",
|
||||
"cron_jobs": {
|
||||
"backed_up": false,
|
||||
"jobs_count": 0,
|
||||
"reason": "no cron/jobs.json present"
|
||||
},
|
||||
"id": "2026-09-18T09-52-18Z",
|
||||
"reason": "pre-curator-run",
|
||||
"skill_files": 5
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"archive": "skills.tar.gz",
|
||||
"archive_bytes": 6646,
|
||||
"created_at": "2026-09-25T09:57:24.566730+00:00",
|
||||
"cron_jobs": {
|
||||
"backed_up": false,
|
||||
"jobs_count": 0,
|
||||
"reason": "no cron/jobs.json present"
|
||||
},
|
||||
"id": "2026-09-25T09-57-24Z",
|
||||
"reason": "pre-curator-run",
|
||||
"skill_files": 5
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"archive": "skills.tar.gz",
|
||||
"archive_bytes": 6649,
|
||||
"created_at": "2026-10-02T10:03:51.398289+00:00",
|
||||
"cron_jobs": {
|
||||
"backed_up": false,
|
||||
"jobs_count": 0,
|
||||
"reason": "no cron/jobs.json present"
|
||||
},
|
||||
"id": "2026-10-02T10-03-51Z",
|
||||
"reason": "pre-curator-run",
|
||||
"skill_files": 5
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"archive": "skills.tar.gz",
|
||||
"archive_bytes": 6644,
|
||||
"created_at": "2026-10-09T10:26:38.684078+00:00",
|
||||
"cron_jobs": {
|
||||
"backed_up": false,
|
||||
"jobs_count": 0,
|
||||
"reason": "no cron/jobs.json present"
|
||||
},
|
||||
"id": "2026-10-09T10-26-38Z",
|
||||
"reason": "pre-curator-run",
|
||||
"skill_files": 5
|
||||
}
|
||||
9
hermes/hermes/skills/.curator_state
Normal file
9
hermes/hermes/skills/.curator_state
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"last_report_path": "/root/.hermes/logs/curator/20261009-102638",
|
||||
"last_run_at": "2026-10-09T10:26:38.561235+00:00",
|
||||
"last_run_duration_seconds": 0.200708,
|
||||
"last_run_summary": "auto: no changes; llm: skipped (consolidation off)",
|
||||
"last_run_summary_shown_at": null,
|
||||
"paused": false,
|
||||
"run_count": 11
|
||||
}
|
||||
1
hermes/hermes/skills/.hub/lock.json
Normal file
1
hermes/hermes/skills/.hub/lock.json
Normal file
@@ -0,0 +1 @@
|
||||
{"version": 1, "installed": {}}
|
||||
1
hermes/hermes/skills/.hub/taps.json
Normal file
1
hermes/hermes/skills/.hub/taps.json
Normal file
@@ -0,0 +1 @@
|
||||
{"taps": []}
|
||||
28
hermes/hermes/skills/.usage.json
Normal file
28
hermes/hermes/skills/.usage.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"skeen-agendar": {
|
||||
"archived_at": null,
|
||||
"created_at": "2026-10-11T21:07:44.555659+00:00",
|
||||
"created_by": null,
|
||||
"last_patched_at": "2026-10-11T21:16:36.704807+00:00",
|
||||
"last_used_at": "2026-10-11T21:41:42.735963+00:00",
|
||||
"last_viewed_at": "2026-10-11T21:41:42.731127+00:00",
|
||||
"patch_count": 2,
|
||||
"pinned": false,
|
||||
"state": "active",
|
||||
"use_count": 4,
|
||||
"view_count": 4
|
||||
},
|
||||
"skeen-rag": {
|
||||
"archived_at": null,
|
||||
"created_at": "2026-07-05T09:01:39.652729+00:00",
|
||||
"created_by": null,
|
||||
"last_patched_at": null,
|
||||
"last_used_at": "2026-10-11T21:41:24.584008+00:00",
|
||||
"last_viewed_at": "2026-10-11T21:41:24.579525+00:00",
|
||||
"patch_count": 0,
|
||||
"pinned": false,
|
||||
"state": "active",
|
||||
"use_count": 16,
|
||||
"view_count": 6
|
||||
}
|
||||
}
|
||||
0
hermes/hermes/skills/.usage.json.lock
Normal file
0
hermes/hermes/skills/.usage.json.lock
Normal file
@@ -2,102 +2,93 @@
|
||||
name: skeen-agendar
|
||||
description: >
|
||||
Agendamiento de citas para SKEEN Derma Experts.
|
||||
Consulta disponibilidad, crea citas, reagenda y cancela.
|
||||
Consulta disponibilidad, crea citas individuales o paquetes de sesiones, reagenda y cancela.
|
||||
Usar cuando el usuario quiera agendar, reagendar o cancelar una cita.
|
||||
version: 1.0.0
|
||||
version: 2.0.0
|
||||
metadata:
|
||||
hermes:
|
||||
requires:
|
||||
bins: [python]
|
||||
bins: []
|
||||
envVars:
|
||||
- name: ODOO_URL
|
||||
required: true
|
||||
- name: ODOO_DB
|
||||
required: true
|
||||
- name: ODOO_API_KEY
|
||||
required: true
|
||||
platforms: [whatsapp, webchat, cli]
|
||||
activation: keyword
|
||||
keywords: ["agendar", "cita", "hora", "reagendar", "cancelar", "disponibilidad"]
|
||||
keywords: ["agendar", "cita", "hora", "reagendar", "cancelar", "disponibilidad", "paquete", "sesiones"]
|
||||
---
|
||||
|
||||
# SKEEN — Agendamiento de Citas
|
||||
|
||||
## Instrucciones de Uso
|
||||
Herramientas disponibles (toolset `skeen_odoo`, hablan HTTP contra la API real de Odoo):
|
||||
|
||||
### PASO 1: Identificar intencion
|
||||
- `skeen_odoo_services` — catálogo real de servicios con ids numéricos.
|
||||
- `skeen_odoo_available_slots` — horarios libres reales.
|
||||
- `skeen_odoo_create_appointment` — crea la cita.
|
||||
- `skeen_odoo_cancel_appointment` — cancela la cita.
|
||||
- `skeen_odoo_patient_balance` — saldo/monedero del paciente.
|
||||
|
||||
- "Quiero agendar" / "Necesito una cita" → NUEVA CITA
|
||||
## PASO 1: Identificar intención
|
||||
|
||||
- "Quiero agendar" → NUEVA CITA
|
||||
- "Quiero cambiar mi cita" / "Reagendar" → REAGENDAR
|
||||
- "Cancelar mi cita" → CANCELAR
|
||||
- "Tengo una cita para manana?" → CONSULTAR
|
||||
- "Tengo una cita para mañana?" → CONSULTAR
|
||||
- "Quiero el paquete de [tratamiento]" → NUEVA CITA CON PAQUETE
|
||||
|
||||
### PASO 2: Para NUEVA CITA
|
||||
## PASO 2: NUEVA CITA
|
||||
|
||||
1. Pregunta que tipo de servicio desea (si no lo especifico):
|
||||
- Consulta dermatologica general ($800 MXN, 30 min)
|
||||
- Tratamiento facial ($1,200-$3,500 MXN, 45-90 min)
|
||||
- Depilacion laser (desde $500 MXN por zona, 15-60 min)
|
||||
- Peel quimico ($1,500 MXN, 30 min)
|
||||
- O consulta de valoracion gratuita (15 min)
|
||||
1. Identifica el servicio. Si no es obvio, búscalo con la herramienta:
|
||||
`skeen_odoo_services(search="<palabra clave>", min_price=1)`.
|
||||
Usa `min_price=1` para excluir servicios históricos sin precio (el catálogo de
|
||||
Odoo trae miles de registros antiguos en $0). **`service_id` es el id numérico
|
||||
que devuelve la herramienta** (NO uses slugs como "cons-derm"). Nunca inventes
|
||||
el id.
|
||||
2. Si el servicio tiene `package_sessions` > 0, ofrece precio por sesión y precio
|
||||
por paquete.
|
||||
3. Consulta disponibilidad (rango de fechas obligatorio):
|
||||
`skeen_odoo_available_slots(service_id=<id>, date_from="AAAA-MM-DD", date_to="AAAA-MM-DD")`
|
||||
Devuelve `slots: [{date, time, datetime}]`. Un rango de un solo día: usa la
|
||||
misma fecha en `date_from` y `date_to`.
|
||||
4. Presenta las 3 mejores opciones de horario para la primera sesión.
|
||||
5. Confirma datos: nombre completo, teléfono (E.164, ej. +52664...),
|
||||
alergias/condiciones (opcional).
|
||||
6. Crea la cita:
|
||||
`skeen_odoo_create_appointment(phone, name, service_id, date="AAAA-MM-DD", time="HH:MM")`
|
||||
Para paquetes, crea N citas (una por sesión); las siguientes pueden ir cada 7 días.
|
||||
7. Confirma al paciente con el formato de abajo. **Tu respuesta se envía sola por
|
||||
WhatsApp** (el bridge la entrega); no necesitas una herramienta de envío.
|
||||
|
||||
2. Consulta disponibilidad usando el plugin `odoo_api_call`:
|
||||
Modelo: salon.booking, metodo: get_available_slots
|
||||
Argumentos: [service_id, date_from, date_to]
|
||||
## PASO 3: REAGENDAR
|
||||
|
||||
3. Presenta las 3 mejores opciones de horario
|
||||
1. Consulta el saldo/datos con `skeen_odoo_patient_balance(phone=<telefono>)` para
|
||||
ver citas pendientes del paciente.
|
||||
2. Cancela la cita actual: `skeen_odoo_cancel_appointment(phone, booking_id=<id>)`.
|
||||
3. Consulta disponibilidad y crea la nueva cita con `skeen_odoo_create_appointment`.
|
||||
4. Confirma el cambio.
|
||||
|
||||
4. Cuando el paciente elige, confirma datos:
|
||||
- Nombre completo
|
||||
- Confirmar telefono
|
||||
- Alergias o condiciones especiales (opcional)
|
||||
## PASO 4: CANCELAR
|
||||
|
||||
5. Crea la cita usando `odoo_api_call`:
|
||||
Modelo: salon.booking, metodo: create
|
||||
Datos: { name, phone, service_id, date, time, state: 'confirmed' }
|
||||
1. `skeen_odoo_cancel_appointment(phone=<telefono>)` (cancela la cita activa más
|
||||
próxima; usa `booking_id` para una específica).
|
||||
2. Confirma al paciente y ofrece reagendar.
|
||||
|
||||
6. Programa recordatorio via cron:
|
||||
```bash
|
||||
hermes cron schedule --name "recordatorio-cita-[ID]" \
|
||||
--at "[fecha_cita] 18:00 - 1 day" \
|
||||
--command "skill skeen-recordatorios --phone [PHONE] --cita [DATE]"
|
||||
```
|
||||
## Formato de confirmación (cita individual)
|
||||
|
||||
7. Envia confirmacion via WACRM usando `wacrm_send`
|
||||
¡Listo! Tu cita está confirmada:
|
||||
|
||||
### PASO 3: Para REAGENDAR
|
||||
Servicio: [Nombre]
|
||||
Fecha: [día de semana], [DD] de [mes] de [AAAA]
|
||||
Hora: [HH:MM]
|
||||
Duración: [X] minutos
|
||||
Dirección: Blvd. Popotla 123, Playas de Rosarito
|
||||
|
||||
1. Busca citas del paciente por telefono via `odoo_api_call`
|
||||
2. Muestra citas activas
|
||||
3. Pide nueva fecha/hora
|
||||
4. Consulta disponibilidad
|
||||
5. Actualiza cita via `odoo_api_call` (model salon.booking, method write)
|
||||
6. Confirma cambio
|
||||
Te recordamos un día antes. Si necesitas cambiar o cancelar, escríbenos.
|
||||
|
||||
### PASO 4: Para CANCELAR
|
||||
## Reglas
|
||||
|
||||
1. Busca citas del paciente por telefono
|
||||
2. Confirma cual cita cancelar
|
||||
3. Pide motivo (opcional, para estadisticas)
|
||||
4. Cancela via `odoo_api_call` (state: 'cancelled')
|
||||
5. Ofrece reagendar
|
||||
|
||||
## Formato de Confirmacion
|
||||
|
||||
Perfecto! Tu cita esta confirmada:
|
||||
|
||||
Servicio: [Nombre servicio]
|
||||
Fecha: [Dia de semana], [DD] de [mes] de [AAAA]
|
||||
Hora: [HH:MM] AM/PM
|
||||
Duracion: [X] minutos
|
||||
Direccion: Blvd. Popotla 123, Playas de Rosarito
|
||||
|
||||
Te enviaremos un recordatorio un dia antes. Si necesitas cambiar o cancelar, solo escribenos!
|
||||
|
||||
## Reglas Importantes
|
||||
|
||||
- Solo agendar dentro del horario de la clinica
|
||||
- Validar que el paciente no tenga citas duplicadas
|
||||
- Consulta de valoracion gratuita tiene duracion de 15 min
|
||||
- Si no hay disponibilidad, ofrecer lista de espera
|
||||
- Guardar TODAS las citas en memoria para recordatorios
|
||||
- Solo agendar dentro del horario de la clínica (lo valida la disponibilidad).
|
||||
- No agendar citas duplicadas para el mismo paciente.
|
||||
- Consulta de valoración: 15 min.
|
||||
- Si no hay disponibilidad, ofrece otra fecha o lista de espera.
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# Disponibilidad y agendado — herramientas reales (SKEEN / Odoo)
|
||||
|
||||
La clínica expone una API pública en Odoo. El agente la consume con las
|
||||
herramientas del plugin `skeen_odoo` (HTTP + `Authorization: Bearer
|
||||
$ODOO_API_KEY`). Ya **no** se usan `execute_code`, `browser_navigate` ni curl.
|
||||
|
||||
## Herramientas
|
||||
|
||||
| Herramienta | Endpoint | Parámetros |
|
||||
|---|---|---|
|
||||
| `skeen_odoo_health` | GET `/skeen/api/v1/health` | — |
|
||||
| `skeen_odoo_services` | GET `/skeen/api/v1/services` | `search`, `category`, `limit` |
|
||||
| `skeen_odoo_available_slots` | POST `/skeen/api/v1/available_slots` | `service_id`, `date_from`, `date_to` |
|
||||
| `skeen_odoo_create_appointment` | POST `/skeen/api/v1/create_appointment` | `phone`, `name`, `service_id`, `date`, `time`, `doctor_id?`, `duration?` |
|
||||
| `skeen_odoo_cancel_appointment` | POST `/skeen/api/v1/cancel_appointment` | `phone`, `booking_id?` |
|
||||
| `skeen_odoo_patient_balance` | POST `/skeen/api/v1/patient_balance` | `phone` |
|
||||
| `skeen_odoo_wallet_redeem` | POST `/skeen/api/v1/wallet_redeem` | `phone`, `points` |
|
||||
|
||||
Contrato completo: `skeen_odoo/addons/skeen_whatsapp/controllers/main.py`.
|
||||
|
||||
## Ejemplo verificado
|
||||
|
||||
`available_slots(service_id=1, date_from="2026-10-20", date_to="2026-10-20")`
|
||||
devuelve slots reales (por ejemplo 08:00, 08:15, 08:30, ...). Los horarios se
|
||||
calculan según la duración del servicio y las citas ya agendadas.
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- `service_id` es el **id numérico vivo** de Odoo (no slugs como "cons-derm"):
|
||||
resuélvelo con `skeen_odoo_services`.
|
||||
- El catálogo real tiene miles de servicios; filtra con `search`/`category`.
|
||||
- Sin `doctor_id`, la disponibilidad es conservadora (cualquier cita del día
|
||||
ocupa el hueco), así que puede subestimar huecos reales.
|
||||
@@ -4,8 +4,8 @@ description: >
|
||||
Monedero digital de fidelidad de SKEEN Derma Experts.
|
||||
Consulta saldo, redime puntos, muestra historial.
|
||||
1 punto = $1 MXN. Se acumula 1 punto por cada $10 gastados.
|
||||
Usar cuando el usuario pregunte por puntos, saldo, o redencion.
|
||||
version: 1.0.0
|
||||
Usar cuando el usuario pregunte por puntos, saldo, o redención.
|
||||
version: 2.0.0
|
||||
metadata:
|
||||
hermes:
|
||||
requires:
|
||||
@@ -13,6 +13,8 @@ metadata:
|
||||
envVars:
|
||||
- name: ODOO_URL
|
||||
required: true
|
||||
- name: ODOO_API_KEY
|
||||
required: true
|
||||
platforms: [whatsapp, webchat, cli]
|
||||
activation: keyword
|
||||
keywords: ["puntos", "monedero", "saldo", "redimir", "fidelidad"]
|
||||
@@ -22,34 +24,29 @@ metadata:
|
||||
|
||||
## Consultar saldo
|
||||
|
||||
Usa `odoo_api_call`: modelo skeen.monedero, metodo get_balance, args: [phone_number]
|
||||
Usa `skeen_odoo_patient_balance(phone="<telefono>")`. Devuelve:
|
||||
`pending_balance`, `pending_invoices`, `wallet_points` (puntos) y
|
||||
`wallet_mxn` (= $ MXN disponibles).
|
||||
|
||||
Respuesta esperada: { points: 450, equivalent_mxn: 450, history: [...] }
|
||||
|
||||
Enviar via WACRM con formato:
|
||||
Formato de respuesta (tu mensaje se envía solo por WhatsApp):
|
||||
|
||||
Tu monedero SKEEN:
|
||||
Saldo actual: 450 puntos (= $450 MXN)
|
||||
Historial reciente:
|
||||
- 03/07/2026: +120 pts (Pago Tratamiento Acne — $1,200)
|
||||
- 28/06/2026: +80 pts (Pago Consulta + peel — $800)
|
||||
- 15/06/2026: +250 pts (Pago Depilacion laser — $2,500)
|
||||
Saldo actual: [wallet_points] puntos (= $[wallet_mxn] MXN)
|
||||
Saldo pendiente por pagar: $[pending_balance] MXN
|
||||
|
||||
Puedes redimir tus puntos en tu proxima visita! Cada 10 puntos = $10 MXN de descuento.
|
||||
¡Puedes redimir tus puntos en tu próxima visita!
|
||||
|
||||
## Redimir puntos
|
||||
|
||||
1. Verificar que tenga suficientes puntos
|
||||
2. Consultar monto a pagar de la cita/servicio
|
||||
3. Calcular descuento (maximo 50% del total)
|
||||
4. Confirmar redencion
|
||||
5. Aplicar via `odoo_api_call`: modelo skeen.monedero, metodo redeem
|
||||
6. Confirmar redencion via WACRM
|
||||
1. Consulta el saldo con `skeen_odoo_patient_balance`.
|
||||
2. Verifica que tenga suficientes puntos y que el descuento no exceda 50% del total.
|
||||
3. Confirma con el paciente.
|
||||
4. `skeen_odoo_wallet_redeem(phone="<telefono>", points=<puntos>)`.
|
||||
Devuelve `remaining_points` y `remaining_mxn`.
|
||||
|
||||
## Reglas
|
||||
|
||||
- Maximo redimible: 50% del total de la compra
|
||||
- Puntos vencen a los 12 meses de inactividad
|
||||
- No transferibles entre pacientes
|
||||
- Se acumulan en pagos confirmados unicamente
|
||||
- En devoluciones, se restan los puntos correspondientes
|
||||
- Máximo redimible: 50% del total de la compra.
|
||||
- Puntos vencen a los 12 meses de inactividad.
|
||||
- No transferibles entre pacientes.
|
||||
- Se acumulan en pagos confirmados únicamente.
|
||||
|
||||
@@ -1,79 +1,61 @@
|
||||
---
|
||||
name: skeen-pagos
|
||||
description: >
|
||||
Gestion de pagos para SKEEN Derma Experts.
|
||||
Genera links de pago (Stripe/MercadoPago), consulta estados,
|
||||
y gestiona facturacion. Usar cuando el usuario quiera pagar,
|
||||
consultar saldo, o ver sus pagos pendientes.
|
||||
version: 1.0.0
|
||||
Gestión de pagos para SKEEN Derma Experts.
|
||||
Consulta saldo y citas pendientes de pago por paciente.
|
||||
Usar cuando el usuario quiera consultar saldo, deuda o pagos pendientes.
|
||||
version: 2.0.0
|
||||
metadata:
|
||||
hermes:
|
||||
requires:
|
||||
bins: [python]
|
||||
env: [STRIPE_SECRET_KEY, MP_ACCESS_TOKEN]
|
||||
bins: []
|
||||
env: []
|
||||
envVars:
|
||||
- name: STRIPE_SECRET_KEY
|
||||
required: true
|
||||
- name: MP_ACCESS_TOKEN
|
||||
required: true
|
||||
- name: ODOO_URL
|
||||
required: true
|
||||
- name: ODOO_API_KEY
|
||||
required: true
|
||||
- name: STRIPE_SECRET_KEY
|
||||
required: false
|
||||
- name: MP_ACCESS_TOKEN
|
||||
required: false
|
||||
platforms: [whatsapp, webchat, cli]
|
||||
activation: keyword
|
||||
keywords: ["pagar", "saldo", "deuda", "factura", "pago", "stripe", "mercadopago"]
|
||||
---
|
||||
|
||||
# SKEEN — Gestion de Pagos
|
||||
# SKEEN — Gestión de Pagos
|
||||
|
||||
## Instrucciones de Uso
|
||||
## PASO 1: Identificar la intención
|
||||
|
||||
### PASO 1: Identificar intencion de pago
|
||||
- "Cuánto debo?" / "Saldo pendiente" → consultar deuda.
|
||||
- "Quiero pagar" → dar los métodos de pago de la clínica.
|
||||
- "Necesito factura" → tomar datos fiscales y avisar a recepción.
|
||||
|
||||
- "Quiero pagar" → Generar link de pago
|
||||
- "Cuanto debo?" / "Saldo pendiente" → Consultar deuda
|
||||
- "Ya pague" → Verificar pago
|
||||
- "Necesito factura" → Generar factura (CFDI)
|
||||
## PASO 2: Consultar saldo y citas pendientes
|
||||
|
||||
### PASO 2: Consultar servicios pendientes
|
||||
Usa `skeen_odoo_patient_balance(phone="<telefono>")`. Devuelve `pending_balance`
|
||||
(total pendiente en MXN) y `pending_invoices` (lista de citas por pagar, cada una
|
||||
con `reference`, `service`, `amount`, `date`). Además `wallet_points`/`wallet_mxn`
|
||||
del monedero.
|
||||
|
||||
Usa `odoo_api_call` para consultar modelo account.move:
|
||||
Dominio: [['partner_id.phone', '=', PACIENTE_PHONE], ['state', '=', 'posted'], ['payment_state', '!=', 'paid']]
|
||||
Campos: ['name', 'amount_residual', 'invoice_date_due']
|
||||
## PASO 3: Métodos de pago
|
||||
|
||||
### PASO 3: Generar link de pago
|
||||
La generación de links de pago en línea (Stripe / MercadoPago) **aún no está
|
||||
configurada** en esta instalación (faltan `STRIPE_SECRET_KEY` / `MP_ACCESS_TOKEN`).
|
||||
No prometas un link que no puedas generar. Ofrece en su lugar:
|
||||
|
||||
Para pagos en Mexico (MXN) — usa MercadoPago:
|
||||
Crear preference con items del servicio
|
||||
notification_url: https://skeen.mx/webhooks/mercadopago
|
||||
back_urls: { success: 'https://app.skeen.mx/pagos/exitoso' }
|
||||
- Pago en recepción (efectivo / tarjeta) el día de la cita.
|
||||
- Transferencia SPEI (pide los datos a recepción).
|
||||
- Si el paciente pide factura, toma RFC y régimen fiscal y avisa que recepción la emite.
|
||||
|
||||
Para pagos internacionales — usa Stripe Checkout:
|
||||
Crear session con line_items
|
||||
mode: 'payment'
|
||||
success_url: 'https://app.skeen.mx/pagos/exitoso'
|
||||
metadata: { patient_phone: PACIENTE_PHONE }
|
||||
## PASO 4: Confirmación
|
||||
|
||||
### PASO 4: Enviar link de pago
|
||||
|
||||
Usa `wacrm_send` para enviar mensaje con resumen y link.
|
||||
|
||||
Incluir:
|
||||
- Listado de servicios con precios
|
||||
- Total a pagar
|
||||
- Link de pago
|
||||
- Metodos aceptados (tarjeta, OXXO, SPEI)
|
||||
- Puntos de monedero que acumulara
|
||||
|
||||
### PASO 5: Confirmacion de pago
|
||||
|
||||
Cuando el webhook confirme el pago:
|
||||
1. Registra pago en Odoo via `odoo_api_call`
|
||||
2. Calcula puntos de monedero (10% del monto)
|
||||
3. Envia confirmacion + puntos acumulados
|
||||
Resume: servicios pendientes, total a pagar y puntos de monedero que acumulará
|
||||
(1 punto por cada $10 pagados). Tu respuesta se envía sola por WhatsApp.
|
||||
|
||||
## Reglas
|
||||
|
||||
- Siempre confirmar el monto antes de generar el link
|
||||
- Para pagos >$5,000 MXN, ofrecer opcion de meses sin intereses
|
||||
- Verificar que el telefono coincida con el paciente
|
||||
- Guardar referencia de pago en memoria
|
||||
- Confirma el monto antes de dar instrucciones de pago.
|
||||
- Verifica que el teléfono coincida con el paciente.
|
||||
- Nunca inventes links ni montos: usa siempre lo que devuelve la herramienta.
|
||||
|
||||
@@ -2,58 +2,50 @@
|
||||
name: skeen-rag
|
||||
description: >
|
||||
Catalogo de servicios dermatologicos de SKEEN Derma Experts.
|
||||
Proporciona informacion sobre tratamientos, precios, duracion y contraindicaciones.
|
||||
Proporciona informacion sobre tratamientos, precios, duracion, paquetes y contraindicaciones.
|
||||
SOLO usar cuando el usuario pregunte por servicios, tratamientos, precios o el catalogo.
|
||||
version: 1.0.0
|
||||
version: 2.0.0
|
||||
metadata:
|
||||
hermes:
|
||||
requires:
|
||||
bins: []
|
||||
envVars:
|
||||
- name: SKEEN_SERVICES_JSON
|
||||
required: false
|
||||
description: "Path al archivo JSON con catalogo de servicios"
|
||||
- name: ODOO_URL
|
||||
required: true
|
||||
- name: ODOO_API_KEY
|
||||
required: true
|
||||
platforms: [whatsapp, webchat, cli]
|
||||
activation: keyword
|
||||
keywords: ["servicios", "tratamientos", "precios", "catalogo", "acne", "peel", "laser", "dermatologia"]
|
||||
keywords: ["servicios", "tratamientos", "precios", "catalogo", "acne", "peel", "laser", "dermatologia", "paquete", "sesiones"]
|
||||
---
|
||||
|
||||
# SKEEN Derma Experts — Catalogo de Servicios
|
||||
# SKEEN Derma Experts — Catálogo de Servicios
|
||||
|
||||
## Instrucciones de Uso
|
||||
El catálogo es **vivo**: se lee de Odoo con la herramienta `skeen_odoo_services`.
|
||||
No uses listas fijas ni slugs.
|
||||
|
||||
Cuando un paciente pregunte por tratamientos, servicios, precios o recomendaciones dermatologicas:
|
||||
## Instrucciones
|
||||
|
||||
1. Lee el archivo de catalogo: `${env:SKEEN_SERVICES_JSON:-/skills/data/skeen-servicios.json}`
|
||||
2. Filtra los servicios relevantes segun la pregunta
|
||||
3. Presenta la informacion de forma clara y amigable
|
||||
4. SIEMPRE incluye: nombre del tratamiento, precio aproximado, duracion, y una breve descripcion
|
||||
5. NUNCA diagnostiques condiciones medicas — invita al paciente a agendar una consulta
|
||||
6. Si no encuentras el servicio, ofrece agendar una consulta de valoracion
|
||||
1. Para saber cuántos hay: `skeen_odoo_services(limit=1)`. El campo `total_active`
|
||||
es el número real de servicios activos de la clínica; `sellable_services` es
|
||||
cuántos tienen precio (>0). OJO: el catálogo de Odoo incluye miles de registros
|
||||
históricos en $0, por eso al buscar usa siempre `min_price=1`.
|
||||
2. Para buscar un tratamiento concreto: `skeen_odoo_services(search="<palabra>", min_price=1)`.
|
||||
3. Filtra por categoría con `skeen_odoo_services(category="consulta"|"tratamiento"|"procedimiento"|"paquete", min_price=1)`.
|
||||
4. Presenta: nombre, precio por sesión, duración y una descripción breve.
|
||||
Si `package_sessions` > 0 y `package_price` > 0, menciona el paquete y el ahorro.
|
||||
5. El `id` numérico que devuelve la herramienta es el `service_id` que necesitas
|
||||
para `skeen_odoo_available_slots` y `skeen_odoo_create_appointment`.
|
||||
|
||||
## Formato de Respuesta
|
||||
## Límites
|
||||
|
||||
Hola! En SKEEN contamos con estos tratamientos para [condicion]:
|
||||
- NO diagnostiques condiciones médicas — invita a agendar una consulta.
|
||||
- NO recomiendes medicamentos específicos.
|
||||
- Precios pueden variar según valoración.
|
||||
- Si no encuentras el servicio, ofrece una consulta de valoración.
|
||||
|
||||
1. **[Nombre tratamiento]** — $[precio] MXN
|
||||
Duracion: [X] minutos
|
||||
[Descripcion breve]
|
||||
|
||||
2. **[Nombre tratamiento]** — $[precio] MXN
|
||||
...
|
||||
|
||||
Te gustaria agendar una consulta de valoracion? Podemos evaluar tu caso especifico y recomendarte el mejor tratamiento.
|
||||
|
||||
## Limites
|
||||
|
||||
- NO diagnosticar — solo informacion general
|
||||
- NO recomendar medicamentos especificos
|
||||
- SIEMPRE sugerir consulta presencial para evaluacion
|
||||
- Precios son aproximados — pueden variar segun evaluacion
|
||||
|
||||
## Datos de la Clinica
|
||||
## Datos de la clínica
|
||||
|
||||
- Nombre: SKEEN Derma Experts
|
||||
- Direccion: Blvd. Popotla 123, Playas de Rosarito, BC, Mexico
|
||||
- Dirección: Blvd. Popotla 123, Playas de Rosarito, BC, Mexico
|
||||
- Horario: Lun-Vie 9:00-18:00, Sab 9:00-14:00
|
||||
- Telefono: +52 (664) 123-4567
|
||||
|
||||
@@ -4,7 +4,7 @@ description: >
|
||||
Recordatorios de citas, follow-ups y re-engagement para SKEEN.
|
||||
Usar PRINCIPALMENTE via cron (hermes cron).
|
||||
Tambien puede responder a consultas sobre recordatorios.
|
||||
version: 1.0.0
|
||||
version: 2.0.0
|
||||
metadata:
|
||||
hermes:
|
||||
always: true
|
||||
@@ -13,10 +13,12 @@ metadata:
|
||||
envVars:
|
||||
- name: ODOO_URL
|
||||
required: true
|
||||
- name: ODOO_API_KEY
|
||||
required: true
|
||||
- name: WACRM_URL
|
||||
required: true
|
||||
required: false
|
||||
- name: WACRM_API_KEY
|
||||
required: true
|
||||
required: false
|
||||
platforms: [whatsapp, webchat, cli]
|
||||
activation: cron
|
||||
cron:
|
||||
@@ -30,60 +32,37 @@ metadata:
|
||||
|
||||
# SKEEN — Recordatorios y Follow-ups
|
||||
|
||||
## Ejecucion via Cron
|
||||
Herramientas: `skeen_odoo_patient_balance` (datos del paciente y citas) y
|
||||
`skeen_wacrm_send` (envío proactivo por WhatsApp). Las respuestas normales a un
|
||||
mensaje entrante las entrega el bridge automáticamente; `skeen_wacrm_send` es
|
||||
solo para mensajes que el paciente NO provocó (recordatorios, seguimientos).
|
||||
|
||||
Esta skill se ejecuta automaticamente via `hermes cron`.
|
||||
## PASO 1: Recordatorios de citas (24 h antes)
|
||||
|
||||
### PASO 1: Generar recordatorios (cada 6 horas)
|
||||
1. Identifica las citas de mañana (el backend de agenda es `skeen.cita` en Odoo).
|
||||
2. Envía el recordatorio al paciente:
|
||||
`skeen_wacrm_send(to="+52...", text="Hola [Nombre]! Te recordamos tu cita
|
||||
mañana [fecha] a las [hora] en SKEEN Derma Experts.")`
|
||||
3. Registra el envío. Si falla, reintenta en la siguiente ejecución (máx. 3).
|
||||
|
||||
Recordatorios de citas (24h antes):
|
||||
Consulta Odoo: salon.booking con date=tomorrow, state=confirmed
|
||||
Para cada cita, programa cron:
|
||||
```bash
|
||||
hermes cron schedule --name "recordatorio-[booking_id]" \
|
||||
--at "[fecha_cita] 18:00 - 1 day" \
|
||||
--command "skill skeen-recordatorios --action send --phone [PHONE] --cita [DATE]"
|
||||
```
|
||||
## PASO 2: Follow-ups post-tratamiento
|
||||
|
||||
Follow-ups post-tratamiento (7 dias despues):
|
||||
Consulta Odoo: salon.booking con date=7_days_ago, state=done
|
||||
Programa cron para cada paciente:
|
||||
```bash
|
||||
hermes cron schedule --name "followup-[booking_id]" \
|
||||
--at "[fecha_cita + 7 days] 10:00" \
|
||||
--command "skill skeen-recordatorios --action followup --phone [PHONE]"
|
||||
```
|
||||
A los 7 días de una cita completada, envía con `skeen_wacrm_send`:
|
||||
"Hola [Nombre]! Han pasado 7 días desde tu [servicio]. ¿Cómo te sientes?"
|
||||
|
||||
Re-engagement (pacientes inactivos 90 dias):
|
||||
Consulta Odoo: res.partner con last_visit <= 90_days_ago
|
||||
Programa cron semanal:
|
||||
```bash
|
||||
hermes cron schedule --name "reengage-[partner_id]" \
|
||||
--at "lunes 9:00" \
|
||||
--command "skill skeen-recordatorios --action reengage --phone [PHONE]"
|
||||
```
|
||||
## PASO 3: Re-engagement (pacientes inactivos ~90 días)
|
||||
|
||||
### PASO 2: Enviar recordatorios (6 PM diario)
|
||||
Envía con `skeen_wacrm_send`:
|
||||
"Hola [Nombre]! Te extrañamos en SKEEN. Agenda tu próxima visita y recibe 50 puntos."
|
||||
|
||||
Para cada recordatorio programado para hoy:
|
||||
1. Enviar via WACRM usando `wacrm_send` con template aprobado
|
||||
2. Si es exitoso, marcar como enviado en Odoo
|
||||
3. Si falla, reintentar en siguiente ejecucion (max 3 intentos)
|
||||
## Pruebas / simulación
|
||||
|
||||
### PASO 3: Limpieza
|
||||
`skeen_wacrm_send(..., simulate=true)` usa el endpoint de simulación de WACRM:
|
||||
NO envía un mensaje real. Úsalo para validar plantillas.
|
||||
|
||||
Eliminar cron jobs completados con mas de 7 dias:
|
||||
```bash
|
||||
hermes cron list --completed | awk '$4 > 7 {print $1}' | xargs hermes cron delete
|
||||
```
|
||||
## Notas de operación
|
||||
|
||||
## Templates de WhatsApp Aprobados
|
||||
|
||||
recordatorio_cita_skeen:
|
||||
"Hola {{1}}! Te recordamos tu cita manana {{2}} a las {{3}} en SKEEN Derma Experts."
|
||||
|
||||
followup_tratamiento_skeen:
|
||||
"Hola {{1}}! Han pasado {{2}} dias desde tu {{3}}. Como te sientes?"
|
||||
|
||||
reengage_skeen:
|
||||
"Hola {{1}}! Te extranamos en SKEEN. Agenda tu proxima visita y recibe 50 puntos."
|
||||
- El envío de recordatorios por *template* de Meta requiere plantillas aprobadas
|
||||
y opt-in del paciente; hasta que existan, envía texto simple dentro de la
|
||||
ventana de 24 h.
|
||||
- El heartbeat de recordatorios del bridge está controlado por `REMINDERS_ENABLED`.
|
||||
|
||||
Reference in New Issue
Block a user