self::MAX_DIGITS ? substr( $digits, -self::MAX_DIGITS ) : $digits; } /** * The trailing run that decides identity. The value an inbound number is looked up by. * * @param string $raw A number as somebody typed it. */ public static function tail( string $raw ): string { $digits = self::digits( $raw ); return strlen( $digits ) > self::COMPARE_DIGITS ? substr( $digits, -self::COMPARE_DIGITS ) : $digits; } /** * Are these two the same telephone? * * @param string $a One number, in any form. * @param string $b Another number, in any form. */ public static function same( string $a, string $b ): bool { $left = self::tail( $a ); $right = self::tail( $b ); // Two empties are not a match. Without this a record with a blank phone number matches every // inbound message, which is the failure mode where a stranger's reply lands on a real person. if ( '' === $left || '' === $right ) { return false; } return $left === $right; } /** * The E.164 form a gateway will accept, or null when the number cannot be addressed. * * RETURNING NULL IS THE POINT OF THIS METHOD. `07700 900123` is a complete, correct, dialable * number *if you already know which country it belongs to*, and meaningless otherwise — there is * no such thing as guessing it. A caller that receives null has been told to ask somebody, and * that is a better outcome than a text sent to the same digits in a different country. * * THIS NEVER REWRITES WHAT IS STORED. `phone_numbers` carries * `UNIQUE KEY link (parent_type,parent_id,number)`, so normalising the column in place would * collide the day one contact holds the same telephone in two notations — and the insert that * collides fails silently. The stored string stays as it was typed; this is the form used to * address a message, and it lives only as long as the request that sends it. * * @param string $raw A number as somebody typed it. * @param string $dialling_code The country's dialling code, for a number written nationally. * Digits, with or without a `+`. Empty when unknown. */ public static function to_e164( string $raw, string $dialling_code = '' ): ?string { $raw = trim( $raw ); if ( '' === $raw ) { return null; } // Anything after an extension marker belongs to a switchboard, not to the public number, and // a gateway cannot dial it. Dropping it is right; carrying it makes the whole number invalid. $trimmed = preg_replace( '/\s*(?:ext\.?|x|#)\s*[0-9]+\s*$/i', '', $raw ); $raw = is_string( $trimmed ) ? $trimmed : $raw; $digits = preg_replace( '/[^0-9]+/', '', $raw ); $digits = is_string( $digits ) ? $digits : ''; if ( '' === $digits ) { return null; } if ( str_starts_with( $raw, '+' ) ) { // Already international. Nothing to infer. return self::finish( $digits ); } // "00" and "011" are how the rest of the world and North America respectively write "+". // Both are international dialling, so the country is stated and no default is wanted. foreach ( array( '011', '00' ) as $access ) { if ( str_starts_with( $digits, $access ) ) { return self::finish( substr( $digits, strlen( $access ) ) ); } } $code = preg_replace( '/[^0-9]+/', '', $dialling_code ); if ( ! is_string( $code ) || '' === $code ) { // National notation and nobody has said which nation. Refuse rather than guess. return null; } // One leading zero is the trunk prefix, which an international number replaces rather than // keeps. Exactly one: "00" was handled above, and a number beginning "000" is not a number. if ( str_starts_with( $digits, '0' ) ) { $digits = substr( $digits, 1 ); } return self::finish( $code . $digits ); } /** * Length-check a finished digit string and put the `+` back on. * * @param string $digits Country code and subscriber number, digits only. */ private static function finish( string $digits ): ?string { $length = strlen( $digits ); if ( $length < self::MIN_DIGITS || $length > self::MAX_DIGITS ) { return null; } return '+' . $digits; } }