Разбирање на FreePBX и негово интегрирање со Bitrix24 и повеќе

Битрикс24 е огромен комбинат кој комбинира CRM, тек на работа, сметководство и многу други работи што менаџерите навистина ги сакаат, а персоналот за ИТ не ги сака. Порталот го користат многу мали и средни компании, вклучувајќи мали клиники, производители, па дури и салони за убавина. Главната функција што менаџерите ја „сакаат“ е интеграцијата на телефонијата и CRM, кога секој повик веднаш се снима во CRM, се креираат клиентски картички, при дојдовни се прикажуваат информации за клиентот и веднаш можете да видите кој е тој, што може да продаде и колку должи. Но, телефонијата од Bitrix24 и нејзината интеграција со CRM чини пари, понекогаш и многу. Во написот ќе ви го кажам искуството за интегрирање со отворени алатки и популарната IP PBX freepbx, а исто така разгледајте ја логиката на работата на различни делови

Работам како аутсорсер во компанија која продава и конфигурира, интегрира IP телефонија. Кога ме прашаа дали можеме да понудиме нешто на оваа и оваа компанија за интегрирање на Bitrix24 со PBX што ги имаат клиентите, како и со виртуелни PBX на различни VDS компании, отидов на Google. И секако ми даде линк до статија во Хабр, каде што има опис, и github, и се чини дека сè функционира. Но, кога се обидувавте да го користите ова решение, се покажа дека Bitrix24 повеќе не е исто како порано и треба многу да се преработи. Покрај тоа, FreePBX не е гола ѕвездичка за вас, тука треба да размислите како да ги комбинирате леснотијата на користење и хардкор планот за бирање во конфигурациските датотеки.

Ја проучуваме логиката на работата

Значи, за почеток, како сето тоа треба да функционира. Кога ќе се прими повик однадвор во PBX (настан SIP INVITE од давателот), започнува обработката на планот за бирање (план за бирање, план за бирање) - правилата што и по кој редослед да се прави со повикот. Од првиот пакет, можете да добиете многу информации, кои потоа може да се користат во правилата. Одлична алатка за проучување на внатрешните работи на SIP е анализаторот sngrep (линк) кој едноставно се инсталира во популарните дистрибуции преку apt install/yum install и слично, но може да се изгради и од извор. Ајде да го погледнеме дневникот за повици во sngrep

Разбирање на FreePBX и негово интегрирање со Bitrix24 и повеќе

Во поедноставена форма, dialplan се занимава само со првиот пакет, понекогаш и во текот на разговорот, се префрлаат повици, притискање на копчињата (DTMF), разни интересни работи како FollowMe, RingGroup, IVR и други.

Што има внатре во пакетот покани

Разбирање на FreePBX и негово интегрирање со Bitrix24 и повеќе

Всушност, повеќето едноставни планови за бирање работат со првите две полиња, а целата логика се врти околу DID и CallerID. DID - каде што се јавуваме, CallerID - кој се јавува.

Но, на крајот на краиштата, ние имаме компанија, а не еден телефон - што значи дека PBX најверојатно има групи за повици (симултано / последователно ѕвонење на неколку уреди) на броеви на градови (Ring Group), IVR (Здраво, се јавивте ... Притиснете еден за ...), Телефонски машини ( фрази), временски услови, препраќање до други броеви или во ќелија (FollowMe, Forward). Тоа значи дека е многу тешко недвосмислено да се одреди кој всушност ќе добие повик и со кого ќе разговара кога ќе пристигне повик. Еве пример за почеток на типичен повик во PBX на нашите клиенти

Разбирање на FreePBX и негово интегрирање со Bitrix24 и повеќе

Откако повикот успешно ќе влезе во PBX, тој патува низ планот за бирање во различни „контексти“. Контекстот од гледна точка на ѕвездичка е нумериран сет на команди, од кои секоја содржи филтер според бираниот број (тој се нарекува exten, за надворешен повик во почетната фаза exten=DID). Командите во линијата за dialplan може да бидат какви било - внатрешни функции (на пример, повикајте внатрешен претплатник - Dial(), спушти го телефонот - Hangup()), условни оператори (IF, ELSE, ExecIF и слично), транзиции кон други правила од овој контекст (Goto, GotoIF), премин во други контексти во форма на повик на функција (Gosub, Macro). Посебна директива include имя_контекста, кој додава команди од друг контекст до крајот на тековниот контекст. Наредбите вклучени преку вклучува секогаш се извршуваат по команди од тековниот контекст.

Целата логика на FreePBX е изградена на вклучување на различни контексти еден во друг преку вклучување и повикување преку управувачите на Gosub, Macro и Handler. Размислете за контекстот на дојдовните повици FreePBX

Разбирање на FreePBX и негово интегрирање со Bitrix24 и повеќе

Повикот поминува низ сите контексти од врвот до дното за возврат, во секој контекст може да има повици до други контексти како макроа (Macro), функции (Gosub) или само транзиции (Goto), така што вистинското дрво на она што се нарекува може само да се следи во дневниците.

Типичен дијаграм за поставување за типичен PBX е прикажан подолу. Кога повикувате, DID се пребарува во дојдовните маршрути, се проверуваат привремени услови за тоа, ако сè е во ред, гласовното мени се активира. Од него, со притискање на копчето 1 или истекување, излезете во групата на оператори за бирање. По завршувањето на повикот, се повикува макрото за хангап повик, по што ништо не може да се направи во планот за бирање, освен специјалните управувачи (ракувач на хангап).

Разбирање на FreePBX и негово интегрирање со Bitrix24 и повеќе

Каде во овој алгоритам за повик треба да обезбедиме информации за почетокот на повикот до CRM, каде да започне снимањето, каде да го заврши снимањето и да го испрати заедно со информации за повикот до CRM?

Интеграција со надворешни системи

Што е PBX и CRM интеграција? Тоа се поставки и програми кои конвертираат податоци и настани помеѓу овие две платформи и ги испраќаат една до друга. Највообичаен начин за независни системи да комуницираат е преку API, а најпопуларниот начин за пристап до API е HTTP REST. Но, не за ѕвездичка.

Внатре во ѕвездичка е:

  • AGI - синхрони повик до надворешни програми / компоненти, што се користи главно во планот за бирање, има библиотеки како phpagi, ПАГИ

  • AMI - текстуален TCP приклучок што работи на принцип на претплата на настани и внесување текстуални команди, наликува на SMTP одвнатре, може да следи настани и да управува со повици, има библиотека ПАМИ - најпопуларен за создавање врска со Asterisk

Пример за излез на AMI

Настан: Нов канал
Привилегија: јавете се, сите
Канал: PJSIP/VMS_pjsip-0000078b
Состојба на каналот: 4
ChannelStateDesc: Прстен
ID на повикувачот: 111222
Име на повикувач: 111222
ConnectedLineNum:
поврзано име на линија:
Јазик: en
код на сметката:
Контекст: од-pstn
Прошири: с
Приоритет: 1
Единствен: 1599589046.5244
Linkedid: 1599589046.5244

  • ARI е мешавина од двете, сите преку REST, WebSocket, во JSON формат - но со свежи библиотеки и обвивки, не многу добри, ненамерно пронајдени (phparia, phpari) кои станаа во нивниот развој пред околу 3 години.

Пример за излез ARI кога се иницира повик

{ "променлива":"CallMeCallerIDName", "вредност":"111222", "тип":"ChannelVarset", "временски печат":"2020-09-09T09:38:36.269+0000", "канал":{ "ID »:»1599644315.5334″, «име»:»PJSIP/VMSpjsip-000007b6″, "state":"Ring", " caller":{ "name":"111222", "number":"111222" }, "connected":{ "name":"", "number" :"" }, "код на сметка":"", "dialplan":{ "context":"from-pstn", "exten":"s", "приоритет":2, "appиме":"Стаза", "апdata":"hello-world" }, "creationtime":"2020-09-09T09:38:35.926+0000", "language":"en" }, "ѕвездичкаid":"48:5б:аа:аа:аа:аа", "апликација":"здраво-свет" }

Практичноста или непријатноста, можноста или неможноста за работа со одреден API се одредуваат според задачите што треба да се решат. Задачите за интеграција со CRM се како што следува:

  • Следете го почетокот на повикот, каде е префрлен, извлечете го CallerID, DID, времето на започнување и завршување, можеби податоци од директориумот (за да пребарувате врска помеѓу телефонот и корисникот на CRM)

  • Започнете и завршете го снимањето на повикот, зачувајте го во саканиот формат, на крајот од снимањето информирајте каде се наоѓа датотеката

  • Започнете повик на надворешен настан (од програмата), повикајте внатрешен број, надворешен број и поврзете ги

  • Изборен: интегрирајте се со CRM, групи за бирачи и FollowME за автоматско пренесување на повици во отсуство на место (според CRM)

Сите овие задачи може да се решат преку AMI или ARI, но ARI обезбедува многу помалку информации, нема многу настани, многу променливи што AMI сè уште ги има (на пример, макро повици, поставување променливи внатре во макроата, вклучително и снимање повици) не се следат. Затоа, за правилно и прецизно следење, да го избереме АМИ засега (но не целосно). Покрај тоа (добро, каде би било без ова, ние сме мрзливи луѓе) - во оригиналното дело (статија во Хабр) користете PAMI. *Потоа треба да се обидете да препишете на ARI, но не и фактот дека тоа ќе работи.

Повторно откривање на интеграцијата

За да може нашиот FreePBX да известува до AMI на едноставни начини за почетокот на повикот, времето на завршување, броевите, имињата на снимените датотеки, најлесно е да се пресмета времетраењето на повикот користејќи го истиот трик како и оригиналните автори. - внесете ги вашите променливи и анализирајте го излезот за нивното присуство. PAMI предлага да го направите ова едноставно преку функцијата за филтрирање.

Еве пример за поставување на сопствена променлива за времето на започнување на повикот (s е посебен број во планот за бирање што се изведува ПРЕД да започне пребарувањето DID)

[ext-did-custom]

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

Пример настан на AMI за оваа линија

Настан: Нов канал

Привилегија: јавете се, сите

Канал: PJSIP/VMS_pjsip-0000078b

Состојба на каналот: 4

ChannelStateDesc: Прстен

ID на повикувачот: 111222

Име на повикувач: 111222

ConnectedLineNum:

поврзано име на линија:

Јазик: en

код на сметката:

Контекст: од-pstn

Прошири: с

Приоритет: 1

Единствен: 1599589046.5244

Linkedid: 1599589046.5244

Апликација: Поставете AppData:

CallStart=1599571046

Бидејќи FreePBX ги препишува датотеките extension.conf и extension_extra.conf, ќе ја користиме датотеката проширување_обичај.конф

Целосен код на 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

Карактеристика и разлика од оригиналниот бирање план на авторите на оригиналната статија -

  • Dialplan во .conf формат, како што сака FreePBX (да, може .ael, но не сите верзии и не е секогаш погодно)

  • Наместо да се обработува крајот преку exten=>h, обработката беше воведена преку hangup_handler, бидејќи FreePBX dialplan работеше само со него

  • Фиксна низа за повици на скрипта, додадени наводници и надворешен број за повик ExtNum

  • Обработката е преместена во _custom контексти и ви овозможува да не допирате или уредувате FreePBX конфигурации - дојдовни преку [ext-do-custom], појдовни преку [outbound-allroutes-custom]

  • Нема врзување за броеви - датотеката е универзална и треба само да се конфигурира за патеката и врската до серверот

За да започнете, исто така треба да извршите скрипти во AMI со најава и лозинка - за ова, FreePBX има и _custom датотека

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

И двете од овие датотеки мора да се стават во /etc/asterisk, а потоа повторно да ги прочитате конфигурациите (или да ја рестартирате ѕвездичката)

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

Сега да преминеме на PHP

Иницијализирање скрипти и создавање услуга

Бидејќи шемата за работа со Bitrix 24, услуга за AMI, не е сосема едноставна и транспарентна, мора да се дискутира посебно. Ѕвездичка, кога е активирана AMI, едноставно го отвора портот и тоа е тоа. Кога клиентот се приклучува, тој бара овластување, а потоа клиентот се претплати на потребните настани. Настаните доаѓаат во обичен текст, кој PAMI го претвора во структурирани објекти и обезбедува можност за поставување на функцијата за филтрирање само за настани од интерес, полиња, броеви итн.

Штом ќе дојде повикот, настанот NewExten се активира почнувајќи од родителскиот контекст [from-pstn], потоа сите настани одат по редоследот на линиите во контекстите. Кога се добиваат информации од променливите CallMeCallerIDName и CallStart наведени во _custom dialplan,

  1. Функцијата за барање UserID што одговара на бројот на екстензијата каде што дојде повикот. Што ако се работи за dial-up група? Прашањето е политичко, дали треба да креирате повик до сите одеднаш (кога сите се јавуваат одеднаш) или да креирате како што викаат кога се јавуваат по ред? Повеќето клиенти ја имаат стратегијата Fisrt Available, така што нема проблем со ова, само еден повик. Но, прашањето треба да се реши.

  2. Функцијата за регистрација на повици во Bitrix24, која го враќа CallID, од кој потоа се бара да ги пријави параметрите на повикот и врска до снимката. Потребен е или број на екстензија или кориснички ID

Разбирање на FreePBX и негово интегрирање со Bitrix24 и повеќе

По завршувањето на повикот, се повикува функцијата за преземање записи, која истовремено го известува статусот на завршување на повикот (Зафатен, Без одговор, Успешно), а исто така презема врска до mp3-датотеката со записот (ако има).

Бидејќи модулот CallMeIn.php треба да работи постојано, за него е креирана датотека за стартување SystemD повикај ме.услуга, кој мора да се стави во /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

иницијализацијата и стартувањето на скриптата се случува преку systemctl или услуга

# systemctl enable callme
# systemctl start callme

Услугата ќе се рестартира сама по потреба (во случај на падови). Услугата за следење сандаче не бара инсталирање на веб-сервер, потребен е само php (кој дефинитивно е на серверот FeePBX). Но, во отсуство на пристап до записите за повици преку веб-серверот (исто така со https), нема да може да се слушаат записи за повици.

Сега да разговараме за појдовните повици. Скриптата CallMeOut.php има две функции:

  • Иницирање на повик кога е примено барање за php скрипта (вклучувајќи го и користењето на копчето „Повик“ во самиот Bitrix). Не работи без веб-сервер, барањето се прима преку HTTP POST, барањето содржи токен

  • Порака за повикот, неговите параметри и записи во Bitrix. Пуштено од ѕвездичка во планот за бирање [под-повик-внатрешно завршување] кога ќе заврши повикот

Разбирање на FreePBX и негово интегрирање со Bitrix24 и повеќе

Веб-серверот е потребен само за две работи - преземање датотеки за снимање Bitrix (преку HTTPS) и повикување на скриптата CallMeOut.php. Можете да го користите вградениот сервер FreePBX, датотеките за кои се /var/www/html, можете да инсталирате друг сервер или да наведете друга патека.

Веб сервер

Да го оставиме поставувањето на веб-серверот за независно проучување (tyts, tyts, tyts). Ако немате домен, можете да пробате FreeDomain( https://www.freenom.com/ru/index.html), што ќе ви даде бесплатно име за вашата бела IP адреса (не заборавајте да ги препраќате портите 80, 443 преку рутерот ако надворешната адреса е само на неа). Ако штотуку креиравте DNS домен, тогаш треба да почекате (од 15 минути до 48 часа) додека не се вчитаат сите сервери. Според искуството за работа со домашни провајдери - од 1 час до ден.

Инсталациона автоматизација

На github е развиен инсталатер за да се олесни инсталацијата. Но, тоа беше мазно на хартија - додека сето тоа го инсталираме рачно, бидејќи откако се чепкаше со сето ова стана кристално јасно што е со кого пријатели, кој каде оди и како да го дебагира. Сè уште нема инсталатер

пристанишен работник

Ако сакате брзо да го пробате решението - постои опција со Docker - брзо креирајте контејнер, дајте му порти нанадвор, лизгајте ги датотеките за поставки и обидете се (ова е опцијата со контејнерот LetsEncrypt, ако веќе имате сертификат , само треба да го пренасочите обратниот прокси кон веб-серверот FreePBX (му дадовме друга порта е 88), LetsEncrypt во докер врз основа на Оваа статија

Треба да ја извршите датотеката во преземената папка на проектот (по git клон), но прво влезете во конфигурациите за ѕвездичка (папка со ѕвездичка) и напишете ги патеките до записите и URL-то на вашата страница таму

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:

Оваа датотека docker-compose.yaml се извршува преку

docker-compose up -d

Ако nginx не започне, тогаш нешто не е во ред со конфигурацијата во папката nginx/ssl_docker.conf

Други интеграции

И зошто да не ставиме CRM во скрипти во исто време, си помисливме. Проучивме неколку други CRM API, особено бесплатниот вграден PBX - ShugarCRM и Vtiger, и да! да, принципот е ист. Но, ова е друга приказна, која подоцна ќе ја прикачиме на github одделно.

референци

Одрекување: Секоја сличност со реалноста е фиктивна и тоа не бев јас.

Извор: www.habr.com

Додадете коментар