Bitrix-D7 23.9
 
Загрузка...
Поиск...
Не найдено
parser.php
1<?php
2
4
8
9class Parser
10{
11 const MAX_LENGTH_COUNTRY_CODE = 3; // The maximum length of the country calling code.
12 const MIN_LENGTH_FOR_NSN = 2; // The minimum length of the national significant number.
13 const MAX_LENGTH_FOR_NSN = 17; // The ITU says the maximum length should be 15, but one can find longer numbers in Germany.
14
15 /* We don't allow input strings for parsing to be longer than 250 chars. This prevents malicious input from consuming CPU.*/
17
18 protected $plusChar = '+';
19
20 /* Digits accepted in phone numbers (ascii, fullwidth, arabic-indic, and eastern arabic digits). */
21 protected $validDigits = '0-9';
22 protected $dashes = '-';
23 protected $slashes = '\/';
24 protected $dot = '.';
25 protected $whitespace = '\s';
26 protected $brackets = '()\\[\\]';
27 protected $tildes = '~';
28 protected $extensionSeparators = ';#';
29 protected $extensionSymbols = ',';
30
37
38 const DEFAULT_COUNTRY_OPTION = 'phone_number_default_country';
39
41 protected static $instance = null;
42
47 protected function __construct()
48 {
49 $this->phoneNumberStartPattern = '[' . $this->plusChar . $this->validDigits . ']';
50 $this->afterPhoneNumberEndPattern = '[^' . $this->validDigits . $this->extensionSeparators . $this->extensionSymbols . ']+$';
51 $this->minLengthPhoneNumberPattern = '[' . $this->validDigits . ']{' . static::MIN_LENGTH_FOR_NSN . '}';
52 $this->validPunctuation = $this->dashes . $this->slashes . $this->dot . $this->whitespace . $this->brackets . $this->tildes . $this->extensionSeparators . $this->extensionSymbols;
53 $this->validPhoneNumber =
54 '[' . $this->plusChar . ']{0,1}' .
55 '(?:' .
56 '[' . $this->validPunctuation . ']*' .
57 '[' . $this->validDigits . ']' .
58 '){3,}' .
59 '[' .
60 $this->validPunctuation .
61 $this->validDigits .
62 ']*';
63
64 $this->validPhoneNumberPattern =
65 '^(?:'.
66 // Either a short two-digit-only phone number
67 '^' . $this->minLengthPhoneNumberPattern .'$' .
68 // Or a longer fully parsed phone number (min 3 characters)
69 '|' . '^' . $this->validPhoneNumber . '$' .
70 ')$';
71
72 }
73
78 public static function getInstance()
79 {
80 if(is_null(static::$instance))
81 {
82 static::$instance = new static();
83 }
84
85 return static::$instance;
86 }
87
92 public static function getDefaultCountry()
93 {
94 $defaultCountryId = Option::get('main', static::DEFAULT_COUNTRY_OPTION);
95
96 if(!$defaultCountryId)
97 {
98 $detectedCountry = static::detectCountry();
99 $detectedCountryId = GetCountryIdByCode($detectedCountry);
100 if($detectedCountryId > 0)
101 {
102 Option::set('main', static::DEFAULT_COUNTRY_OPTION, $detectedCountryId);
103 $defaultCountryId = $detectedCountryId;
104 }
105 }
106
107 return $defaultCountryId ? GetCountryCodeById($defaultCountryId) : "";
108 }
109
110 public static function getUserDefaultCountry()
111 {
112 $userSettings = \CUserOptions::GetOption('main', 'phone_number');
113 return $userSettings['default_country'] ?? '';
114 }
115
120 public static function detectCountry()
121 {
122 if(Loader::includeModule('bitrix24'))
123 {
124 $defaultCountry = Option::get("bitrix24", "REG_COUNTRY", "");
125
126 if(!$defaultCountry)
127 {
128 $portalZone = \CBitrix24::getPortalZone();
129
130 if(in_array($portalZone, array('br', 'cn', 'de', 'in', 'ru', 'ua', 'by', 'kz', 'fr', 'pl')))
131 {
132 $defaultCountry = $portalZone;
133 }
134 }
135 }
136
137 if(!$defaultCountry)
138 {
139 $currentLanguage = Context::getCurrent()->getLanguage();
140 if(in_array($currentLanguage, array('br', 'cn', 'de', 'in', 'ru', 'ua', 'by', 'kz', 'fr', 'pl')))
141 {
142 $defaultCountry = $currentLanguage;
143 }
144 }
145
146 if(!$defaultCountry)
147 {
148 // last hope, let's try geoip
149 $defaultCountry = \Bitrix\Main\Service\GeoIp\Manager::getCountryCode();
150 }
151
152 return mb_strtoupper($defaultCountry);
153 }
154
159 public function getValidNumberPattern()
160 {
162 }
163
170 public function parse($phoneNumber, $defaultCountry = '')
171 {
172 if($defaultCountry == '')
173 {
174 $defaultCountry = static::getDefaultCountry();
175 }
176 $result = new PhoneNumber();
177 $result->setRawNumber($phoneNumber);
178
179 if(!$this->isViablePhoneNumber($phoneNumber))
180 {
181 return $result;
182 }
183 $formattedPhoneNumber = $this->extractFormattedPhoneNumber($phoneNumber);
184
185 list($extensionSeparator, $extension) = $this->stripExtension($formattedPhoneNumber);
186 $result->setNationalNumber($formattedPhoneNumber);
187 $result->setExtensionSeparator($extensionSeparator);
188 $result->setExtension($extension);
189
190 $parseResult = $this->parsePhoneNumberAndCountryPhoneCode($formattedPhoneNumber);
191 if($parseResult === false)
192 {
193 return $result;
194 }
195
196 $countryCode = $parseResult['countryCode'];
197 $localNumber = $parseResult['localNumber'];
198 $hasPlus = false;
199
200 if($countryCode)
201 {
202 // Number in international format, starting with '+', thus we ignore $country parameter
203 $isInternational = true;
204 $hasPlus = true;
205 $countryMetadata = $this->getMetadataByCountryCode($countryCode);
206 if(!$countryMetadata)
207 {
208 return $result;
209 }
210
211 /*
212 $country will be set later, because, for example, for NANPA countries
213 there are several countries corresponding to the same `1` country phone code.
214 Therefore, to reliably determine the exact country, national number should be parsed first.
215 */
216 $country = null;
217 }
218 else
219 {
220 // Number in national format or in international format without + sign.
221 $country = $defaultCountry;
222 $countryMetadata = $this->getCountryMetadata($country);
223 if(!$countryMetadata)
224 {
225 return $result;
226 }
227
228 $countryCode = $countryMetadata['countryCode'];
229 $isInternational = $this->stripCountryCode($localNumber, $countryMetadata);
230 }
231
232 $nationalPrefix = $this->stripNationalPrefix($localNumber, $countryMetadata);
233
234 // Sometimes there are several countries corresponding to the same country phone code (e.g. NANPA countries all
235 // having `1` country phone code). Therefore, to reliably determine the exact country, national (significant)
236 // number should have been parsed first.
237 if(!$country)
238 {
239 $country = $this->findCountry($countryCode, $localNumber);
240 if(!$country)
241 {
242 return $result;
243 }
244
245 $countryMetadata = $this->getCountryMetadata($country);
246 }
247
248 // Validate local (significant) number length
249 if(mb_strlen($localNumber) > static::MAX_LENGTH_FOR_NSN)
250 {
251 return $result;
252 }
253
254 $nationalNumberRegex = '/^(?:' . $countryMetadata['generalDesc']['nationalNumberPattern'] . ')$/';
255 if(!preg_match($nationalNumberRegex, $localNumber))
256 {
257 return $result;
258 }
259
260 $numberType = $this->getNumberType($localNumber, $country);
261 $result->setHasPlus($hasPlus);
262 $result->setCountry($country);
263 $result->setCountryCode($countryCode);
264 $result->setNumberType($numberType);
265 $result->setValid($numberType !== false);
266
267 if($result->isValid())
268 {
269 $result->setNationalNumber($localNumber);
270 $result->setInternational($isInternational);
271 $result->setNationalPrefix($nationalPrefix);
272 }
273
274 return $result;
275 }
276
282 public function stripExtension(&$phoneNumber)
283 {
284 $extension = "";
285 $extensionSeparator = "";
286
287 if(preg_match("/[" . $this->extensionSeparators ."]/", $phoneNumber, $matches, PREG_OFFSET_CAPTURE))
288 {
289 $extensionSeparator = $matches[0][0];
290 $separatorPosition = $matches[0][1];
291 $extension = mb_substr($phoneNumber, $separatorPosition + 1);
292 $phoneNumber = mb_substr($phoneNumber, 0, $separatorPosition);
293 }
294 return [$extensionSeparator, $extension];
295 }
296
302 protected function extractFormattedPhoneNumber($phoneNumber)
303 {
304 if (!$phoneNumber || mb_strlen($phoneNumber) > static::MAX_INPUT_STRING_LENGTH)
305 {
306 return '';
307 }
308
309 if(!preg_match('/'.$this->phoneNumberStartPattern.'/', $phoneNumber, $matches, PREG_OFFSET_CAPTURE))
310 {
311 return '';
312 }
313
314 // Attempt to extract a possible number from the string passed in
315 $startsAt = $matches[0][1];
316 if ($startsAt < 0)
317 {
318 return '';
319 }
320
321 $result = mb_substr($phoneNumber, $startsAt);
322 $result = preg_replace('/'.$this->afterPhoneNumberEndPattern.'/', '', $result);
323 return $result;
324 }
325
331 protected function isViablePhoneNumber($phoneNumber)
332 {
333 return mb_strlen($phoneNumber) >= static::MIN_LENGTH_FOR_NSN && preg_match('/'.$this->validPhoneNumberPattern.'/i', $phoneNumber);
334 }
335
341 protected function parsePhoneNumberAndCountryPhoneCode($phoneNumber)
342 {
343 $phoneNumber = $this->normalizePhoneNumber($phoneNumber);
344 if(!$phoneNumber)
345 return false;
346
347 // If this is not an international phone number,
348 // then don't extract country phone code.
349 if ($phoneNumber[0] !== $this->plusChar)
350 {
351 return array(
352 'countryCode' => '',
353 'localNumber' => $phoneNumber
354 );
355 }
356
357 // Strip the leading '+' sign
358 $phoneNumber = mb_substr($phoneNumber, 1);
359
360 // Fast abortion: country codes do not begin with a '0'
361 if ($phoneNumber[0] === '0')
362 {
363 return false;
364 }
365
366 for ($i = static::MAX_LENGTH_COUNTRY_CODE; $i > 0; $i--)
367 {
368 $countryCode = mb_substr($phoneNumber, 0, $i);
369 if(MetadataProvider::getInstance()->isValidCountryCode($countryCode))
370 {
371 return array(
372 'countryCode' => $countryCode,
373 'localNumber' => mb_substr($phoneNumber, $i)
374 );
375 }
376 }
377 return false;
378 }
379
385 protected function normalizePhoneNumber($phoneNumber)
386 {
387 if (!$phoneNumber)
388 return '';
389
390 $isInternational = mb_substr($phoneNumber, 0, 1) === $this->plusChar;
391
392 // Remove non-digits (and strip the possible leading '+')
393 $phoneNumber = static::stripLetters($phoneNumber);
394
395 if ($isInternational)
396 return $this->plusChar . $phoneNumber;
397 else
398 return $phoneNumber;
399 }
400
406 protected function getMetadataByCountryCode($countryCode)
407 {
408 if(!MetadataProvider::getInstance()->isValidCountryCode($countryCode))
409 {
410 return false;
411 }
412
413 $countries = MetadataProvider::getInstance()->getCountriesByCode($countryCode);
414 return $this->getCountryMetadata($countries[0]);
415 }
416
423 protected function findCountry($countryCode, $localNumber)
424 {
425 if(!$countryCode || !$localNumber)
426 return false;
427
428 $possibleCountries = MetadataProvider::getInstance()->getCountriesByCode($countryCode);
429 if(count($possibleCountries) === 1)
430 {
431 return $possibleCountries[0];
432 }
433
434 foreach($possibleCountries as $possibleCountry)
435 {
436 $countryMetadata = $this->getCountryMetadata($possibleCountry);
437
438 // Check leading digits first
439 if(isset($countryMetadata['leadingDigits']))
440 {
441 $leadingDigitsRegex = '/^('.$countryMetadata['leadingDigits'].')/';
442 if(preg_match($leadingDigitsRegex, $localNumber))
443 {
444 return $possibleCountry;
445 }
446 }
447 // Else perform full validation with all of those bulky fixed-line/mobile/etc regular expressions.
448 else if($this->getNumberType($localNumber, $possibleCountry))
449 {
450 return $possibleCountry;
451 }
452 }
453
454 return false;
455 }
456
463 protected function getNumberType($localNumber, $country)
464 {
465 // Check that the number is valid for this country
466 $countryMetadata = $this->getCountryMetadata($country);
467 if(!$countryMetadata)
468 return false;
469
470 if(isset($countryMetadata['generalDesc']['nationalNumberPattern']))
471 {
472 $nationalNumberRegex = '/^(?:' . $countryMetadata['generalDesc']['nationalNumberPattern'] . ')$/';
473 if(!preg_match($nationalNumberRegex, $localNumber))
474 return false;
475 }
476
477 $possibleTypes = array('noInternationalDialling', 'areaCodeOptional', 'fixedLine', 'mobile', 'pager', 'tollFree', 'premiumRate', 'sharedCost', 'personalNumber', 'voip', 'uan', 'voicemail');
478 foreach ($possibleTypes as $possibleType)
479 {
480 if(isset($countryMetadata[$possibleType]['nationalNumberPattern']))
481 {
482 // skip checking possible lengths for now
483
484 $numberTypeRegex = '/^' . $countryMetadata[$possibleType]['nationalNumberPattern'] . '$/';
485 if(preg_match($numberTypeRegex, $localNumber))
486 {
487 return $possibleType;
488 }
489 }
490 }
491 return false;
492 }
493
501 protected static function stripNationalPrefix(&$phoneNumber, $countryMetadata)
502 {
503 $nationalPrefixForParsing = $countryMetadata['nationalPrefixForParsing'] ?? ($countryMetadata['nationalPrefix'] ?? '');
504
505 if($phoneNumber == '' || $nationalPrefixForParsing == '')
506 return '';
507
508 $nationalPrefixRegex = '/^(?:' . $nationalPrefixForParsing . ')/';
509 if(!preg_match($nationalPrefixRegex, $phoneNumber, $nationalPrefixMatches))
510 {
511 //if national prefix is omitted, nothing to strip
512 return '';
513 }
514
515 $nationalPrefixTransformRule = $countryMetadata['nationalPrefixTransformRule'] ?? '';
516 if($nationalPrefixTransformRule && count($nationalPrefixMatches) > 1)
517 {
518 $nationalSignificantNumber = preg_replace($nationalPrefixRegex, $nationalPrefixTransformRule, $phoneNumber);
519 }
520 else
521 {
522 // No transformation is required, just strip the prefix
523 $nationalSignificantNumber = mb_substr($phoneNumber, mb_strlen($nationalPrefixMatches[0]));
524 }
525 $nationalPrefix = mb_substr($phoneNumber, 0, mb_strlen($phoneNumber) - mb_strlen($nationalSignificantNumber));
526
527 $nationalNumberRegex = '/^(?:' . $countryMetadata['generalDesc']['nationalNumberPattern'] . ')$/';
528 if(preg_match($nationalNumberRegex, $phoneNumber) && !preg_match($nationalNumberRegex, $nationalSignificantNumber))
529 {
530 /*
531 If the original number (before stripping national prefix) was viable, and the resultant number is not,
532 then prefer the original phone number. This is because for some countries (e.g. Russia) the same digit
533 could be both a national prefix and a leading digit of a valid national phone number, like `8` is the
534 national prefix for Russia and both `8 800 555 35 35` and `800 555 35 35` are valid numbers.
535 */
536 return '';
537 }
538
539 $phoneNumber = $nationalSignificantNumber;
540 return $nationalPrefix;
541 }
542
549 protected static function stripCountryCode(&$phoneNumber, $countryMetadata)
550 {
551 $countryCode = $countryMetadata['countryCode'];
552 if(mb_strpos($phoneNumber, $countryCode) !== 0)
553 return false;
554
555 $possibleLocalNumber = mb_substr($phoneNumber, mb_strlen($countryCode));
556 $nationalNumberRegex = '/^(?:' . $countryMetadata['generalDesc']['nationalNumberPattern'] . ')$/';
557
558 if(!preg_match($nationalNumberRegex, $phoneNumber) && preg_match($nationalNumberRegex, $possibleLocalNumber))
559 {
560 /*
561 If the original number (before stripping national prefix) was viable, and the resultant number is not,
562 then prefer the original phone number. This is because for some countries (e.g. Russia) the same digit
563 could be both a national prefix and a leading digit of a valid national phone number, like `8` is the
564 national prefix for Russia and both `8 800 555 35 35` and `800 555 35 35` are valid numbers.
565 */
566 $phoneNumber = $possibleLocalNumber;
567 return true;
568 }
569
570 return false;
571 }
572
573 protected function getCountriesByCode($countryCode)
574 {
575 return MetadataProvider::getInstance()->getCountriesByCode($countryCode);
576 }
577
578 protected function getCountryMetadata($country)
579 {
580 return MetadataProvider::getInstance()->getCountryMetadata($country);
581 }
582
588 protected static function stripLetters($str)
589 {
590 return preg_replace("/[^\d]/", "", $str);
591 }
592}
static getCurrent()
Definition context.php:241
static includeModule($moduleName)
Definition loader.php:69
stripExtension(&$phoneNumber)
Definition parser.php:282
extractFormattedPhoneNumber($phoneNumber)
Definition parser.php:302
parsePhoneNumberAndCountryPhoneCode($phoneNumber)
Definition parser.php:341
getMetadataByCountryCode($countryCode)
Definition parser.php:406
getCountriesByCode($countryCode)
Definition parser.php:573
findCountry($countryCode, $localNumber)
Definition parser.php:423
normalizePhoneNumber($phoneNumber)
Definition parser.php:385
parse($phoneNumber, $defaultCountry='')
Definition parser.php:170
getNumberType($localNumber, $country)
Definition parser.php:463
isViablePhoneNumber($phoneNumber)
Definition parser.php:331
static stripNationalPrefix(&$phoneNumber, $countryMetadata)
Definition parser.php:501
static stripCountryCode(&$phoneNumber, $countryMetadata)
Definition parser.php:549