Pochopení FreePBX a jeho integrace s Bitrix24 a dalšími

Bitrix24 je obrovská kombinace, která kombinuje CRM, workflow, účetnictví a mnoho dalších věcí, které mají manažeři opravdu rádi a IT pracovníci naopak nemají rádi. Portál využívá spousta malých a středních firem, včetně malých klinik, výrobců a dokonce i kosmetických salonů. Hlavní funkcí, kterou manažeři „milují“, je integrace telefonie a CRM, kdy se jakýkoli hovor okamžitě zaznamená do CRM, vytvoří se klientské karty, při příchozím se zobrazí informace o klientovi a hned vidíte, kdo je, co může prodat a kolik dluží. Jenže telefonování od Bitrix24 a jeho integrace s CRM stojí peníze, někdy hodně. V článku vám řeknu zkušenosti s integrací s otevřenými nástroji a populární IP PBX freepbx, a také zvážit logiku práce různých částí

Pracuji jako outsourcing ve společnosti, která prodává a konfiguruje, integruje IP telefonii. Když jsem byl požádán, zda bychom této a této společnosti mohli nabídnout něco pro integraci Bitrix24 s PBX, které mají zákazníci, a také s virtuálními PBX na různých společnostech VDS, šel jsem do Googlu. A samozřejmě mi dal odkaz článek v habr, kde je popis a github a zdá se, že vše funguje. Při pokusu o použití tohoto řešení se ale ukázalo, že Bitrix24 již není stejný jako dříve a je třeba mnohé předělat. FreePBX pro vás navíc není pouhá hvězdička, zde je třeba myslet na to, jak v konfiguračních souborech zkombinovat snadnost použití a hardcore dialplan.

Studujeme logiku práce

Takže pro začátek, jak by to mělo všechno fungovat. Při přijetí hovoru z vnějšku ústředny (událost SIP INVITE od poskytovatele) začíná zpracování dialplanu (dial plan, dialplan) - pravidla, co a v jakém pořadí s hovorem dělat. Z prvního paketu lze získat mnoho informací, které lze následně použít v pravidlech. Vynikajícím nástrojem pro studium vnitřností SIP je analyzátor sngrep (odkaz), který se v populárních distribucích jednoduše instaluje pomocí apt install/yum install a podobně, ale lze jej také sestavit ze zdroje. Podívejme se na protokol hovorů sngrep

Pochopení FreePBX a jeho integrace s Bitrix24 a dalšími

Ve zjednodušené podobě se dialplan zabývá pouze prvním paketem, někdy i během hovoru, přepojováním hovorů, stisky tlačítek (DTMF), různými zajímavostmi jako FollowMe, RingGroup, IVR a další.

Co je uvnitř balíčku Invite Pack

Pochopení FreePBX a jeho integrace s Bitrix24 a dalšími

Ve skutečnosti většina jednoduchých číselných plánů pracuje s prvními dvěma poli a celá logika se točí kolem DID a CallerID. DID - kam voláme, CallerID - kdo volá.

Ale přeci jen máme firmu a ne jeden telefon - to znamená, že ústředna má s největší pravděpodobností skupiny volání (současné / po sobě jdoucí prozvonění více zařízení) na městských číslech (Ring Group), IVR (Dobrý den, volali jste ... Stiskněte jeden pro ...), Záznamníky (Phrases), Časové podmínky, Přesměrování na jiná čísla nebo do buňky (FollowMe, Forward). To znamená, že je velmi obtížné jednoznačně určit, kdo skutečně přijme hovor a s kým bude při příchozím hovoru hovořit. Zde je příklad začátku typického hovoru v ústředně našich klientů

Pochopení FreePBX a jeho integrace s Bitrix24 a dalšími

Po úspěšném vstupu hovoru do ústředny prochází dialplánem v různých „kontextech“. Kontextem je z pohledu Asterisku očíslovaná sada příkazů, z nichž každý obsahuje filtr podle volaného čísla (říká se exten, pro vnější volání v počáteční fázi exten=DID). Příkazy v lince dialplanu mohou být jakékoli - interní funkce (například volání interního účastníka - Dial(), položte telefon - Hangup()), podmíněné operátory (IF, ELSE, ExecIF a podobně), přechody na jiná pravidla tohoto kontextu (Goto, GotoIF), přechod do dalších kontextů formou volání funkce (Gosub, Macro). Samostatná směrnice include имя_контекста, který přidává příkazy z jiného kontextu na konec aktuálního kontextu. Příkazy zahrnuté přes include se provádějí vždy po příkazy aktuálního kontextu.

Celá logika FreePBX je postavena na zahrnutí různých kontextů do sebe prostřednictvím include a call pomocí Gosub, Macro a Handler handlerů. Zvažte kontext příchozích volání FreePBX

Pochopení FreePBX a jeho integrace s Bitrix24 a dalšími

Volání prochází postupně všemi kontexty shora dolů, v každém kontextu mohou být volání do jiných kontextů, jako jsou makra (Macro), funkce (Gosub) nebo jen přechody (Goto), takže skutečný strom toho, co je voláno, může pouze být sledován v protokolech.

Typické schéma nastavení pro typickou PBX je uvedeno níže. Při volání se hledá DID v příchozích trasách, kontrolují se u něj dočasné podmínky, pokud je vše v pořádku, spustí se hlasové menu. Z něj stiskem tlačítka 1 nebo timeoutu opustíte skupinu operátorů volby. Po ukončení hovoru je voláno makro hangupcall, po kterém nelze v dialplanu nic dělat, kromě speciálních handlerů (handler hangup).

Pochopení FreePBX a jeho integrace s Bitrix24 a dalšími

Kde v tomto algoritmu hovoru máme dodat informaci o začátku hovoru do CRM, kde začít nahrávat, kde nahrávání ukončit a odeslat spolu s informací o hovoru do CRM?

Integrace s externími systémy

Co je integrace PBX a CRM? Jedná se o nastavení a programy, které převádějí data a události mezi těmito dvěma platformami a vzájemně si je posílají. Nejběžnějším způsobem komunikace nezávislých systémů je prostřednictvím rozhraní API a nejoblíbenějším způsobem přístupu k rozhraním API je HTTP REST. Ale ne za hvězdičku.

Uvnitř hvězdičky je:

  • AGI - synchronní volání externích programů/komponent, používá se především v dialplanu, existují knihovny jako např. phpagi, PAGI

  • AMI - textový TCP socket, který funguje na principu přihlašování k událostem a zadávání textových příkazů, zevnitř připomíná SMTP, umí sledovat události a spravovat hovory, je zde knihovna PAMI - nejoblíbenější pro vytvoření spojení s Asterisk

Příklad výstupu AMI

Událost: Nový kanál
Výsada: volání, všichni
Kanál: PJSIP/VMS_pjsip-0000078b
Stav kanálu: 4
ChannelStateDesc: Vyzvánět
ID volajícího: 111222
Jméno volajícího: 111222
ConnectedLineNum:
připojený název řádku:
Jazyk: en
kód účtu:
Kontext: od-pstn
Rozšířit: s
Priorita: 1
Jedinečné: 1599589046.5244
Linkedid: 1599589046.5244

  • ARI je směs obojího, vše přes REST, WebSocket, ve formátu JSON – ale s čerstvými knihovnami a obaly, nepříliš dobré, z ruky nalezené (phparia, phpari), který se stal v jejich vývoji asi před 3 lety.

Příklad výstupu ARI při zahájení hovoru

{ "variable":"CallMeCallerIDName", "value":"111222", "type":"ChannelVarset", "timestamp":"2020-09-09T09:38:36.269+0000", "channel":{ "id »:»1599644315.5334″, «jméno»:»PJSIP/VMSpjsip-000007b6″, "state":"Vyzvánění", "volající":{ "jméno":"111222″, "číslo":"111222″ }, "připojeno":{ "jméno":"", "číslo" :"" }, "accountcode":"", "dialplan":{ "context":"from-pstn", "exten":"s", "priority":2, "appjméno":"Stasis", "appdata":"hello-world" }, "creationtime":"2020-09-09T09:38:35.926+0000", "language":"en" }, "hvězdičkaid":"48:5b:aa:aa:aa:aa", "application":"hello-world" }

Pohodlí nebo nepohodlí, možnost nebo nemožnost práce s konkrétním API jsou určeny úkoly, které je třeba vyřešit. Úkoly pro integraci s CRM jsou následující:

  • Sledujte začátek hovoru, kam byl přepojen, vytáhněte CallerID, DID, čas začátku a konce, případně data z adresáře (pro vyhledání spojení mezi telefonem a uživatelem CRM)

  • Spusťte a ukončete záznam hovoru, uložte jej v požadovaném formátu, na konci záznamu informujte, kde se soubor nachází

  • Iniciujte volání na externí událost (z programu), zavolejte na interní číslo, externí číslo a spojte je

  • Volitelný: integrace s CRM, dialerovými skupinami a FollowME pro automatické přesměrování hovorů v nepřítomnosti místa (podle CRM)

Všechny tyto úlohy lze řešit přes AMI nebo ARI, ale ARI poskytuje mnohem méně informací, událostí není mnoho, mnoho proměnných, které AMI stále má (například volání maker, nastavení proměnných uvnitř maker včetně nahrávání hovorů) není sledováno. Pro správné a přesné sledování proto zvolme zatím AMI (ne však úplně). Navíc (no, kde by to bez toho bylo, my jsme leniví) - v původním díle (článek v habr) používat PAMI. *Pak je třeba zkusit přepsat na ARI, ale ne skutečnost, že to půjde.

Znovuobjevování integrace

Aby naše FreePBX mohla jednoduchým způsobem hlásit AMI začátek hovoru, čas ukončení, čísla, názvy nahraných souborů, je nejjednodušší vypočítat délku hovoru pomocí stejného triku jako původní autoři - zadejte své proměnné a analyzujte výstup na jejich přítomnost. PAMI navrhuje udělat to jednoduše pomocí funkce filtru.

Zde je příklad nastavení vlastní proměnné pro čas zahájení hovoru (s je speciální číslo v dialplanu, které se provádí PŘED spuštěním vyhledávání DID)

[ext-did-custom]

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

Příklad události AMI pro tento řádek

Událost: Nový kanál

Výsada: volání, všichni

Kanál: PJSIP/VMS_pjsip-0000078b

Stav kanálu: 4

ChannelStateDesc: Vyzvánět

ID volajícího: 111222

Jméno volajícího: 111222

ConnectedLineNum:

připojený název řádku:

Jazyk: en

kód účtu:

Kontext: od-pstn

Rozšířit: s

Priorita: 1

Jedinečné: 1599589046.5244

Linkedid: 1599589046.5244

Aplikace: Nastavit data aplikace:

Začátek hovoru=1599571046

Protože FreePBX přepisuje soubory extensionion.conf a extensionion_další.conf, použijeme soubor rozšíření_zvyk.conf

Úplný kód 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

Vlastnost a rozdíl oproti původnímu číselníku autorů původního článku -

  • Dialplan ve formátu .conf, jak to chce FreePBX (ano, umí .ael, ale ne všechny verze a ne vždy se to hodí)

  • Místo zpracování konce přes exten=>h bylo zpracování zavedeno přes hangup_handler, protože dialplán FreePBX fungoval pouze s ním

  • Opraven řetězec volání skriptu, přidány uvozovky a externí volací číslo ExtNum

  • Zpracování je přesunuto do _vlastních kontextů a umožňuje vám nedotýkat se ani upravovat konfigurace FreePBX – příchozí přes [ext-did-custom], odchozí přes [outbound-allroutes-custom]

  • Žádná vazba na čísla - soubor je univerzální a je třeba jej nakonfigurovat pouze pro cestu a odkaz na server

Chcete-li začít, musíte také spouštět skripty v AMI pomocí přihlašovacího jména a hesla - k tomu má FreePBX také _custom soubor

soubor 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

Oba tyto soubory je třeba umístit do /etc/asterisk, poté znovu přečíst konfiguraci (nebo restartovat hvězdičku)

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

Nyní přejděme k PHP

Inicializace skriptů a vytvoření služby

Vzhledem k tomu, že schéma práce s Bitrix 24, službou pro AMI, není úplně jednoduché a transparentní, je třeba ho probrat samostatně. Asterisk při aktivaci AMI jednoduše otevře port a je to. Když se klient připojí, požádá o autorizaci a poté se klient přihlásí k odběru nezbytných událostí. Události přicházejí v prostém textu, který PAMI převádí na strukturované objekty a poskytuje možnost nastavit funkci filtrování pouze pro události zájmu, pole, čísla atd.

Jakmile přijde volání, spustí se událost NewExten počínaje nadřazeným kontextem [from-pstn] a poté všechny události projdou v pořadí řádků v kontextech. Když jsou přijaty informace z proměnných CallMeCallerIDName a CallStart zadaných v _custom dialplan,

  1. Funkce vyžádání ID uživatele odpovídající číslu pobočky, kam hovor přišel. Co když je to vytáčená skupina? Otázka je politická, potřebujete vytvořit hovor pro všechny najednou (když všichni volají najednou) nebo vytvořit tak, jak volají, když střídavě volají? Většina klientů má strategii Fisrt Available, takže s tím není problém, pouze jeden hovor. Problém je ale potřeba vyřešit.

  2. Funkce registrace hovoru v Bitrix24, která vrací CallID, která je pak vyžadována k nahlášení parametrů hovoru a odkazu na nahrávku. Vyžaduje číslo pobočky nebo ID uživatele

Pochopení FreePBX a jeho integrace s Bitrix24 a dalšími

Po ukončení hovoru je volána funkce stahování záznamu, která současně hlásí stav ukončení hovoru (Obsazeno, Neodpovídá, Úspěch) a také stáhne odkaz na mp3 soubor se záznamem (pokud existuje).

Protože modul CallMeIn.php musí běžet nepřetržitě, byl pro něj vytvořen spouštěcí soubor SystemD callme.service, který musí být vložen do /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

inicializace a spuštění skriptu probíhá prostřednictvím systemctl nebo služby

# systemctl enable callme
# systemctl start callme

Služba se sama restartuje podle potřeby (v případě pádů). Služba sledování doručené pošty nevyžaduje instalaci webového serveru, stačí pouze php (což je určitě na serveru FeePBX). Ale bez přístupu k záznamům hovorů přes webový server (také s https), nebude možné poslouchat záznamy hovorů.

Nyní pojďme mluvit o odchozích hovorech. Skript CallMeOut.php má dvě funkce:

  • Zahájení hovoru, když je přijat požadavek na php skript (včetně použití tlačítka "Zavolat" v samotném Bitrixu). Bez webového serveru to nefunguje, požadavek je přijat přes HTTP POST, požadavek obsahuje token

  • Zpráva o hovoru, jeho parametrech a záznamech v Bitrixu. Spouští Asterisk v dialplánu [sub-call-internal-ended], když hovor skončí

Pochopení FreePBX a jeho integrace s Bitrix24 a dalšími

Webový server je potřeba pouze pro dvě věci – stahování souborů záznamů Bitrix (přes HTTPS) a volání skriptu CallMeOut.php. Můžete použít vestavěný server FreePBX, jehož soubory jsou /var/www/html, můžete nainstalovat jiný server nebo zadat jinou cestu.

webový server

Necháme nastavení webového serveru pro samostatné studium (tyts, tyts, tyts). Pokud nemáte doménu, můžete zkusit FreeDomain( https://www.freenom.com/ru/index.html), který vám dá volné jméno pro vaši bílou IP (nezapomeňte přesměrovat porty 80, 443 přes router, pokud je externí adresa pouze na něm). Pokud jste právě vytvořili doménu DNS, musíte počkat (od 15 minut do 48 hodin), než se načtou všechny servery. Podle zkušeností z práce s tuzemskými poskytovateli - od 1 hodiny do dne.

Automatizace instalace

Na githubu byl vyvinut instalační program, který instalaci ještě zjednoduší. Ale na papíře to bylo hladké - zatímco jsme to všechno instalovali ručně, protože po tom všem bylo křišťálově jasné, co se s kým kamarádí, kdo kam chodí a jak to odladit. Zatím neexistuje žádný instalátor

přístavní dělník

Pokud chcete řešení rychle vyzkoušet - existuje možnost s Dockerem - rychle vytvořte kontejner, dejte mu porty ven, podsuňte soubory nastavení a vyzkoušejte (toto je možnost s kontejnerem LetsEncrypt, pokud již máte certifikát , stačí přesměrovat reverzní proxy na webový server FreePBX (dali jsme mu jiný port je 88), LetsEncrypt v dockeru na základě tento článek

Musíte spustit soubor ve stažené složce projektu (po git clone), ale nejprve se dostat do asterisk configs (složka hvězdička) a napsat tam cesty k záznamům a URL svého webu

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:

Tento soubor docker-compose.yaml se spouští přes

docker-compose up -d

Pokud se nginx nespustí, pak je něco špatně s konfigurací ve složce nginx/ssl_docker.conf

Další integrace

A proč zároveň nevložit do skriptů nějaké CRM, říkali jsme si. Studovali jsme několik dalších CRM API, zejména bezplatnou vestavěnou PBX - ShugarCRM a Vtiger, a ano! ano, princip je stejný. Ale to je jiný příběh, který později nahrajeme na github samostatně.

reference

Upozornění: Jakákoli podobnost s realitou je fiktivní a nebyl jsem to já.

Zdroj: www.habr.com

Přidat komentář