Entendiendo FreePBX e integrándolo con Bitrix24 y más

Bitrix24 es una gran combinación que combina CRM, flujo de trabajo, contabilidad y muchas otras cosas que a los gerentes realmente les gustan y al personal de TI realmente no les gusta. El portal es utilizado por muchas pequeñas y medianas empresas, incluidas pequeñas clínicas, fabricantes e incluso salones de belleza. La función principal que "aman" los gerentes es la integración de telefonía y CRM, cuando cualquier llamada se registra inmediatamente en CRM, se crean tarjetas de clientes, cuando ingresa, se muestra información sobre el cliente y puede ver de inmediato quién es, qué él puede vender y cuánto debe. Pero la telefonía de Bitrix24 y su integración con CRM cuesta dinero, a veces mucho. En el artículo les contaré la experiencia de integración con herramientas abiertas y la popular IP PBX FreePBX, y también considerar la lógica del trabajo de varias partes

Trabajo como outsourcer en una empresa que vende y configura, integra telefonía IP. Cuando me preguntaron si podíamos ofrecerle algo a esta y esta empresa para integrar Bitrix24 con los PBX que tienen los clientes, así como con los PBX virtuales en varias compañías de VDS, fui a Google. Y, por supuesto, me dio un enlace a articulo en habr, donde hay una descripción y github, y todo parece funcionar. Pero al intentar usar esta solución, resultó que Bitrix24 ya no es el mismo que antes, y se necesita rehacer mucho. Además, FreePBX no es un simple asterisco para usted, aquí debe pensar en cómo combinar la facilidad de uso y un plan de marcación incondicional en los archivos de configuración.

Estudiamos la lógica del trabajo.

Entonces, para empezar, cómo debería funcionar todo. Cuando se recibe una llamada desde el exterior en el PBX (evento SIP INVITE del proveedor), comienza el procesamiento del plan de marcación (plan de marcación, plan de marcación): las reglas de qué y en qué orden hacer con la llamada. Desde el primer paquete, puede obtener mucha información, que luego puede usarse en las reglas. Una excelente herramienta para estudiar los aspectos internos de SIP es el analizador. sngrep (enlace) que simplemente se instala en distribuciones populares a través de apt install/yum install y similares, pero también se puede compilar desde la fuente. Veamos el registro de llamadas en sngrep

Entendiendo FreePBX e integrándolo con Bitrix24 y más

De forma simplificada, el dialplan trata solo con el primer paquete, a veces también durante la conversación, se transfieren llamadas, pulsaciones de botones (DTMF), varias cosas interesantes como FollowMe, RingGroup, IVR y otras.

Qué hay dentro del paquete de invitación

Entendiendo FreePBX e integrándolo con Bitrix24 y más

En realidad, la mayoría de los planes de marcación simples funcionan con los dos primeros campos, y toda la lógica gira en torno a DID y CallerID. DID - a dónde llamamos, CallerID - quién llama.

Pero después de todo, tenemos una empresa y no un teléfono, lo que significa que lo más probable es que el PBX tenga grupos de llamadas (timbre simultáneo / consecutivo de varios dispositivos) en números de ciudad (Grupo de timbre), IVR (Hola, llamaste ... Presione uno para...), Contestadores automáticos ( Frases), Condiciones de Tiempo, Reenvío a otros números oa un celular (Sígueme, Reenviar). Esto significa que es muy difícil determinar sin ambigüedades quién recibirá realmente una llamada y con quién tendrá una conversación cuando llegue una llamada. Aquí tienes un ejemplo del inicio de una llamada típica en la centralita de nuestros clientes

Entendiendo FreePBX e integrándolo con Bitrix24 y más

Después de que la llamada ingresa con éxito al PBX, viaja a través del plan de marcación en diferentes "contextos". El contexto desde el punto de vista de Asterisk es un conjunto numerado de comandos, cada uno de los cuales contiene un filtro por el número marcado (se llama exten, para una llamada externa en la etapa inicial exten=DID). Los comandos en la línea del plan de marcación pueden ser cualquier cosa: funciones internas (por ejemplo, llamar a un suscriptor interno, Dial(), guarda el teléfono - Hangup()), operadores condicionales (IF, ELSE, ExecIF y similares), transiciones a otras reglas de este contexto (Goto, GotoIF), transición a otros contextos en forma de llamada de función (Gosub, Macro). Una directiva separada include имя_контекста, que agrega comandos de otro contexto al final del contexto actual. Los comandos incluidos a través de include siempre se ejecutan después comandos del contexto actual.

Toda la lógica de FreePBX se basa en la inclusión de diferentes contextos entre sí mediante la inclusión y la llamada a través de los controladores Gosub, Macro y Handler. Considere el contexto de las llamadas FreePBX entrantes

Entendiendo FreePBX e integrándolo con Bitrix24 y más

La llamada pasa por todos los contextos de arriba a abajo, en cada contexto puede haber llamadas a otros contextos como macros (Macro), funciones (Gosub) o solo transiciones (Goto), por lo que el árbol real de lo que se llama solo puede ser rastreado en los registros.

A continuación se muestra un diagrama de configuración típico para un PBX típico. Al llamar, se busca DID en las rutas entrantes, se verifican las condiciones temporales, si todo está en orden, se inicia el menú de voz. Desde ella, pulsando el botón 1 o timeout, se sale al grupo de operadores de marcación. Una vez que finaliza la llamada, se llama a la macro hangupcall, después de lo cual no se puede hacer nada en el plan de marcación, excepto los controladores especiales (handup handler).

Entendiendo FreePBX e integrándolo con Bitrix24 y más

¿En qué parte de este algoritmo de llamadas debemos proporcionar información sobre el comienzo de la llamada a CRM, dónde comenzar a grabar, dónde finalizar la grabación y enviarla junto con información sobre la llamada a CRM?

Integración con sistemas externos

¿Qué es la integración de PBX y CRM? Estas son configuraciones y programas que convierten datos y eventos entre estas dos plataformas y los envían entre sí. La forma más común para que los sistemas independientes se comuniquen es a través de las API, y la forma más popular de acceder a las API es HTTP REST. Pero no por asterisco.

Dentro de Asterisk está:

  • AGI - llamada síncrona de programas/componentes externos, se utiliza principalmente en el dialplan, hay bibliotecas como PHPAGI, PAGÍ

  • AMI: un socket TCP de texto que funciona según el principio de suscribirse a eventos e ingresar comandos de texto, se parece a SMTP desde adentro, puede rastrear eventos y administrar llamadas, hay una biblioteca PAMI - el más popular para crear una conexión con Asterisk

Ejemplo de salida de AMI

Evento: Nuevo canal
Privilegio: llamar, todos
Canal: PJSIP/VMS_pjsip-0000078b
Estado del canal: 4
Descripción del estado del canal: Anillo
Número de identificación de la persona que llama: 111222
Identificador de llamadas: 111222
Número de línea conectada:
nombre de línea conectado:
Idioma: es
código de cuenta:
Contexto: desde-pstn
Extensión: s
Prioridad: 1
Identificación única: 1599589046.5244
Linkedid: 1599589046.5244

  • ARI es una mezcla de ambos, todo a través de REST, WebSocket, en formato JSON, pero con bibliotecas y contenedores nuevos, no muy buenos, encontrados de forma improvisada (phparia, phpari) que se convirtió en su desarrollo hace unos 3 años.

Ejemplo de salida ARI cuando se inicia una llamada

{ "variable":"CallMeCallerIDName", "valor":"111222", "tipo":"ChannelVarset", "marca de tiempo":"2020-09-09T09:38:36.269+0000", "canal":{ "id »:»1599644315.5334″, «nombre»:»PJSIP/VMSpjsip-000007b6″, "estado":"Timbre", "persona que llama":{ "nombre":"111222″, "número":"111222″ }, "conectado":{ "nombre":"", "número" :"" }, "código de cuenta":"", "plan de marcación":{ "contexto":"desde-pstn", "extensión":"s", "prioridad":2, "aplicaciónnombre":"Estasis", "aplicacióndata":"hello-world" }, "creationtime":"2020-09-09T09:38:35.926+0000", "language":"es" }, "asteriscoid":"48:5b:aa:aa:aa:aa", "aplicación":"hola-mundo" }

La conveniencia o inconveniencia, la posibilidad o imposibilidad de trabajar con una determinada API están determinadas por las tareas a resolver. Las tareas para la integración con CRM son las siguientes:

  • Rastree el comienzo de la llamada, dónde se transfirió, extraiga CallerID, DID, horas de inicio y finalización, tal vez datos del directorio (para buscar una conexión entre el teléfono y el usuario de CRM)

  • Iniciar y finalizar la grabación de la llamada, guardarla en el formato deseado, informar al final de la grabación donde se encuentra el archivo

  • Iniciar una llamada en un evento externo (desde el programa), llamar a un número interno, un número externo y conectarlos

  • Opcional: integración con CRM, grupos de marcador y FollowME para transferencia automática de llamadas en ausencia de un lugar (según CRM)

Todas estas tareas se pueden resolver a través de AMI o ARI, pero ARI proporciona mucha menos información, no hay muchos eventos, muchas variables que todavía tiene AMI (por ejemplo, macrollamadas, configuración de variables dentro de macros, incluida la grabación de llamadas) no se rastrean. Por lo tanto, para un seguimiento correcto y preciso, elijamos AMI por ahora (pero no completamente). Además (bueno, ¿dónde estaría sin esto, somos gente perezosa), en el trabajo original (articulo en habr) Usa PAMI. *Luego, debe intentar reescribir en ARI, pero no el hecho de que funcionará.

Reinventar la integración

Para que nuestro FreePBX pueda informar a AMI de manera simple sobre el comienzo de la llamada, la hora de finalización, los números, los nombres de los archivos grabados, es más fácil calcular la duración de la llamada usando el mismo truco que los autores originales. - ingrese sus variables y analice la salida para su presencia. PAMI sugiere hacer esto simplemente a través de una función de filtro.

Aquí hay un ejemplo de configuración de su propia variable para la hora de inicio de la llamada (s es un número especial en el plan de marcación que se realiza ANTES de comenzar la búsqueda DID)

[ext-did-custom]

exten => s,1,Set(CallStart=${STRFTIME(epoch,,%s)})

Un evento de AMI de ejemplo para esta línea

Evento: Nuevo canal

Privilegio: llamar, todos

Canal: PJSIP/VMS_pjsip-0000078b

Estado del canal: 4

Descripción del estado del canal: Anillo

Número de identificación de la persona que llama: 111222

Identificador de llamadas: 111222

Número de línea conectada:

nombre de línea conectado:

Idioma: es

código de cuenta:

Contexto: desde-pstn

Extensión: s

Prioridad: 1

Identificación única: 1599589046.5244

Linkedid: 1599589046.5244

Aplicación: Establecer AppData:

Inicio de llamada = 1599571046

Porque FreePBX sobrescribe los archivos extension.conf y extension_adicional.conf, usaremos el archivo extensión_personalizado.conf

Código completo de extension_custom.conf

[globals]	
;; Проверьте пути и права на папки - юзер asterisk должен иметь права на запись
;; Сюда будет писаться разговоры
WAV=/var/www/html/callme/records/wav 
MP3=/var/www/html/callme/records/mp3

;; По этим путям будет воспроизводится и скачиваться запись
URLRECORDS=https://www.host.ru/callmeplus/records/mp3

;; Адрес для калбека при исходящем вызове
URLPHP=https://www.host.ru/callmeplus

;; Да пишем разговоры
RECORDING=1

;; Это макрос для записи разговоров в нашу папку. 
;; Можно использовать и системную запись, но пока пусть будет эта - 
;; она работает
[recording]
exten => ~~s~~,1,Set(LOCAL(calling)=${ARG1})
exten => ~~s~~,2,Set(LOCAL(called)=${ARG2})
exten => ~~s~~,3,GotoIf($["${RECORDING}" = "1"]?4:14)
exten => ~~s~~,4,Set(fname=${UNIQUEID}-${STRFTIME(${EPOCH},,%Y-%m-%d-%H_%M)}-${calling}-${called})
exten => ~~s~~,5,Set(datedir=${STRFTIME(${EPOCH},,%Y/%m/%d)})
exten => ~~s~~,6,System(mkdir -p ${MP3}/${datedir})
exten => ~~s~~,7,System(mkdir -p ${WAV}/${datedir})
exten => ~~s~~,8,Set(monopt=nice -n 19 /usr/bin/lame -b 32  --silent "${WAV}/${datedir}/${fname}.wav"  "${MP3}/${datedir}/${fname}.mp3" && rm -f "${WAV}/${fname}.wav" && chmod o+r "${MP3}/${datedir}/${fname}.mp3")
exten => ~~s~~,9,Set(FullFname=${URLRECORDS}/${datedir}/${fname}.mp3)
exten => ~~s~~,10,Set(CDR(filename)=${fname}.mp3)
exten => ~~s~~,11,Set(CDR(recordingfile)=${fname}.wav)
exten => ~~s~~,12,Set(CDR(realdst)=${called})
exten => ~~s~~,13,MixMonitor(${WAV}/${datedir}/${fname}.wav,b,${monopt})
exten => ~~s~~,14,NoOp(Finish if_recording_1)
exten => ~~s~~,15,Return()


;; Это основной контекст для начала разговора
[ext-did-custom]

;; Это хулиганство, делать это так и здесь, но работает - добавляем к номеру '8'
exten =>  s,1,Set(CALLERID(num)=8${CALLERID(num)})

;; Тут всякие переменные для скрипта
exten =>  s,n,Gosub(recording,~~s~~,1(${CALLERID(number)},${EXTEN}))
exten =>  s,n,ExecIF(${CallMeCallerIDName}?Set(CALLERID(name)=${CallMeCallerIDName}):NoOp())
exten =>  s,n,Set(CallStart=${STRFTIME(epoch,,%s)})
exten =>  s,n,Set(CallMeDISPOSITION=${CDR(disposition)})

;; Самое главное! Обработчик окончания разговора. 
;; Обычные пути обработки конца через (exten=>h,1,чтототут) в FreePBX не работают - Macro(hangupcall,) все портит. 
;; Поэтому вешаем Hangup_Handler на окончание звонка
exten => s,n,Set(CHANNEL(hangup_handler_push)=sub-call-from-cid-ended,s,1(${CALLERID(num)},${EXTEN}))

;; Обработчик окончания входящего вызова
[sub-call-from-cid-ended]

;; Сообщаем о значениях при конце звонка
exten => s,1,Set(CDR_PROP(disable)=true)
exten => s,n,Set(CallStop=${STRFTIME(epoch,,%s)})
exten => s,n,Set(CallMeDURATION=${MATH(${CallStop}-${CallStart},int)})

;; Статус вызова - Ответ, не ответ...
exten => s,n,Set(CallMeDISPOSITION=${CDR(disposition)})
exten => s,n,Return


;; Обработчик исходящих вызовов - все аналогичено
[outbound-allroutes-custom]

;; Запись
exten => _.,1,Gosub(recording,~~s~~,1(${CALLERID(number)},${EXTEN}))
;; Переменные
exten => _.,n,Set(__CallIntNum=${CALLERID(num)})
exten => _.,n,Set(CallExtNum=${EXTEN})
exten => _.,n,Set(CallStart=${STRFTIME(epoch,,%s)})
exten => _.,n,Set(CallmeCALLID=${SIPCALLID})

;; Вешаем Hangup_Handler на окончание звонка
exten => _.,n,Set(CHANNEL(hangup_handler_push)=sub-call-internal-ended,s,1(${CALLERID(num)},${EXTEN}))

;; Обработчик окончания исходящего вызова
[sub-call-internal-ended]

;; переменные
exten => s,1,Set(CDR_PROP(disable)=true)
exten => s,n,Set(CallStop=${STRFTIME(epoch,,%s)})
exten => s,n,Set(CallMeDURATION=${MATH(${CallStop}-${CallStart},int)})
exten => s,n,Set(CallMeDISPOSITION=${CDR(disposition)})

;; Вызов скрипта, который сообщит о звонке в CRM - это исходящий, 
;; так что по факту окончания
exten => s,n,System(curl -s ${URLPHP}/CallMeOut.php --data action=sendcall2b24 --data ExtNum=${CallExtNum} --data call_id=${SIPCALLID} --data-urlencode FullFname='${FullFname}' --data CallIntNum=${CallIntNum} --data CallDuration=${CallMeDURATION} --data-urlencode CallDisposition='${CallMeDISPOSITION}')
exten => s,n,Return

Característica y diferencia del dialplan original de los autores del artículo original -

  • Dialplan en formato .conf, como lo quiere FreePBX (sí, puede .ael, pero no todas las versiones y no siempre es conveniente)

  • En lugar de procesar el final a través de exten=>h, el procesamiento se introdujo a través de hangup_handler, porque el plan de marcación de FreePBX solo funcionaba con él.

  • Cadena de llamada de secuencia de comandos fija, comillas agregadas y número de llamada externo ExtNum

  • El procesamiento se mueve a _contextos personalizados y le permite no tocar ni editar las configuraciones de FreePBX: entrante a través de [ext-hizo-personalizado], saliente a través de [todas las rutas de salida personalizadas]

  • Sin vinculación a números: el archivo es universal y solo debe configurarse para la ruta y el enlace al servidor

Para comenzar, también debe ejecutar secuencias de comandos en AMI mediante el inicio de sesión y la contraseña; para esto, FreePBX también tiene un archivo _custom

archivo manager_custom.conf

;;  это логин
[callmeplus]
;; это пароль
secret = trampampamturlala
deny = 0.0.0.0/0.0.0.0

;; я работаю с локальной машиной - но если надо, можно и другие прописать
permit = 127.0.0.1/255.255.255.255
read = system,call,log,verbose,agent,user,config,dtmf,reporting,cdr,dialplan
write = system,call,agent,log,verbose,user,config,command,reporting,originate

Ambos archivos deben colocarse en /etc/asterisk, luego volver a leer las configuraciones (o reiniciar el asterisco)

# astrisk -rv
  Connected to Asterisk 16.6.2 currently running on freepbx (pid = 31629)
#freepbx*CLI> dialplan reload
     Dialplan reloaded.
#freepbx*CLI> exit

Ahora pasemos a PHP

Inicializar scripts y crear un servicio

Dado que el esquema para trabajar con Bitrix 24, un servicio para AMI, no es del todo simple y transparente, debe discutirse por separado. Asterisk, cuando AMI está activado, simplemente abre el puerto y listo. Cuando un cliente se une, solicita autorización, luego el cliente se suscribe a los eventos necesarios. Los eventos vienen en texto plano, que PAMI convierte en objetos estructurados y brinda la posibilidad de configurar la función de filtrado solo para eventos de interés, campos, números, etc.

Tan pronto como entra la llamada, el evento NewExten se activa a partir del contexto principal [from-pstn], luego todos los eventos van en el orden de las líneas en los contextos. Cuando se recibe información de las variables CallMeCallerIDName y CallStart especificadas en _custom dialplan, el

  1. La función de solicitar el UserID correspondiente al número de extensión de donde vino la llamada. ¿Qué pasa si es un grupo de acceso telefónico? La pregunta es política, ¿necesitas crear una llamada para todos a la vez (cuando todos llaman a la vez) o crear como llaman cuando llaman a su vez? La mayoría de los clientes tienen la estrategia Fisrt Available, por lo que no hay problema con esto, solo uno llama. Pero el problema debe resolverse.

  2. La función de registro de llamadas en Bitrix24, que devuelve el CallID, que luego se requiere para informar los parámetros de la llamada y un enlace a la grabación. Requiere número de extensión o ID de usuario

Entendiendo FreePBX e integrándolo con Bitrix24 y más

Después del final de la llamada, se llama a la función de descarga de registro, que informa simultáneamente el estado de finalización de la llamada (Ocupado, Sin respuesta, Éxito) y también descarga un enlace al archivo mp3 con el registro (si lo hay).

Debido a que el módulo CallMeIn.php necesita ejecutarse continuamente, se ha creado un archivo de inicio de SystemD para él. llámame.servicio, que debe colocarse en /etc/systemd/system/callme.service

[Unit]
Description=CallMe

[Service]
WorkingDirectory=/var/www/html/callmeplus
ExecStart=/usr/bin/php /var/www/html/callmeplus/CallMeIn.php 2>&1 >>/var/log/callmeplus.log
ExecStop=/bin/kill -WINCH ${MAINPID}
KillSignal=SIGKILL

Restart=on-failure
RestartSec=10s

#тут надо смотреть,какие права на папки
#User=www-data  #Ubuntu - debian
#User=nginx #Centos

[Install]
WantedBy=multi-user.target

la inicialización y el lanzamiento de la secuencia de comandos se produce a través de systemctl o servicio

# systemctl enable callme
# systemctl start callme

El servicio se reiniciará según sea necesario (en caso de fallas). El servicio de seguimiento de la bandeja de entrada no requiere la instalación de un servidor web, solo se necesita php (que definitivamente está en el servidor FeePBX). Pero en ausencia de acceso a los registros de llamadas a través del servidor web (también con https), no será posible escuchar los registros de llamadas.

Ahora hablemos de las llamadas salientes. El script CallMeOut.php tiene dos funciones:

  • Inicio de una llamada cuando se recibe una solicitud de un script php (incluido el uso del botón "Llamar" en el propio Bitrix). No funciona sin un servidor web, la solicitud se recibe a través de HTTP POST, la solicitud contiene un token

  • Mensaje sobre la llamada, sus parámetros y registros en Bitrix. Despedido por Asterisk en el plan de marcación [sub-llamada-interna-finalizada] cuando finaliza una llamada

Entendiendo FreePBX e integrándolo con Bitrix24 y más

El servidor web solo se necesita para dos cosas: descargar archivos de registro de Bitrix (a través de HTTPS) y llamar al script CallMeOut.php. Puede usar el servidor FreePBX incorporado, cuyos archivos son /var/www/html, puede instalar otro servidor o especificar una ruta diferente.

Servidor web

Dejemos la configuración del servidor web para un estudio independiente (tíos, tíos, tíos). Si no tiene un dominio, puede probar FreeDomain( https://www.freenom.com/ru/index.html), que le dará un nombre gratuito para su IP blanca (no olvide reenviar los puertos 80, 443 a través del enrutador si la dirección externa solo está en él). Si acaba de crear un dominio DNS, debe esperar (de 15 minutos a 48 horas) hasta que se carguen todos los servidores. Según la experiencia de trabajar con proveedores nacionales, de 1 hora a un día.

Automatización de instalaciones

Se ha desarrollado un instalador en github para facilitar aún más la instalación. Pero fue fluido en el papel, mientras lo instalamos todo manualmente, ya que después de jugar con todo esto quedó muy claro qué es amigos con quién, quién va a dónde y cómo depurarlo. Aún no hay instalador

Docker

Si desea probar la solución rápidamente, hay una opción con Docker: cree rápidamente un contenedor, dele puertos al exterior, deslice los archivos de configuración e intente (esta es la opción con el contenedor LetsEncrypt, si ya tiene un certificado , solo necesita redirigir el proxy inverso al servidor web FreePBX (le dimos otro puerto 88), LetsEncrypt en docker basado en este artículo

Debe ejecutar el archivo en la carpeta del proyecto descargado (después de git clone), pero primero ingrese a las configuraciones de asterisco (carpeta de asterisco) y escriba las rutas a los registros y la URL de su sitio allí

version: '3.3'
services:
  nginx:
    image: nginx:1.15-alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/ssl_docker.conf:/etc/nginx/conf.d/ssl_docker.conf
  certbot:
    image: certbot/certbot
  freepbx:
    image: flaviostutz/freepbx
    ports:
      - 88:80 # для настройки
      - 5060:5060/udp
      - 5160:5160/udp
      - 127.0.0.1:5038:5038 # для CallMeOut.php
#      - 3306:3306
      - 18000-18100:18000-18100/udp
    restart: always
    environment:
      - ADMIN_PASSWORD=admin123
    volumes:
      - backup:/backup
      - recordings:/var/spool/asterisk/monitor
      - ./callme:/var/www/html/callme
      - ./systemd/callme.service:/etc/systemd/system/callme.conf
      - ./asterisk/manager_custom.conf:/etc/asterisk/manager_custom.conf
      - ./asterisk/extensions_custom.conf:/etc/asterisk/extensions_custom.conf
#      - ./conf/startup.sh:/startup.sh

volumes:
  backup:
  recordings:

Este archivo docker-compose.yaml se ejecuta mediante

docker-compose up -d

Si nginx no se inicia, entonces algo está mal con la configuración en la carpeta nginx/ssl_docker.conf

Otras integraciones

Y por qué no poner algo de CRM en los scripts al mismo tiempo, pensamos. Estudiamos varias otras API de CRM, especialmente el PBX integrado gratuito: ShugarCRM y Vtiger, ¡y sí! si, el principio es el mismo. Pero esta es otra historia, que luego subiremos al github por separado.

referencias

Descargo de responsabilidad: cualquier parecido con la realidad es ficticio y no fui yo.

Fuente: habr.com

Añadir un comentario