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 os
|
||||||
import xmlrpc.client
|
from typing import Any, Dict, Optional
|
||||||
from typing import Any, Dict, List, Optional
|
|
||||||
|
|
||||||
ODOO_URL = os.environ.get("ODOO_URL", "http://odoo:8069")
|
import requests
|
||||||
ODOO_DB = os.environ.get("ODOO_DB", "skeen")
|
|
||||||
ODOO_API_KEY = os.environ.get("ODOO_API_KEY")
|
DEFAULT_TIMEOUT = 30
|
||||||
|
|
||||||
|
|
||||||
def odoo_api_call(model: str, method: str, args: List = None, kwargs: Dict = None) -> Any:
|
def odoo_api_call(endpoint: str, payload: Optional[Dict[str, Any]] = None,
|
||||||
"""
|
method: str = "POST", timeout: int = DEFAULT_TIMEOUT) -> Any:
|
||||||
Plugin de Hermes para comunicarse con Odoo via XML-RPC.
|
"""Llama un endpoint de la API pública y devuelve el JSON.
|
||||||
|
|
||||||
Uso desde skills:
|
|
||||||
odoo_api_call(model="salon.booking", method="get_available_slots",
|
|
||||||
args=[service_id, date_from, date_to])
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
model: Nombre del modelo Odoo (ej: 'res.partner', 'salon.booking')
|
endpoint: Ruta relativa, p. ej. ``/skeen/api/v1/services``.
|
||||||
method: Metodo a ejecutar (ej: 'search_read', 'create', 'write')
|
payload: Cuerpo JSON (None para GET).
|
||||||
args: Lista de argumentos posicionales
|
method: Verbo HTTP (GET/POST).
|
||||||
kwargs: Diccionario de argumentos nombrados
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Resultado de la llamada XML-RPC
|
|
||||||
"""
|
"""
|
||||||
if args is None:
|
base = (os.environ.get("ODOO_URL") or "http://localhost:8069").rstrip("/")
|
||||||
args = []
|
key = os.environ.get("ODOO_API_KEY")
|
||||||
if kwargs is None:
|
if not key:
|
||||||
kwargs = {}
|
raise RuntimeError("ODOO_API_KEY no está configurada en el entorno.")
|
||||||
|
|
||||||
if not ODOO_API_KEY:
|
resp = requests.request(
|
||||||
raise ValueError("ODOO_API_KEY no configurada en variables de entorno")
|
method,
|
||||||
|
base + endpoint,
|
||||||
# Autenticacion
|
json=payload,
|
||||||
common = xmlrpc.client.ServerProxy(f"{ODOO_URL}/xmlrpc/2/common")
|
headers={"Authorization": f"Bearer {key}", "Accept": "application/json"},
|
||||||
uid = common.authenticate(ODOO_DB, 1, ODOO_API_KEY, {})
|
timeout=timeout,
|
||||||
|
|
||||||
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.raise_for_status()
|
||||||
return result
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
def odoo_search_read(model: str, domain: List = None, fields: List[str] = None,
|
def health() -> Any:
|
||||||
limit: int = 100) -> List[Dict]:
|
return odoo_api_call("/skeen/api/v1/health", method="GET")
|
||||||
"""
|
|
||||||
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 odoo_create(model: str, values: Dict) -> int:
|
def services() -> Any:
|
||||||
"""
|
return odoo_api_call("/skeen/api/v1/services", method="GET")
|
||||||
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 odoo_write(model: str, ids: List[int], values: Dict) -> bool:
|
def available_slots(service_id: int, date_from: str, date_to: Optional[str] = None) -> Any:
|
||||||
"""
|
return odoo_api_call("/skeen/api/v1/available_slots", {
|
||||||
Atajo para write en Odoo.
|
"service_id": int(service_id),
|
||||||
|
"date_from": date_from,
|
||||||
Uso:
|
"date_to": date_to or date_from,
|
||||||
odoo_write(model="salon.booking", ids=[123],
|
})
|
||||||
values={"state": "cancelled"})
|
|
||||||
"""
|
|
||||||
return odoo_api_call(model=model, method="write", args=[ids, values])
|
|
||||||
|
|
||||||
|
|
||||||
# Registro del plugin para Hermes
|
def create_appointment(phone: str, name: str, service_id: int, date: str, time: str,
|
||||||
PLUGIN_NAME = "odoo_api_call"
|
**extra: Any) -> Any:
|
||||||
PLUGIN_VERSION = "1.0.0"
|
payload = {"phone": phone, "name": name, "service_id": int(service_id),
|
||||||
PLUGIN_DESCRIPTION = "Comunicacion con Odoo ERP via XML-RPC"
|
"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 = {
|
def cancel_appointment(phone: str, booking_id: Optional[int] = None) -> Any:
|
||||||
"odoo_api_call": odoo_api_call,
|
payload: Dict[str, Any] = {"phone": phone}
|
||||||
"odoo_search_read": odoo_search_read,
|
if booking_id is not None:
|
||||||
"odoo_create": odoo_create,
|
payload["booking_id"] = int(booking_id)
|
||||||
"odoo_write": odoo_write,
|
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
|
name: skeen-agendar
|
||||||
description: >
|
description: >
|
||||||
Agendamiento de citas para SKEEN Derma Experts.
|
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.
|
Usar cuando el usuario quiera agendar, reagendar o cancelar una cita.
|
||||||
version: 1.0.0
|
version: 2.0.0
|
||||||
metadata:
|
metadata:
|
||||||
hermes:
|
hermes:
|
||||||
requires:
|
requires:
|
||||||
bins: [python]
|
bins: []
|
||||||
envVars:
|
envVars:
|
||||||
- name: ODOO_URL
|
- name: ODOO_URL
|
||||||
required: true
|
required: true
|
||||||
- name: ODOO_DB
|
|
||||||
required: true
|
|
||||||
- name: ODOO_API_KEY
|
- name: ODOO_API_KEY
|
||||||
required: true
|
required: true
|
||||||
platforms: [whatsapp, webchat, cli]
|
platforms: [whatsapp, webchat, cli]
|
||||||
activation: keyword
|
activation: keyword
|
||||||
keywords: ["agendar", "cita", "hora", "reagendar", "cancelar", "disponibilidad"]
|
keywords: ["agendar", "cita", "hora", "reagendar", "cancelar", "disponibilidad", "paquete", "sesiones"]
|
||||||
---
|
---
|
||||||
|
|
||||||
# SKEEN — Agendamiento de Citas
|
# 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
|
- "Quiero cambiar mi cita" / "Reagendar" → REAGENDAR
|
||||||
- "Cancelar mi cita" → CANCELAR
|
- "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):
|
1. Identifica el servicio. Si no es obvio, búscalo con la herramienta:
|
||||||
- Consulta dermatologica general ($800 MXN, 30 min)
|
`skeen_odoo_services(search="<palabra clave>", min_price=1)`.
|
||||||
- Tratamiento facial ($1,200-$3,500 MXN, 45-90 min)
|
Usa `min_price=1` para excluir servicios históricos sin precio (el catálogo de
|
||||||
- Depilacion laser (desde $500 MXN por zona, 15-60 min)
|
Odoo trae miles de registros antiguos en $0). **`service_id` es el id numérico
|
||||||
- Peel quimico ($1,500 MXN, 30 min)
|
que devuelve la herramienta** (NO uses slugs como "cons-derm"). Nunca inventes
|
||||||
- O consulta de valoracion gratuita (15 min)
|
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`:
|
## PASO 3: REAGENDAR
|
||||||
Modelo: salon.booking, metodo: get_available_slots
|
|
||||||
Argumentos: [service_id, date_from, date_to]
|
|
||||||
|
|
||||||
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:
|
## PASO 4: CANCELAR
|
||||||
- Nombre completo
|
|
||||||
- Confirmar telefono
|
|
||||||
- Alergias o condiciones especiales (opcional)
|
|
||||||
|
|
||||||
5. Crea la cita usando `odoo_api_call`:
|
1. `skeen_odoo_cancel_appointment(phone=<telefono>)` (cancela la cita activa más
|
||||||
Modelo: salon.booking, metodo: create
|
próxima; usa `booking_id` para una específica).
|
||||||
Datos: { name, phone, service_id, date, time, state: 'confirmed' }
|
2. Confirma al paciente y ofrece reagendar.
|
||||||
|
|
||||||
6. Programa recordatorio via cron:
|
## Formato de confirmación (cita individual)
|
||||||
```bash
|
|
||||||
hermes cron schedule --name "recordatorio-cita-[ID]" \
|
|
||||||
--at "[fecha_cita] 18:00 - 1 day" \
|
|
||||||
--command "skill skeen-recordatorios --phone [PHONE] --cita [DATE]"
|
|
||||||
```
|
|
||||||
|
|
||||||
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`
|
Te recordamos un día antes. Si necesitas cambiar o cancelar, escríbenos.
|
||||||
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
|
|
||||||
|
|
||||||
### PASO 4: Para CANCELAR
|
## Reglas
|
||||||
|
|
||||||
1. Busca citas del paciente por telefono
|
- Solo agendar dentro del horario de la clínica (lo valida la disponibilidad).
|
||||||
2. Confirma cual cita cancelar
|
- No agendar citas duplicadas para el mismo paciente.
|
||||||
3. Pide motivo (opcional, para estadisticas)
|
- Consulta de valoración: 15 min.
|
||||||
4. Cancela via `odoo_api_call` (state: 'cancelled')
|
- Si no hay disponibilidad, ofrece otra fecha o lista de espera.
|
||||||
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
|
|
||||||
|
|||||||
@@ -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.
|
Monedero digital de fidelidad de SKEEN Derma Experts.
|
||||||
Consulta saldo, redime puntos, muestra historial.
|
Consulta saldo, redime puntos, muestra historial.
|
||||||
1 punto = $1 MXN. Se acumula 1 punto por cada $10 gastados.
|
1 punto = $1 MXN. Se acumula 1 punto por cada $10 gastados.
|
||||||
Usar cuando el usuario pregunte por puntos, saldo, o redencion.
|
Usar cuando el usuario pregunte por puntos, saldo, o redención.
|
||||||
version: 1.0.0
|
version: 2.0.0
|
||||||
metadata:
|
metadata:
|
||||||
hermes:
|
hermes:
|
||||||
requires:
|
requires:
|
||||||
@@ -13,6 +13,8 @@ metadata:
|
|||||||
envVars:
|
envVars:
|
||||||
- name: ODOO_URL
|
- name: ODOO_URL
|
||||||
required: true
|
required: true
|
||||||
|
- name: ODOO_API_KEY
|
||||||
|
required: true
|
||||||
platforms: [whatsapp, webchat, cli]
|
platforms: [whatsapp, webchat, cli]
|
||||||
activation: keyword
|
activation: keyword
|
||||||
keywords: ["puntos", "monedero", "saldo", "redimir", "fidelidad"]
|
keywords: ["puntos", "monedero", "saldo", "redimir", "fidelidad"]
|
||||||
@@ -22,34 +24,29 @@ metadata:
|
|||||||
|
|
||||||
## Consultar saldo
|
## 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: [...] }
|
Formato de respuesta (tu mensaje se envía solo por WhatsApp):
|
||||||
|
|
||||||
Enviar via WACRM con formato:
|
|
||||||
|
|
||||||
Tu monedero SKEEN:
|
Tu monedero SKEEN:
|
||||||
Saldo actual: 450 puntos (= $450 MXN)
|
Saldo actual: [wallet_points] puntos (= $[wallet_mxn] MXN)
|
||||||
Historial reciente:
|
Saldo pendiente por pagar: $[pending_balance] MXN
|
||||||
- 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)
|
|
||||||
|
|
||||||
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
|
## Redimir puntos
|
||||||
|
|
||||||
1. Verificar que tenga suficientes puntos
|
1. Consulta el saldo con `skeen_odoo_patient_balance`.
|
||||||
2. Consultar monto a pagar de la cita/servicio
|
2. Verifica que tenga suficientes puntos y que el descuento no exceda 50% del total.
|
||||||
3. Calcular descuento (maximo 50% del total)
|
3. Confirma con el paciente.
|
||||||
4. Confirmar redencion
|
4. `skeen_odoo_wallet_redeem(phone="<telefono>", points=<puntos>)`.
|
||||||
5. Aplicar via `odoo_api_call`: modelo skeen.monedero, metodo redeem
|
Devuelve `remaining_points` y `remaining_mxn`.
|
||||||
6. Confirmar redencion via WACRM
|
|
||||||
|
|
||||||
## Reglas
|
## Reglas
|
||||||
|
|
||||||
- Maximo redimible: 50% del total de la compra
|
- Máximo redimible: 50% del total de la compra.
|
||||||
- Puntos vencen a los 12 meses de inactividad
|
- Puntos vencen a los 12 meses de inactividad.
|
||||||
- No transferibles entre pacientes
|
- No transferibles entre pacientes.
|
||||||
- Se acumulan en pagos confirmados unicamente
|
- Se acumulan en pagos confirmados únicamente.
|
||||||
- En devoluciones, se restan los puntos correspondientes
|
|
||||||
|
|||||||
@@ -1,79 +1,61 @@
|
|||||||
---
|
---
|
||||||
name: skeen-pagos
|
name: skeen-pagos
|
||||||
description: >
|
description: >
|
||||||
Gestion de pagos para SKEEN Derma Experts.
|
Gestión de pagos para SKEEN Derma Experts.
|
||||||
Genera links de pago (Stripe/MercadoPago), consulta estados,
|
Consulta saldo y citas pendientes de pago por paciente.
|
||||||
y gestiona facturacion. Usar cuando el usuario quiera pagar,
|
Usar cuando el usuario quiera consultar saldo, deuda o pagos pendientes.
|
||||||
consultar saldo, o ver sus pagos pendientes.
|
version: 2.0.0
|
||||||
version: 1.0.0
|
|
||||||
metadata:
|
metadata:
|
||||||
hermes:
|
hermes:
|
||||||
requires:
|
requires:
|
||||||
bins: [python]
|
bins: []
|
||||||
env: [STRIPE_SECRET_KEY, MP_ACCESS_TOKEN]
|
env: []
|
||||||
envVars:
|
envVars:
|
||||||
- name: STRIPE_SECRET_KEY
|
|
||||||
required: true
|
|
||||||
- name: MP_ACCESS_TOKEN
|
|
||||||
required: true
|
|
||||||
- name: ODOO_URL
|
- name: ODOO_URL
|
||||||
required: true
|
required: true
|
||||||
|
- name: ODOO_API_KEY
|
||||||
|
required: true
|
||||||
|
- name: STRIPE_SECRET_KEY
|
||||||
|
required: false
|
||||||
|
- name: MP_ACCESS_TOKEN
|
||||||
|
required: false
|
||||||
platforms: [whatsapp, webchat, cli]
|
platforms: [whatsapp, webchat, cli]
|
||||||
activation: keyword
|
activation: keyword
|
||||||
keywords: ["pagar", "saldo", "deuda", "factura", "pago", "stripe", "mercadopago"]
|
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
|
## PASO 2: Consultar saldo y citas pendientes
|
||||||
- "Cuanto debo?" / "Saldo pendiente" → Consultar deuda
|
|
||||||
- "Ya pague" → Verificar pago
|
|
||||||
- "Necesito factura" → Generar factura (CFDI)
|
|
||||||
|
|
||||||
### 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:
|
## PASO 3: Métodos de pago
|
||||||
Dominio: [['partner_id.phone', '=', PACIENTE_PHONE], ['state', '=', 'posted'], ['payment_state', '!=', 'paid']]
|
|
||||||
Campos: ['name', 'amount_residual', 'invoice_date_due']
|
|
||||||
|
|
||||||
### 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:
|
- Pago en recepción (efectivo / tarjeta) el día de la cita.
|
||||||
Crear preference con items del servicio
|
- Transferencia SPEI (pide los datos a recepción).
|
||||||
notification_url: https://skeen.mx/webhooks/mercadopago
|
- Si el paciente pide factura, toma RFC y régimen fiscal y avisa que recepción la emite.
|
||||||
back_urls: { success: 'https://app.skeen.mx/pagos/exitoso' }
|
|
||||||
|
|
||||||
Para pagos internacionales — usa Stripe Checkout:
|
## PASO 4: Confirmación
|
||||||
Crear session con line_items
|
|
||||||
mode: 'payment'
|
|
||||||
success_url: 'https://app.skeen.mx/pagos/exitoso'
|
|
||||||
metadata: { patient_phone: PACIENTE_PHONE }
|
|
||||||
|
|
||||||
### PASO 4: Enviar link de pago
|
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.
|
||||||
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
|
|
||||||
|
|
||||||
## Reglas
|
## Reglas
|
||||||
|
|
||||||
- Siempre confirmar el monto antes de generar el link
|
- Confirma el monto antes de dar instrucciones de pago.
|
||||||
- Para pagos >$5,000 MXN, ofrecer opcion de meses sin intereses
|
- Verifica que el teléfono coincida con el paciente.
|
||||||
- Verificar que el telefono coincida con el paciente
|
- Nunca inventes links ni montos: usa siempre lo que devuelve la herramienta.
|
||||||
- Guardar referencia de pago en memoria
|
|
||||||
|
|||||||
@@ -2,58 +2,50 @@
|
|||||||
name: skeen-rag
|
name: skeen-rag
|
||||||
description: >
|
description: >
|
||||||
Catalogo de servicios dermatologicos de SKEEN Derma Experts.
|
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.
|
SOLO usar cuando el usuario pregunte por servicios, tratamientos, precios o el catalogo.
|
||||||
version: 1.0.0
|
version: 2.0.0
|
||||||
metadata:
|
metadata:
|
||||||
hermes:
|
hermes:
|
||||||
requires:
|
requires:
|
||||||
bins: []
|
bins: []
|
||||||
envVars:
|
envVars:
|
||||||
- name: SKEEN_SERVICES_JSON
|
- name: ODOO_URL
|
||||||
required: false
|
required: true
|
||||||
description: "Path al archivo JSON con catalogo de servicios"
|
- name: ODOO_API_KEY
|
||||||
|
required: true
|
||||||
platforms: [whatsapp, webchat, cli]
|
platforms: [whatsapp, webchat, cli]
|
||||||
activation: keyword
|
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}`
|
1. Para saber cuántos hay: `skeen_odoo_services(limit=1)`. El campo `total_active`
|
||||||
2. Filtra los servicios relevantes segun la pregunta
|
es el número real de servicios activos de la clínica; `sellable_services` es
|
||||||
3. Presenta la informacion de forma clara y amigable
|
cuántos tienen precio (>0). OJO: el catálogo de Odoo incluye miles de registros
|
||||||
4. SIEMPRE incluye: nombre del tratamiento, precio aproximado, duracion, y una breve descripcion
|
históricos en $0, por eso al buscar usa siempre `min_price=1`.
|
||||||
5. NUNCA diagnostiques condiciones medicas — invita al paciente a agendar una consulta
|
2. Para buscar un tratamiento concreto: `skeen_odoo_services(search="<palabra>", min_price=1)`.
|
||||||
6. Si no encuentras el servicio, ofrece agendar una consulta de valoracion
|
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
|
## Datos de la clínica
|
||||||
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
|
|
||||||
|
|
||||||
- Nombre: SKEEN Derma Experts
|
- 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
|
- 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.
|
Recordatorios de citas, follow-ups y re-engagement para SKEEN.
|
||||||
Usar PRINCIPALMENTE via cron (hermes cron).
|
Usar PRINCIPALMENTE via cron (hermes cron).
|
||||||
Tambien puede responder a consultas sobre recordatorios.
|
Tambien puede responder a consultas sobre recordatorios.
|
||||||
version: 1.0.0
|
version: 2.0.0
|
||||||
metadata:
|
metadata:
|
||||||
hermes:
|
hermes:
|
||||||
always: true
|
always: true
|
||||||
@@ -13,10 +13,12 @@ metadata:
|
|||||||
envVars:
|
envVars:
|
||||||
- name: ODOO_URL
|
- name: ODOO_URL
|
||||||
required: true
|
required: true
|
||||||
|
- name: ODOO_API_KEY
|
||||||
|
required: true
|
||||||
- name: WACRM_URL
|
- name: WACRM_URL
|
||||||
required: true
|
required: false
|
||||||
- name: WACRM_API_KEY
|
- name: WACRM_API_KEY
|
||||||
required: true
|
required: false
|
||||||
platforms: [whatsapp, webchat, cli]
|
platforms: [whatsapp, webchat, cli]
|
||||||
activation: cron
|
activation: cron
|
||||||
cron:
|
cron:
|
||||||
@@ -30,60 +32,37 @@ metadata:
|
|||||||
|
|
||||||
# SKEEN — Recordatorios y Follow-ups
|
# 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):
|
## PASO 2: Follow-ups post-tratamiento
|
||||||
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]"
|
|
||||||
```
|
|
||||||
|
|
||||||
Follow-ups post-tratamiento (7 dias despues):
|
A los 7 días de una cita completada, envía con `skeen_wacrm_send`:
|
||||||
Consulta Odoo: salon.booking con date=7_days_ago, state=done
|
"Hola [Nombre]! Han pasado 7 días desde tu [servicio]. ¿Cómo te sientes?"
|
||||||
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]"
|
|
||||||
```
|
|
||||||
|
|
||||||
Re-engagement (pacientes inactivos 90 dias):
|
## PASO 3: Re-engagement (pacientes inactivos ~90 días)
|
||||||
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 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:
|
## Pruebas / simulación
|
||||||
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)
|
|
||||||
|
|
||||||
### 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:
|
## Notas de operación
|
||||||
```bash
|
|
||||||
hermes cron list --completed | awk '$4 > 7 {print $1}' | xargs hermes cron delete
|
|
||||||
```
|
|
||||||
|
|
||||||
## Templates de WhatsApp Aprobados
|
- 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
|
||||||
recordatorio_cita_skeen:
|
ventana de 24 h.
|
||||||
"Hola {{1}}! Te recordamos tu cita manana {{2}} a las {{3}} en SKEEN Derma Experts."
|
- El heartbeat de recordatorios del bridge está controlado por `REMINDERS_ENABLED`.
|
||||||
|
|
||||||
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."
|
|
||||||
|
|||||||
Reference in New Issue
Block a user