From 5fc0d2a780a35561a0d1a73d64ece28ffb244143 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Consultor=C3=ADa=20Alcaraz=20Salazar?= Date: Sun, 11 Oct 2026 21:42:32 +0000 Subject: [PATCH] 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 --- hermes/hermes/plugins/odoo_api_call.py | 173 ++++---- hermes/hermes/plugins/skeen_odoo/__init__.py | 380 ++++++++++++++++++ hermes/hermes/plugins/skeen_odoo/client.py | 88 ++++ hermes/hermes/plugins/skeen_odoo/plugin.yaml | 20 + hermes/hermes/plugins/skeen_wacrm/__init__.py | 99 +++++ hermes/hermes/plugins/skeen_wacrm/plugin.yaml | 14 + .../2026-09-11T09-33-23Z/manifest.json | 13 + .../2026-09-18T09-52-18Z/manifest.json | 13 + .../2026-09-25T09-57-24Z/manifest.json | 13 + .../2026-10-02T10-03-51Z/manifest.json | 13 + .../2026-10-09T10-26-38Z/manifest.json | 13 + hermes/hermes/skills/.curator_state | 9 + hermes/hermes/skills/.hub/lock.json | 1 + hermes/hermes/skills/.hub/taps.json | 1 + hermes/hermes/skills/.usage.json | 28 ++ hermes/hermes/skills/.usage.json.lock | 0 hermes/hermes/skills/skeen-agendar/SKILL.md | 127 +++--- .../references/disponibilidad-endpoints.md | 33 ++ hermes/hermes/skills/skeen-monedero/SKILL.md | 43 +- hermes/hermes/skills/skeen-pagos/SKILL.md | 88 ++-- hermes/hermes/skills/skeen-rag/SKILL.md | 64 ++- .../skills/skeen-recordatorios/SKILL.md | 79 ++-- 22 files changed, 990 insertions(+), 322 deletions(-) create mode 100644 hermes/hermes/plugins/skeen_odoo/__init__.py create mode 100644 hermes/hermes/plugins/skeen_odoo/client.py create mode 100644 hermes/hermes/plugins/skeen_odoo/plugin.yaml create mode 100644 hermes/hermes/plugins/skeen_wacrm/__init__.py create mode 100644 hermes/hermes/plugins/skeen_wacrm/plugin.yaml create mode 100644 hermes/hermes/skills/.curator_backups/2026-09-11T09-33-23Z/manifest.json create mode 100644 hermes/hermes/skills/.curator_backups/2026-09-18T09-52-18Z/manifest.json create mode 100644 hermes/hermes/skills/.curator_backups/2026-09-25T09-57-24Z/manifest.json create mode 100644 hermes/hermes/skills/.curator_backups/2026-10-02T10-03-51Z/manifest.json create mode 100644 hermes/hermes/skills/.curator_backups/2026-10-09T10-26-38Z/manifest.json create mode 100644 hermes/hermes/skills/.curator_state create mode 100644 hermes/hermes/skills/.hub/lock.json create mode 100644 hermes/hermes/skills/.hub/taps.json create mode 100644 hermes/hermes/skills/.usage.json create mode 100644 hermes/hermes/skills/.usage.json.lock create mode 100644 hermes/hermes/skills/skeen-agendar/references/disponibilidad-endpoints.md diff --git a/hermes/hermes/plugins/odoo_api_call.py b/hermes/hermes/plugins/odoo_api_call.py index 43c86f0..fbb2d41 100644 --- a/hermes/hermes/plugins/odoo_api_call.py +++ b/hermes/hermes/plugins/odoo_api_call.py @@ -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 = {} - - 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 + 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.") + + 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)}) diff --git a/hermes/hermes/plugins/skeen_odoo/__init__.py b/hermes/hermes/plugins/skeen_odoo/__init__.py new file mode 100644 index 0000000..3c6119d --- /dev/null +++ b/hermes/hermes/plugins/skeen_odoo/__init__.py @@ -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, + ) diff --git a/hermes/hermes/plugins/skeen_odoo/client.py b/hermes/hermes/plugins/skeen_odoo/client.py new file mode 100644 index 0000000..9eb2a44 --- /dev/null +++ b/hermes/hermes/plugins/skeen_odoo/client.py @@ -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" diff --git a/hermes/hermes/plugins/skeen_odoo/plugin.yaml b/hermes/hermes/plugins/skeen_odoo/plugin.yaml new file mode 100644 index 0000000..191cf92 --- /dev/null +++ b/hermes/hermes/plugins/skeen_odoo/plugin.yaml @@ -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 diff --git a/hermes/hermes/plugins/skeen_wacrm/__init__.py b/hermes/hermes/plugins/skeen_wacrm/__init__.py new file mode 100644 index 0000000..9c98d6e --- /dev/null +++ b/hermes/hermes/plugins/skeen_wacrm/__init__.py @@ -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="📤", + ) diff --git a/hermes/hermes/plugins/skeen_wacrm/plugin.yaml b/hermes/hermes/plugins/skeen_wacrm/plugin.yaml new file mode 100644 index 0000000..e806f3b --- /dev/null +++ b/hermes/hermes/plugins/skeen_wacrm/plugin.yaml @@ -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 diff --git a/hermes/hermes/skills/.curator_backups/2026-09-11T09-33-23Z/manifest.json b/hermes/hermes/skills/.curator_backups/2026-09-11T09-33-23Z/manifest.json new file mode 100644 index 0000000..4f241da --- /dev/null +++ b/hermes/hermes/skills/.curator_backups/2026-09-11T09-33-23Z/manifest.json @@ -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 +} \ No newline at end of file diff --git a/hermes/hermes/skills/.curator_backups/2026-09-18T09-52-18Z/manifest.json b/hermes/hermes/skills/.curator_backups/2026-09-18T09-52-18Z/manifest.json new file mode 100644 index 0000000..06a12a4 --- /dev/null +++ b/hermes/hermes/skills/.curator_backups/2026-09-18T09-52-18Z/manifest.json @@ -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 +} \ No newline at end of file diff --git a/hermes/hermes/skills/.curator_backups/2026-09-25T09-57-24Z/manifest.json b/hermes/hermes/skills/.curator_backups/2026-09-25T09-57-24Z/manifest.json new file mode 100644 index 0000000..68eb0a4 --- /dev/null +++ b/hermes/hermes/skills/.curator_backups/2026-09-25T09-57-24Z/manifest.json @@ -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 +} \ No newline at end of file diff --git a/hermes/hermes/skills/.curator_backups/2026-10-02T10-03-51Z/manifest.json b/hermes/hermes/skills/.curator_backups/2026-10-02T10-03-51Z/manifest.json new file mode 100644 index 0000000..d2ee363 --- /dev/null +++ b/hermes/hermes/skills/.curator_backups/2026-10-02T10-03-51Z/manifest.json @@ -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 +} \ No newline at end of file diff --git a/hermes/hermes/skills/.curator_backups/2026-10-09T10-26-38Z/manifest.json b/hermes/hermes/skills/.curator_backups/2026-10-09T10-26-38Z/manifest.json new file mode 100644 index 0000000..bc57834 --- /dev/null +++ b/hermes/hermes/skills/.curator_backups/2026-10-09T10-26-38Z/manifest.json @@ -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 +} \ No newline at end of file diff --git a/hermes/hermes/skills/.curator_state b/hermes/hermes/skills/.curator_state new file mode 100644 index 0000000..c8d9158 --- /dev/null +++ b/hermes/hermes/skills/.curator_state @@ -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 +} \ No newline at end of file diff --git a/hermes/hermes/skills/.hub/lock.json b/hermes/hermes/skills/.hub/lock.json new file mode 100644 index 0000000..23486a6 --- /dev/null +++ b/hermes/hermes/skills/.hub/lock.json @@ -0,0 +1 @@ +{"version": 1, "installed": {}} diff --git a/hermes/hermes/skills/.hub/taps.json b/hermes/hermes/skills/.hub/taps.json new file mode 100644 index 0000000..4743145 --- /dev/null +++ b/hermes/hermes/skills/.hub/taps.json @@ -0,0 +1 @@ +{"taps": []} diff --git a/hermes/hermes/skills/.usage.json b/hermes/hermes/skills/.usage.json new file mode 100644 index 0000000..58b88d8 --- /dev/null +++ b/hermes/hermes/skills/.usage.json @@ -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 + } +} \ No newline at end of file diff --git a/hermes/hermes/skills/.usage.json.lock b/hermes/hermes/skills/.usage.json.lock new file mode 100644 index 0000000..e69de29 diff --git a/hermes/hermes/skills/skeen-agendar/SKILL.md b/hermes/hermes/skills/skeen-agendar/SKILL.md index 32425a6..f44e661 100644 --- a/hermes/hermes/skills/skeen-agendar/SKILL.md +++ b/hermes/hermes/skills/skeen-agendar/SKILL.md @@ -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="", 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=, 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=)` para + ver citas pendientes del paciente. +2. Cancela la cita actual: `skeen_odoo_cancel_appointment(phone, booking_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=)` (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. diff --git a/hermes/hermes/skills/skeen-agendar/references/disponibilidad-endpoints.md b/hermes/hermes/skills/skeen-agendar/references/disponibilidad-endpoints.md new file mode 100644 index 0000000..dfb8360 --- /dev/null +++ b/hermes/hermes/skills/skeen-agendar/references/disponibilidad-endpoints.md @@ -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. diff --git a/hermes/hermes/skills/skeen-monedero/SKILL.md b/hermes/hermes/skills/skeen-monedero/SKILL.md index 9166a8b..5b40541 100644 --- a/hermes/hermes/skills/skeen-monedero/SKILL.md +++ b/hermes/hermes/skills/skeen-monedero/SKILL.md @@ -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="")`. 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="", points=)`. + 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. diff --git a/hermes/hermes/skills/skeen-pagos/SKILL.md b/hermes/hermes/skills/skeen-pagos/SKILL.md index f65f2cc..9cb60b3 100644 --- a/hermes/hermes/skills/skeen-pagos/SKILL.md +++ b/hermes/hermes/skills/skeen-pagos/SKILL.md @@ -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="")`. 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. diff --git a/hermes/hermes/skills/skeen-rag/SKILL.md b/hermes/hermes/skills/skeen-rag/SKILL.md index eb730ec..e76c1a9 100644 --- a/hermes/hermes/skills/skeen-rag/SKILL.md +++ b/hermes/hermes/skills/skeen-rag/SKILL.md @@ -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="", 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 diff --git a/hermes/hermes/skills/skeen-recordatorios/SKILL.md b/hermes/hermes/skills/skeen-recordatorios/SKILL.md index 6e6e822..977e702 100644 --- a/hermes/hermes/skills/skeen-recordatorios/SKILL.md +++ b/hermes/hermes/skills/skeen-recordatorios/SKILL.md @@ -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`.