openapi: 3.1.0

info:
  title: API de complementos de agentia
  version: "1.0.0"
  summary: El contrato entre agentia y los complementos que corren por fuera.
  description: |
    Todo lo que se hablan agentia y un complemento pasa por aca. No hay otro
    camino, y ningun complemento tiene un trato distinto: el mismo contrato
    para todos.

    Quien hace que:

    - agentia es duena del canal del cliente (su WhatsApp y los demas), decide
      a quien le toca cada conversacion (por anuncio, por derivacion del
      agente o porque el complemento la devuelve), cobra y dice si el
      complemento esta habilitado, guarda la conversacion entera y pone topes.
    - el complemento es dueno de su logica y de su estado: decide que contestar
      y cuando soltar la conversacion. Verifica la firma de lo que le llega,
      respeta `puede_atender` y reporta lo que hizo.
    - agentia no ejecuta al complemento ni conoce su logica. El complemento no
      toca Meta ni guarda credenciales del cliente.

    Seis llamadas del complemento a agentia (`paths`) y dos avisos de agentia
    al complemento (`webhooks`). Los avisos son opcionales: llegan solo si el
    complemento declaro una direccion donde recibirlos.

    Este esquema describe las formas de los mensajes. No puede describir los
    comportamientos, que son la parte que importa:

    1. Guarda la licencia hasta `vence` y no vuelvas a preguntar. Si consultas
       en cada mensaje, una caida de agentia se convierte en una caida tuya.
    2. Ramifica sobre `puede_atender`, nunca sobre `codigo`. Un codigo nuevo no
       tiene que obligarte a redesplegar lo que ya esta afuera.
    3. Comproba la firma, la de la licencia y la de cada aviso. Sin eso,
       cualquiera en el medio le habla a tu artefacto en nombre de agentia.
    4. Usa ids de sesion estables. Es lo unico que impide que un reporte
       reenviado duplique la actividad.
    5. Un aviso se contesta en menos de 8 segundos y no se reintenta. Lo que
       tarde va despues por `/enviar`.

    La documentacion completa esta en https://www.chayana.co/desarrolladores
  contact:
    name: agentia
    url: https://www.chayana.co/desarrolladores
  license:
    name: Uso reservado a complementos dados de alta en agentia.
    url: https://www.chayana.co/desarrolladores

servers:
  - url: https://www.chayana.co

security:
  - tokenDeInstalacion: []

tags:
  - name: Conversacion
    description: Pasarle una consulta al agente del cliente cuando el artefacto se traba.
  - name: Habilitacion
    description: Si el cliente esta al dia y el artefacto puede trabajar.
  - name: Reporte
    description: Que hizo el artefacto, para que el cliente vea para que paga.
  - name: Canal
    description: Atender por el canal del cliente, sin conectar nada con Meta.
  - name: Avisos
    description: Lo que agentia le manda al complemento, si declaro una direccion.

paths:
  /api/complementos/verificar:
    post:
      tags: [Habilitacion]
      operationId: verificar
      summary: Pedir la licencia
      description: |
        Devuelve 200 aunque el cliente no pueda atender: el resultado esta en el
        cuerpo, no en el estado HTTP. Muchos clientes de HTTP tiran excepcion
        ante un 402 o un 403 antes de que llegues a leer el motivo y el mensaje
        para la persona. El 401 si es un 401: no reconocemos la credencial.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                version:
                  type: string
                  maxLength: 20
                  description: |
                    La version del artefacto, tipo "1.4.2". Si el cliente tiene
                    una version minima configurada y no la mandas, no se
                    habilita.
                  examples: ["1.0.0"]
      responses:
        "200":
          description: La licencia.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Licencia" }
        "401":
          description: Falta la credencial o no corresponde a ninguna instalacion.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Rechazo" }

  /api/complementos/reportar:
    post:
      tags: [Reporte]
      operationId: reportar
      summary: Informar la actividad
      description: |
        Idempotente por `sesiones[].id`. Reenviar el mismo lote no duplica nada
        y actualiza lo que haya cambiado, asi que se puede reportar una sesion
        al empezar y volver a mandarla completa al terminar.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/Reporte" }
      responses:
        "200":
          description: Recibido.
          content:
            application/json:
              schema:
                type: object
                required: [recibidas, guardadas, descartadas]
                properties:
                  recibidas: { type: integer }
                  guardadas: { type: integer }
                  descartadas:
                    type: integer
                    description: Sesiones sin fecha de inicio legible.
        "400":
          description: |
            El cuerpo no tiene la forma esperada. `detalle` dice que campo falla.
            Es un error tuyo: reintentar igual no lo arregla.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }
                  detalle:
                    type: array
                    items:
                      type: object
                      properties:
                        campo: { type: string }
                        problema: { type: string }
        "401":
          description: Falta la credencial o no corresponde a ninguna instalacion.
        "500":
          description: |
            Fue nuestro. Reintenta el mismo lote mas tarde: con los mismos ids
            no duplica.

  /api/complementos/conversar:
    post:
      tags: [Conversacion]
      operationId: conversar
      summary: Pasarle una consulta al agente
      description: |
        El artefacto sigue siendo el dueño del canal: devolvemos el texto y lo
        manda el. Con el mismo `sesion_id` la charla continua donde iba, asi que
        se puede delegar un turno, tres o el resto de la conversacion.

        Tarda lo que tarda un modelo en contestar. No la llames con un timeout
        de dos segundos.

        Siempre 200 cuando la credencial vale, aunque no se haya podido
        atender: el motivo lo tenes que poder leer.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/Traspaso" }
      responses:
        "200":
          description: |
            La respuesta del agente, o el motivo por el que no la hubo.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RespuestaDelAgente" }
        "400":
          description: El cuerpo no tiene la forma esperada.
        "401":
          description: Falta la credencial o no corresponde a ninguna instalacion.

  /api/complementos/enviar:
    post:
      tags: [Canal]
      operationId: enviar
      summary: Mandar un mensaje por el canal del cliente
      description: |
        Manda un texto por el WhatsApp del cliente, sin que el complemento
        tenga que conectar nada con Meta.

        Es para lo que no entra en la respuesta del webhook: cotizar, consultar
        un sistema, cualquier cosa que tarde mas de unos segundos. Se contesta
        el webhook sin texto y despues se llama por aca.

        Solo dentro de una conversacion que el complemento tenga tomada. El
        mensaje sale a nombre del negocio del cliente, asi que sin esa
        comprobacion un token serviria para escribirle a cualquier contacto.

        Hay tope por hora y por instalacion. Un bucle mal escrito son miles de
        mensajes desde un numero que no es tuyo, y al que le bajan la
        calificacion es al cliente.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [conversacion_id, mensaje]
              properties:
                conversacion_id:
                  type: string
                  format: uuid
                  description: El que viene en el webhook.
                mensaje:
                  type: string
                  maxLength: 4096
                  description: |
                    Hasta 4000 solo, 1024 con botones, 4096 con lista y 1024
                    con documento o con imagen, donde va como pie.
                opciones: { $ref: "#/components/schemas/Opciones" }
                documento: { $ref: "#/components/schemas/Documento" }
                imagen: { $ref: "#/components/schemas/Imagen" }
                version:
                  type: string
            examples:
              texto:
                value: { conversacion_id: "8f3c2b1a-1111-4111-8111-111111111111", mensaje: "Ya te preparo el presupuesto." }
              botones:
                value:
                  conversacion_id: "8f3c2b1a-1111-4111-8111-111111111111"
                  mensaje: "¿Cómo podemos ayudarte?"
                  opciones:
                    items:
                      - { id: pileta, titulo: "Quiero una pileta" }
                      - { id: ayuda, titulo: "Necesito ayuda" }
              lista:
                value:
                  conversacion_id: "8f3c2b1a-1111-4111-8111-111111111111"
                  mensaje: "Elegí el modelo"
                  opciones:
                    tipo: lista
                    boton: "Ver modelos"
                    items:
                      - { id: af01, titulo: "AF01", descripcion: "6 x 3 m" }
                      - { id: af02, titulo: "AF02", descripcion: "7 x 3,5 m" }
              documento:
                value:
                  conversacion_id: "8f3c2b1a-1111-4111-8111-111111111111"
                  mensaje: "Tu presupuesto AF-20260910-ABC123. Válido por 15 días."
                  documento:
                    url: "https://tu-servidor/presupuestos/AF-20260910-ABC123.pdf?firma=..."
                    nombre: "Presupuesto AF-20260910-ABC123.pdf"
              imagen:
                value:
                  conversacion_id: "8f3c2b1a-1111-4111-8111-111111111111"
                  mensaje: "Rectangular: la que mejor aprovecha patios angostos."
                  imagen:
                    url: "https://tu-servidor/modelos/rectangular.png"
              botones_con_imagen:
                value:
                  conversacion_id: "8f3c2b1a-1111-4111-8111-111111111111"
                  mensaje: "¿Te gusta este modelo?"
                  opciones:
                    tipo: botones
                    encabezado: { imagen: "https://tu-servidor/modelos/af01.png" }
                    items:
                      - { id: si, titulo: "Sí, cotizalo" }
                      - { id: otro, titulo: "Ver otro" }
      responses:
        "200":
          description: Salio.
          content:
            application/json:
              schema:
                type: object
                properties:
                  enviado: { type: boolean, enum: [true] }
                  encabezado:
                    type: boolean
                    description: |
                      Solo si pediste imagen arriba de los botones: true si
                      salio con ella, false si salieron los botones sin ella.
        "400":
          description: El cuerpo no tiene la forma esperada.
        "401":
          description: Falta la credencial o no corresponde a ninguna instalacion.
        "404":
          description: Esa conversacion no existe.
        "409":
          description: |
            No la estas atendiendo vos, o el canal del cliente no esta
            atendiendo.
        "422":
          description: |
            El documento o la imagen no salio y no se mando nada. `motivo`
            dice por que: `direccion_no_permitida` (no es https o apunta a una
            red privada), `no_se_pudo_bajar` (no respondio 200 en diez
            segundos, o redirigio), `no_es_pdf`, `no_es_imagen` (no es PNG ni
            JPEG), `muy_grande` (mas de 5 MB), `el_canal_no_admite_documentos`
            o `el_canal_no_admite_imagenes`. Manda el texto solo.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }
                  motivo: { type: string }
        "429":
          description: |
            Llegaste al tope de mensajes por hora. Un mensaje con opciones, con
            documento o con imagen cuenta como uno.
        "502":
          description: El canal no acepto el mensaje.

  /api/complementos/asignar:
    post:
      tags: [Canal]
      operationId: asignar
      summary: Pasarle la conversacion al agente
      description: |
        El complemento suelta la conversacion y desde ahi contesta el agente,
        con toda la charla adelante.

        Lo que contesto el complemento ya quedo guardado en la conversacion, asi
        que no hace falta mandar el historial. En `contexto` va lo que sabe el
        complemento y no esta dicho en la charla: por que la suelta y en que
        quedo.

        Es una sola direccion. Despues de esto, `enviar` sobre esa conversacion
        devuelve 409. Si el agente necesita volver a pasarsela, tiene su propia
        herramienta para derivar.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [conversacion_id]
              properties:
                conversacion_id:
                  type: string
                  format: uuid
                contexto:
                  type: string
                  maxLength: 2000
                  example: Cotizo 8x4, quiere hablar de financiacion
                version:
                  type: string
      responses:
        "200":
          description: Quedo en manos del agente.
          content:
            application/json:
              schema:
                type: object
                properties:
                  asignado: { type: boolean, enum: [true] }
                  atiende: { type: string, enum: [agente] }
        "400":
          description: El cuerpo no tiene la forma esperada.
        "401":
          description: Falta la credencial o no corresponde a ninguna instalacion.
        "404":
          description: Esa conversacion no existe.
        "409":
          description: No la estas atendiendo vos.

  /api/complementos/escribiendo:
    post:
      tags: [Canal]
      operationId: escribiendo
      summary: Renovar el "escribiendo..." de una conversacion
      description: |
        Chayana ya lo muestra al entregarte cada mensaje; dura hasta que sale la
        respuesta o 25 segundos. Esto es para lo que tarda mas: se llama cada
        tanto mientras se trabaja. En WhatsApp tambien marca como leido el
        ultimo mensaje de la persona. No lo llames si no vas a contestar.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [conversacion_id]
              properties:
                conversacion_id: { type: string, format: uuid }
                version: { type: string }
      responses:
        "200":
          description: |
            Mostrado. `escribiendo` es false si el canal no tiene indicador, o
            si la persona todavia no escribio nada: no es un error.
          content:
            application/json:
              schema:
                type: object
                properties:
                  escribiendo: { type: boolean }
        "400":
          description: El cuerpo no tiene la forma esperada.
        "401":
          description: Falta la credencial o no corresponde a ninguna instalacion.
        "403":
          description: La licencia no te habilita.
        "404":
          description: Esa conversacion no existe.
        "409":
          description: No la estas atendiendo vos, o el canal no esta atendiendo.
        "429":
          description: Llegaste al tope de 600 avisos por hora.
        "502":
          description: El canal no acepto el aviso.

webhooks:
  mensaje:
    post:
      tags: [Avisos]
      operationId: avisoDeMensaje
      summary: Alguien escribio por el canal del cliente
      description: |
        Llega cuando una persona escribe en una conversacion que atiende el
        complemento: porque entro por un anuncio que el complemento toma,
        porque el agente se la derivo, o porque la instalacion toma todas las
        conversaciones nuevas.

        Chayana ya descarto los duplicados del canal antes de entregarlo.
        Cada mensaje se entrega una sola vez.
      security: []
      parameters:
        - $ref: "#/components/parameters/FirmaDelAviso"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/AvisoDeMensaje" }
      responses:
        "200":
          description: |
            Recibido. Con `responder`, agentia manda ese texto por el canal en el
            momento. Sin texto, el complemento contesta despues por `/enviar`.
            Cualquier otro status, o pasados 8 segundos, la entrega se da por
            fallada: la persona no recibe nada y no hay segundo intento.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RespuestaAlAviso" }

  derivacion:
    post:
      tags: [Avisos]
      operationId: avisoDeDerivacion
      summary: El agente del cliente le pasa una conversacion
      description: |
        Llega cuando el agente decide que lo que pide la persona es lo que sabe
        hacer el complemento.

        Si trae `canal`, el hilo pasa a ser del complemento en ese momento: lo
        que la persona escriba despues llega como `mensaje`, igual que si
        hubiera entrado por un anuncio. Si `canal` es nulo, no hay por donde
        contestar y se sigue por `/conversar`.
      security: []
      parameters:
        - $ref: "#/components/parameters/FirmaDelAviso"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/AvisoDeDerivacion" }
      responses:
        "200":
          description: |
            La toma. Con canal, `responder` sale por el canal justo despues del
            mensaje del agente que le avisa a la persona que la pasa. Cualquier
            otro status, o pasados 8 segundos, la conversacion sigue con el
            agente.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RespuestaAlAviso" }

components:
  securitySchemes:
    tokenDeInstalacion:
      type: http
      scheme: bearer
      description: |
        El token que entregamos al instalar el complemento para un cliente.
        Empieza con `agc_` y es por instalacion: el mismo complemento vendido a
        diez clientes son diez tokens distintos. Es ademas la clave con la que
        se firma la licencia.

  parameters:
    FirmaDelAviso:
      name: x-chayana-firma
      in: header
      required: true
      description: |
        HMAC-SHA256 del cuerpo entero, tal cual llego, con el token de la
        instalacion como clave, en base64url sin relleno. Se comprueba antes de
        hacer nada con el cuerpo. Por un tiempo la misma firma viaja tambien en
        x-agentia-firma, el nombre anterior.
      schema: { type: string }

  schemas:
    Licencia:
      type: object
      required:
        - codigo
        - estado
        - descripcion
        - puede_atender
        - modo
        - vence
        - emitida
        - firma
      properties:
        codigo:
          type: integer
          enum: [1, 2, 3, 5]
          description: |
            1 habilitado, 2 falta de pago, 3 inactivo, 5 otro. El 4 es "no me
            pude conectar" y lo genera el artefacto: si estamos contestando, se
            conecto. Para el registro, no para ramificar.
        estado:
          type: string
          description: |
            Slug estable. Distingue dos situaciones con el mismo codigo, por
            ejemplo `en_gracia` y `gracia_terminada`, que son las dos 2.
          examples: ["habilitado", "en_gracia", "sin_suscripcion"]
        descripcion:
          type: string
          description: En castellano, para tus registros. No se la muestres a nadie.
        puede_atender:
          type: boolean
          description: Sobre esto ramificas. Es la unica pregunta que importa.
        modo:
          type: string
          enum: [completo, limitado]
          description: |
            Hoy siempre "completo". Existe para que un modo nuevo no obligue a
            redesplegar lo que ya esta afuera.
        mensaje_para_el_usuario:
          type: [string, "null"]
          description: |
            Que decirle a la persona del otro lado cuando no podes atender. Lo
            escribimos nosotros para que salga igual en todos los complementos.
        vence:
          type: string
          format: date-time
          description: |
            Hasta cuando vale. Un dia cuando esta habilitado; una hora cuando
            esta apagado, para que el cliente que paga no espere hasta mañana.
            Nunca mas alla del momento en que cambiaria la respuesta.
        emitida:
          type: string
          format: date-time
        complemento:
          type: object
          properties:
            clave: { type: string }
            nombre: { type: string }
        cliente:
          type: object
          properties:
            nombre: { type: string, description: El cliente en agentia. }
            agente: { type: string, description: Su agente en agentia. }
        uso:
          type: object
          properties:
            sesiones_del_mes: { type: integer }
            sesiones_incluidas:
              type: [integer, "null"]
              description: "Nulo es sin tope. Informativo: no cortamos por consumo."
        reportar_en:
          type: string
          format: uri
          description: A donde mandar el reporte. No la fijes en el codigo.
        firma:
          type: string
          description: |
            HMAC-SHA256 en base64url sin relleno, con el token como clave, sobre
            `codigo|estado|puede_atender|modo|vence|emitida`, donde
            `puede_atender` va como 1 o 0. Se firma una concatenacion y no el
            JSON entero para no depender de como serialice las claves cada lado.

    Rechazo:
      type: object
      properties:
        codigo: { type: integer, enum: [5] }
        estado:
          type: string
          enum: [sin_credencial, credencial_desconocida]
          description: |
            No distingue entre un token que nunca existio y uno que revocamos:
            probar tokens al azar no tiene que ser informativo.
        descripcion: { type: string }
        puede_atender: { type: boolean, enum: [false] }
        modo: { type: string }

    Reporte:
      type: object
      required: [sesiones]
      properties:
        version:
          type: string
          maxLength: 20
        sesiones:
          type: array
          minItems: 1
          maxItems: 500
          items: { $ref: "#/components/schemas/Sesion" }

    Traspaso:
      type: object
      required: [sesion_id, mensaje]
      properties:
        sesion_id:
          type: string
          minLength: 1
          maxLength: 200
          description: |
            El mismo que usas para reportar. Es lo que une las dos mitades: la
            charla que tuviste vos y la que siguio el agente. Repetirlo continua
            la conversacion.
        mensaje:
          type: string
          minLength: 1
          maxLength: 4000
          description: Lo que dijo la persona, tal cual.
        contacto:
          type: [string, "null"]
          maxLength: 120
        contexto:
          type: [string, "null"]
          maxLength: 4000
          description: |
            Lo que ya averiguaste. Va a las instrucciones del turno y no al
            historial: el agente no repregunta lo que la persona ya contesto, y
            la conversacion que despues lee el cliente queda limpia. Mandalo en
            cada llamada si cambia.
        version:
          type: string
          maxLength: 20

    RespuestaDelAgente:
      type: object
      required: [atendido, codigo, estado, respuesta]
      properties:
        atendido: { type: boolean }
        codigo: { type: integer }
        estado:
          type: string
          description: |
            `atendido` cuando salio bien. Si no: `agente_no_operativo` si el
            cliente todavia no puso su agente en produccion,
            `sin_comportamiento` si no tiene uno publicado,
            `el_motor_fallo` si fallo de nuestro lado, o el estado de la
            licencia si el complemento no esta habilitado.
        respuesta:
          type: [string, "null"]
          description: El texto para mandarle a la persona. Nulo si no se atendio.
        mensaje_para_el_usuario:
          type: [string, "null"]
          description: |
            Presente cuando no se atendio por licencia. Es lo que hay que
            relevar en ese caso.
        motivo:
          type: [string, "null"]
          description: Para tus registros. No se la muestres a nadie.
        conversacion_id:
          type: string
          description: La charla del lado de agentia, por si la queres registrar.
        costo_usd:
          type: number
          description: |
            Lo que costo el turno. Va a la cuenta del cliente, no a la tuya: es
            su agente el que trabaja.

    Sesion:
      type: object
      required: [id, inicio]
      description: |
        Una charla, una visita o una corrida, segun lo que seas.
      properties:
        id:
          type: string
          minLength: 1
          maxLength: 200
          description: |
            El id se lo pones vos y tiene que ser el mismo si reintentas. Es lo
            unico que impide que un lote reenviado duplique la actividad.
          examples: ["wa-5491122334455-1782"]
        inicio:
          type: string
          format: date-time
        fin:
          type: [string, "null"]
          format: date-time
          description: Sin esto la sesion se cuenta como abierta.
        paso:
          type: [string, "null"]
          maxLength: 40
          description: |
            A que paso llego, no por cuales paso: una sola variable que se pisa.
            Quien llego al cuarto paso paso por los tres anteriores, y el embudo
            lo armamos nosotros. Los nombres y su orden se acuerdan al dar de
            alta el complemento.
          examples: ["precio"]
        desenlace:
          type: [string, "null"]
          maxLength: 40
          examples: ["cotizado"]
        contacto:
          type: [string, "null"]
          maxLength: 120
        detalle:
          type: object
          description: Lo tuyo. Se guarda tal cual y no lo interpretamos.
          additionalProperties: true

    Origen:
      type: [object, "null"]
      description: |
        De que anuncio vino la persona. Llega solo en el primer mensaje de la
        conversacion y no vuelve: si hace falta, se guarda ahi.
      properties:
        anuncioId: { type: string, description: El id del anuncio o de la publicacion en Meta. }
        tipo: { type: string, description: '"ad" o "post".' }
        url: { type: string }
        titular: { type: string }
        texto: { type: string }
        clicId: { type: string, description: Para atribuir la conversion en Meta. }

    AvisoDeMensaje:
      type: object
      required: [evento, conversacion_id, canal, contacto, mensaje]
      properties:
        evento: { type: string, enum: [mensaje] }
        conversacion_id:
          type: string
          format: uuid
          description: La clave del hilo. Todo lo que siga va contra este id.
        canal: { type: string, examples: [whatsapp] }
        contacto:
          type: object
          required: [id]
          properties:
            id: { type: string, description: 'El id de la persona en el canal. En WhatsApp, su numero.' }
            nombre: { type: [string, "null"] }
        mensaje:
          type: string
          description: Lo que escribio, o el titulo de lo que toco.
        respuesta:
          type: [object, "null"]
          description: |
            Lo que toco, si toco un boton o una fila de una lista. Nulo si
            escribio. Si toca un boton de un mensaje viejo, llega igual con su id.
          properties:
            id: { type: string }
            titulo: { type: string }
        origen: { $ref: "#/components/schemas/Origen" }

    AvisoDeDerivacion:
      type: object
      required: [evento, sesion_id, conversacion_id, motivo, emitida]
      properties:
        evento: { type: string, enum: [derivacion] }
        sesion_id:
          type: string
          description: |
            Quien es la persona. Si la conversacion empezo del lado del
            complemento, su id de sesion; si empezo en agentia, el id de la
            persona en el canal. No es un id para reportar.
        conversacion_id: { type: string, format: uuid }
        contacto: { type: [string, "null"], description: El nombre, si se sabe. }
        canal:
          type: [string, "null"]
          description: Por donde sigue la charla. Nulo si no hay canal para contestar.
        motivo: { type: string }
        resumen: { type: string }
        emitida: { type: string, format: date-time }

    RespuestaAlAviso:
      type: object
      properties:
        responder:
          type: string
          maxLength: 4096
          description: Lo que agentia manda por el canal en el momento.
        opciones:
          $ref: "#/components/schemas/Opciones"
          description: |
            Opcional, junto con `responder`. Aca no hay como avisarte un error:
            si no cumplen los limites, si el canal no las muestra o si WhatsApp
            las rechaza, sale el texto con las opciones numeradas desde 1.
        recibido:
          type: boolean
          description: Recibido sin texto; el complemento contesta despues por /enviar.

    Opciones:
      type: object
      required: [items]
      description: |
        Opciones para tocar: botones o una lista de WhatsApp. Los limites son los
        de WhatsApp y no se recortan: en /enviar, lo que no cumple es un 400 que
        dice que campo. En la respuesta de un aviso, o por un canal que no las
        muestra, salen como texto numerado desde 1, en el orden de `items`.
      properties:
        tipo:
          type: string
          enum: [botones, lista]
          description: Si falta, hasta 3 van como botones y de 4 a 10 como lista.
        boton:
          type: string
          maxLength: 20
          description: El texto del boton que abre la lista. Solo en lista; por defecto "Ver opciones".
        encabezado:
          type: object
          required: [imagen]
          description: |
            Una imagen arriba de los botones. Solo con botones: con lista es un
            400. Se baja y se valida igual que `Imagen`; si no se puede —no
            baja, no es imagen, el canal no la muestra—, los botones salen sin
            ella y no es un error.
          properties:
            imagen: { type: string, format: uri }
        items:
          type: array
          minItems: 1
          maxItems: 10
          description: Botones de 1 a 3; lista de 1 a 10. Ni los ids ni los titulos se repiten.
          items:
            type: object
            required: [id, titulo]
            properties:
              id: { type: string, maxLength: 200, description: Vuelve en `respuesta.id` cuando la persona lo toca. }
              titulo: { type: string, maxLength: 24, description: Hasta 20 en botones y 24 en lista. }
              descripcion: { type: string, maxLength: 72, description: Solo en lista. }

    Documento:
      type: object
      required: [url, nombre]
      description: |
        Un PDF. Solo en /enviar: bajarlo puede pasar de los 8 segundos de un
        aviso. Chayana lo baja al recibir el pedido, lo valida y lo guarda; a
        WhatsApp le pasa un link propio de corta vida, nunca el tuyo. La
        direccion tiene que ser https, no puede apuntar a una red privada, no
        se siguen redirecciones, y el archivo tiene que responder en diez
        segundos, pesar hasta 5 MB, ser application/pdf y empezar con %PDF.
        No va junto con `opciones` en el mismo mensaje.
      properties:
        url: { type: string, format: uri }
        nombre: { type: string, maxLength: 240, description: Con el que lo ve la persona. }

    Imagen:
      type: object
      required: [url]
      description: |
        Una imagen PNG o JPEG, con `mensaje` como pie (hasta 1024). Solo en
        /enviar. Las mismas reglas que `Documento`: https, sin redes privadas,
        sin redirecciones, diez segundos, hasta 5 MB; tiene que ser image/png o
        image/jpeg y empezar como tal. A WhatsApp le va un link nuestro. No va
        junto con `opciones` ni con `documento` en el mismo mensaje.
      properties:
        url: { type: string, format: uri }

