Explorando o FreePBX e integrando-o com o Bitrix24 e muito mais

Bitrix24 é uma imensa plataforma que combina CRM, gestão de documentos, contabilidade e muitas outras funcionalidades que agradam aos gerentes e nem tanto ao pessoal de TI. O portal é usado por muitas pequenas e médias empresas, incluindo pequenas clínicas, fabricantes e até salões de beleza. A principal funcionalidade que os gerentes 'adoram' é a integração da telefonia com o CRM, onde qualquer ligação é imediatamente registrada no CRM, são criados registros de clientes, informações são mostradas ao receber uma chamada, e é possível ver quem é o cliente, o que ele pode comprar e quanto deve. Porém, a telefonia do Bitrix24 e sua integração com o CRM têm um custo, às vezes considerável. Neste artigo, contarei minha experiência de integração usando ferramentas abertas e uma popular central telefônica IP. FreePBX, e também abordarei a lógica de funcionamento das várias partes.

Trabalho como freelancer em uma empresa que vende e configura, integra telefonia IP. Quando me perguntaram se poderíamos oferecer algo para a integração do Bitrix24 com centrais telefônicas que os clientes possuem, bem como com centrais virtuais em diversos VDS da empresa, eu fui ao Google. E ele, claro, me deu um link para um artigo no Habr, onde há descrição, GitHub, e parece que tudo funciona. Mas ao tentar usar essa solução, percebi que o Bitrix24 não é mais o que era antes, e muitas coisas precisam ser refeitas. Além disso, o FreePBX não é um Astérisk puro; você precisa considerar como combinar a usabilidade e o dialplan hardcore nos arquivos de configuração.

Analisando a lógica de funcionamento

Bem, primeiro, como tudo isso deve funcionar. Quando uma chamada externa entra na central (evento SIP INVITE do provedor), o dialplan (plano de discagem) começa a ser processado — regras sobre o que fazer e em que ordem com a chamada. Do primeiro pacote é possível obter muitas informações que podem ser utilizadas nas regras. Uma excelente ferramenta para estudar as internações do SIP é o analisador sngrep (link) que pode ser facilmente instalado nas distribuições populares através de 'apt install/yum install' e similar, mas também pode ser compilado a partir do código fonte. Vamos ver o log de chamadas no sngrep.

Explorando o FreePBX e integrando-o com o Bitrix24 e muito mais

De forma simplificada, o dialplan lida apenas com o primeiro pacote; às vezes, também ocorrem a transferência de chamadas e a pressão de botões (DTMF) durante a conversa, além de diversas funcionalidades interessantes, como FollowMe, RingGroup, IVR e outras.

O que está dentro do pacote Invite

Explorando o FreePBX e integrando-o com o Bitrix24 e muito mais

Na verdade, a maioria dos dialplans simples opera com os dois primeiros campos, e toda a lógica gira em torno do DID e do CallerID. DID — para onde estamos ligando; CallerID — quem está ligando.

Mas nós somos uma empresa, não apenas um telefone — e isso significa que, na central telefônica, provavelmente existem grupos de chamadas (ligações simultâneas/ sequenciais de vários aparelhos) para números urbanos (Ring Group), IVR (Olá, você ligou para... Pressione um para...), Secretárias eletrônicas (Phrases), Condições temporais (Time Conditions), Encaminhamento para outros números ou para celular (FollowMe, Forward). Isso significa que é muito difícil definir exatamente para quem a chamada realmente irá e com quem será a conversa ao receber a ligação. Aqui está um exemplo de como uma chamada típica passa pela central telefônica de nossos clientes.

Explorando o FreePBX e integrando-o com o Bitrix24 e muito mais

Após a entrada bem-sucedida da chamada na central telefônica, ocorre a sua jornada pelo dialplan em diferentes "contextos". O contexto, do ponto de vista do Asterisk, é um conjunto numerado de comandos, cada um contendo um filtro para o número discado (chamado de exten, para chamadas externas na etapa inicial é exten=DID). Os comandos na linha do dialplan podem ser qualquer coisa — funções internas (por exemplo, ligar para um ramal interno — Dial(), desligar — Hangup()), operadores condicionais (IF, ELSE, ExecIF e semelhantes), transições para outras regras deste contexto (Goto, GotoIF), transições para outros contextos na forma de chamadas de funções (Gosub, Macro). Há uma diretiva separada chamada include nome_contexto, que adiciona comandos de outro contexto ao final do contexto atual. Os comandos incluídos através do include sempre executam após o comando do contexto atual.

Toda a lógica de funcionamento do FreePBX é construída sobre a inclusão de diferentes contextos uns nos outros através do include e chamadas via Gosub, Macro e manipuladores Handler. Vamos analisar o contexto de chamadas recebidas do FreePBX.

Explorando o FreePBX e integrando-o com o Bitrix24 e muito mais

A chamada passa por todos os contextos de cima para baixo, na ordem, e em cada contexto podem haver chamadas para outros contextos como macros (Macro), funções (Gosub) ou simplesmente transições (Goto); portanto, a árvore real do que está sendo chamado só pode ser rastreada nos logs.

O esquema padrão para configuração de uma PABX típica está mostrado abaixo. Ao fazer chamadas, nos roteiros de entrada, busca-se o DID, verificam-se as condições temporais; se tudo estiver em ordem, o menu de voz é acionado. A partir dele, pressionando o botão 1 ou após um timeout, a chamada é encaminhada para o grupo de operadores. Após o término da chamada, a macro hangupcall é chamada, após a qual nada mais pode ser executado no diagrama de chamadas, exceto por tratadores especiais (hangup handler).

Explorando o FreePBX e integrando-o com o Bitrix24 e muito mais

Onde neste algoritmo de chamada devemos fornecer informações sobre o início da chamada no CRM, onde iniciar a gravação, onde finalizar a gravação e enviá-la juntamente com as informações da chamada para o CRM?

Integração com sistemas externos

O que é a integração entre PABX e CRM? São configurações e programas que convertem dados e eventos entre essas duas plataformas e os enviam mutuamente. O método mais comum de interação entre sistemas independentes é a API, e o método mais popular de acesso à API é o HTTP REST. Mas não para asterisk.

Dentro do Asterisk há:

  • AGI — chamada síncrona de programas/componentes externos, utilizada principalmente no diagrama de chamadas, com bibliotecas como phpagi, PAGI

  • AMI — socket TCP textual, que opera sob o princípio de assinatura de eventos e entrada de comandos textuais, lembrando o SMTP internamente, pode rastrear eventos e gerenciar chamadas, há a biblioteca PAMI — a mais popular para estabelecer conexão com o Asterisk

Exemplo de saída do AMI

Evento: Newchannel
Privilégio: call,all
Canal: PJSIP/VMS_pjsip-0000078b
EstadoCanal: 4
DescEstadoCanal: Ring
NúmeroCallerID: 111222
NomeCallerID: 111222
NúmeroLinhaConectada:
NomeLinhaConectada:
Língua: en
CódigoConta:
Contexto: from-pstn
Extensão: s
Prioridade: 1
Uniqueid: 1599589046.5244
Linkedid: 1599589046.5244

  • ARI — uma mistura de ambos, tudo através de REST, WebSocket, no formato JSON — mas com bibliotecas e wrappers recentes não muito bons, a primeira vista encontramos (phparia, phpari) que estão estagnadas no seu desenvolvimento há cerca de 3 anos.

Exemplo de saída do ARI ao iniciar uma chamada

{ "variável":"CallMeCallerIDName", "valor":"111222", "tipo":"ChannelVarset", "timestamp":"2020-09-09T09:38:36.269+0000", "canal":{ "id":"1599644315.5334", "nome":"PJSIP/VMSpjsip-000007b6", "estado":"Ring", "chamador":{ "nome":"111222", "número":"111222" }, "conectado":{ "nome":"", "número":"" }, "códigoconta":"", "diagrama":{ "contexto":"from-pstn", "extensão":"s", "prioridade":2, "aplicativo"nome":"Stasis", "aplicativo"dados":"hello-world" }, "tempoCriação":"2020-09-09T09:38:35.926+0000", "língua":"ru" }, "asterisk"id":"48:5b:aa:aa:aa:aa", "aplicativo":"hello-world" }

A conveniência ou inconveniência, a possibilidade ou impossibilidade de trabalhar com determinada API são definidas pelas tarefas que precisam ser resolvidas. As tarefas para integração com o CRM são as seguintes:

  • Rastrear o início da chamada, para onde foi transferida, extrair CallerID, DID, tempos de início e fim, talvez dados do diretório (para encontrar a relação entre telefone e usuário do CRM)

  • Iniciar e encerrar a gravação da chamada, salvá-la no formato necessário, informar onde o arquivo está localizado após a gravação

  • Iniciar uma chamada a partir de um evento externo (do programa), ligar para um número interno, externo e conectá-los

  • Opcional: integrar com CRM, grupos de discagem e FollowME para redirecionar automaticamente chamadas quando ausente (com base nas informações do CRM)

Todas essas tarefas podem ser resolvidas através do AMI ou ARI, mas o ARI fornece muito menos informações, muitos eventos não são rastreados, muitas variáveis que existem no AMI não estão disponíveis (por exemplo, chamadas de macros, atribuição de variáveis dentro das macros, incluindo gravação de chamadas). Portanto, para rastreamento correto e preciso — vamos optar por AMI por enquanto (mas não de forma definitiva). Além disso (bem, o que seria de nós, somos pessoas preguiçosas) — no trabalho original (artigo no Habr) usam PAMI. *Depois, precisamos tentar reescrever usando ARI, mas não garantimos que funcionará.

Estamos reinventando a integração

Para que nosso FreePBX possa notificar o AMI de maneira simples sobre o início da chamada, tempo de término, números, nomes de arquivos gravados e calcular a duração da chamada, a maneira mais fácil é usar o mesmo truque que os autores originais — introduzir nossas variáveis e analisar a saída para a sua presença. O PAMI oferece isso de forma simples através de uma função de filtro.

Aqui está um exemplo de definição da nossa variável para o tempo de início da chamada (s é um número especial no dialplan que é executado ANTES de começar a busca pelo DID)

[ext-did-custom]

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

Exemplo de evento AMI para esta linha

Evento: Newchannel

Privilégio: call,all

Canal: PJSIP/VMS_pjsip-0000078b

EstadoCanal: 4

DescEstadoCanal: Ring

NúmeroCallerID: 111222

NomeCallerID: 111222

NúmeroLinhaConectada:

NomeLinhaConectada:

Língua: en

CódigoConta:

Contexto: from-pstn

Extensão: s

Prioridade: 1

Uniqueid: 1599589046.5244

Linkedid: 1599589046.5244

Application: Set AppData:

CallStart=1599571046

Como o FreePBX regrava os arquivos extention.conf e extention_additional.conf, vamos usar o arquivo extention_custom.conf

Código completo em extention_custom.conf

[globals]	
;; Verifique os caminhos e permissões das pastas - o usuário asterisk deve ter permissões de gravação
;; Aqui serão gravadas as conversas
WAV=\/var\/www\/html\/callme\/records\/wav 
MP3=\/var\/www\/html\/callme\/records\/mp3

;; Estes são os caminhos onde a gravação será reproduzida e baixada
URLRECORDS=https:\/\/www.host.ru\/callmeplus\/records\/mp3

;; Endereço para o callback em uma chamada de saída
URLPHP=https:\/\/www.host.ru\/callmeplus

;; Sim, estamos gravando as conversas
RECORDING=1

;; Este é o macro para gravar conversas em nossa pasta. 
;; É possível usar a gravação do sistema, mas por enquanto vamos manter esta - 
;; ela está funcionando
[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()


;; Este é o contexto principal para iniciar a conversa
[ext-did-custom]

;; É uma travessura fazer isso aqui, mas funciona - adicionamos '8' ao número
nexten =>  s,1,Set(CALLERID(num)=8${CALLERID(num)})

;; Aqui estão várias variáveis para o script
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)})

;; O mais importante! Manipulador de término da conversa. 
;; Os caminhos normais de tratamento do término através (exten=>h,1,o que quer que seja) em FreePBX não funcionam - Macro(hangupcall,) estraga tudo. 
;; Por isso, associamos Hangup_Handler ao término da chamada
exten => s,n,Set(CHANNEL(hangup_handler_push)=sub-call-from-cid-ended,s,1(${CALLERID(num)},${EXTEN}))

;; Manipulador de término de chamada recebida
[sub-call-from-cid-ended]

;; Informamos os valores no término da chamada
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)})

;; Status da chamada - Atendida, não atendida...
exten => s,n,Set(CallMeDISPOSITION=${CDR(disposition)})
exten => s,n,Return


;; Manipulador de chamadas de saída - tudo é análogo
[outbound-allroutes-custom]

;; Gravação
exten => _.,1,Gosub(recording,~~s~~,1(${CALLERID(number)},${EXTEN}))
;; Variáveis
exten => _.,n,Set(__CallIntNum=${CALLERID(num)})
exten => _.,n,Set(CallExtNum=${EXTEN})
exten => _.,n,Set(CallStart=${STRFTIME(epoch,,%s)})
exten => _.,n,Set(CallmeCALLID=${SIPCALLID})

;; Associamos Hangup_Handler ao término da chamada
exten => _.,n,Set(CHANNEL(hangup_handler_push)=sub-call-internal-ended,s,1(${CALLERID(num)},${EXTEN}))

;; Manipulador de término da chamada de saída
[sub-call-internal-ended]

;; variáveis
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)})

;; Chamamos o script que informará sobre a chamada no CRM - isso é uma chamada de saída, 
;; portanto, ao finalizar
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

A característica e a diferença em relação ao dialplan original dos autores do artigo original —

  • Dialplan no formato .conf, já que é isso que o FreePBX deseja (sim, ele também suporta .ael, mas nem todas as versões e nem sempre isso é conveniente)

  • Em vez de processar o término através de exten=>h, foi implementado um tratamento através de hangup_handler, pois o dialplan do FreePBX funciona apenas com ele

  • A linha de chamada do script foi corrigida, aspas foram adicionadas e o número externo da chamada ExtNum

  • O processamento foi movido para contextos _custom e permite que você não precise alterar e não edite as configurações do FreePBX — entrada através de [ext-did-custom], saída através de [outbound-allroutes-custom]

  • Não há vinculação a números — o arquivo é genérico e precisa de configuração apenas do caminho e referência ao servidor

Para começar a trabalhar, você também precisa liberar scripts no AMI usando login e senha — para isso também existe um arquivo _custom no FreePBX

Arquivo manager_custom.conf

;;  este é o login
[callmeplus]
;; este é a senha
secret = trampampamturlala
deny = 0.0.0.0/0.0.0.0

;; eu trabalho com a máquina local - mas se necessário, outros podem ser configurados
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

Esses dois arquivos devem ser colocados em /etc/asterisk, depois os arquivos de configuração devem ser relidos (ou o Asterisk reiniciado)

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

Agora vamos passar para o PHP

Inicialização de scripts e criação do serviço

Como o esquema de trabalho com o Bitrix 24, um serviço para AMI, não é exatamente simples e transparente, devemos falar sobre isso separadamente. O Asterisk, ao ativar o AMI, simplesmente abre a porta e pronto. Quando um cliente se conecta, ele solicita autorização, e então o cliente se inscreve nos eventos desejados. Os eventos chegam em texto simples, que o PAMI transforma em objetos estruturados e oferece a possibilidade de definir funções de filtragem apenas para eventos, campos, números, etc. de interesse.

Assim que a chamada chega, ocorre o evento NewExten começando com o contexto pai [from-pstn], em seguida, todos os eventos seguem na ordem das linhas nos contextos. Ao obter informações das variáveis _custom CallMeCallerIDName e CallStart definidas no dialplan, é chamada

  1. A função de consulta UserID, correspondente ao número interno para o qual a chamada foi recebida. E se for um grupo de discagem? É uma questão política; devemos criar uma chamada para todos ao mesmo tempo (quando todos ligam ao mesmo tempo) ou criar conforme a discagem em ordem? A maioria dos clientes adota a estratégia First Available, então isso não é um problema, apenas uma pessoa é chamada. Mas a questão precisa ser resolvida.

  2. A função de registro de chamadas no Bitrix24, que retorna o CallID, necessário depois para relatar os parâmetros da chamada e o link para a gravação. Exige ou o número interno ou o UserID.

Explorando o FreePBX e integrando-o com o Bitrix24 e muito mais

Após o término da chamada, a função de carregamento da gravação é chamada, que simultaneamente informa o status de conclusão da chamada (Ocupado, Sem resposta, Sucesso), além de carregar o link para o arquivo mp3 da gravação (se houver).

Como o módulo CallMeIn.php deve funcionar continuamente, foi criado um arquivo de inicialização SystemD para ele. callme.service, que deve ser colocado em /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

#aqui é preciso verificar quais permissões são necessárias para as pastas
#User=www-data  #Ubuntu - debian
#User=nginx #Centos

[Install]
WantedBy=multi-user.target

a inicialização e execução do script ocorre através do systemctl ou service.

# systemctl enable callme
# systemctl start callme

O serviço irá reiniciar automaticamente conforme necessário (em caso de falhas). O serviço de monitoramento de chamadas recebidas não requer a instalação de um servidor web, apenas o PHP (que com certeza está no servidor FreePBX). Mas na ausência de acesso às gravações de chamadas através do servidor web (também com HTTPS) não será possível ouvir as gravações das conversas.

Agora vamos falar sobre chamadas de saída. O script CallMeOut.php possui duas funções:

  • Inicia a chamada ao receber um pedido no script PHP (inclusive através do botão 'Ligar' no próprio Bitrix). Não funciona sem um servidor web, o pedido chega via HTTP POST, contendo um token.

  • Relata sobre a chamada, seus parâmetros e gravações no Bitrix. Isso ocorre por iniciativa do Asterisk no plano de discagem [sub-call-internal-ended] ao final da chamada.

Explorando o FreePBX e integrando-o com o Bitrix24 e muito mais

O servidor web é necessário apenas para duas coisas — o upload de arquivos de gravações pelo Bitrix (via HTTPS) e a chamada do script CallMeOut.php. É possível usar o servidor embutido do FreePBX, cujos arquivos estão em /var/www/html, ou instalar outro servidor ou especificar um caminho diferente.

Servidor web

Deixaremos a configuração do servidor web para estudo independente (clique aqui, clique aqui, clique aqui). Se você não possui um domínio, pode tentar o FreeDomain ( https://www.freenom.com/ru/index.html), que te dará gratuitamente um nome para seu IP público (não esqueça de redirecionar as portas 80 e 443 através do roteador, caso o endereço externo esteja somente nele). Se você acabou de criar o domínio DNS, será necessário aguardar (de 15 minutos a 48 horas) até que todos os servidores carreguem. Pela experiência com provedores nacionais — de 1 hora a um dia.

Automatização da instalação

No github, o desenvolvimento de um instalador foi iniciado para facilitar ainda mais a instalação. Mas tudo parecia fácil no papel — enquanto instalamos tudo isso manualmente, após investigar, ficou claro quem se dá bem com quem, quem vai aonde e como depurar isso. O instalador ainda não está disponível (

Docker

Se quiser testar a solução rapidamente — há uma opção com Docker — criar um container rapidamente, expor as portas, inserir os arquivos de configuração e testar (essa é uma opção com o container LetsEncrypt, se o certificado já existir, basta redirecionar o proxy reverso para o servidor web FreePBX (demos a ele uma porta diferente — 88), LetsEncrypt no Docker inspirado em este artigo

Você deve executar o arquivo na pasta do projeto baixado (após o git clone), mas primeiro acesse as configurações do Asterisk (pasta asterisk) e defina os caminhos para as gravações e a URL do seu site.

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 # para configuração
      - 5060:5060\/udp
      - 5160:5160\/udp
      - 127.0.0.1:5038:5038 # para 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 arquivo docker-compose.yaml é executado através de

docker-compose up -d

Se o nginx não iniciou, então há algo de errado com a configuração na pasta nginx\/ssl_docker.conf.

Outras integrações

E por que não incluir algumas CRM nos scripts, pensamos. Estudamos algumas APIs de outras CRM, especialmente a gratuita embutida em algumas PABX — ShugarCRM e Vtiger, e sim! é possível, o princípio é o mesmo. Mas essa é outra história que vamos subir no github separadamente.

Links

Disclaimer: quaisquer semelhanças com a realidade são puramente coincidentes e não fui eu.

Fonte: habr.com

Compre hospedagem confiável para sites com proteção contra DDoS, servidores VPS VDS 🔥 Compre hospedagem confiável para sites com proteção contra DDoS, servidores VPS VDS | ProHoster