Skip to content

Commit

Permalink
Add phone number formatter
Browse files Browse the repository at this point in the history
  • Loading branch information
Neur0toxine authored Dec 24, 2024
2 parents 5f4534e + cc638da commit b529c4f
Show file tree
Hide file tree
Showing 4 changed files with 365 additions and 0 deletions.
218 changes: 218 additions & 0 deletions core/util/phone.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
package util

import (
"errors"
"fmt"
"regexp"
"slices"
"strconv"
"strings"

phoneiso3166 "github.com/onlinecity/go-phone-iso3166"
pn "github.com/ttacon/libphonenumber"
)

const (
MinPhoneSymbolCount = 5
CountryPhoneCodeDE = 49
CountryPhoneCodeAG = 54
CountryPhoneCodeMX = 52
CountryPhoneCodeUS = "1443"
CountryPhoneCodePS = 970
CountryPhoneCodeUZ = 998
PalestineRegion = "PS"
BangladeshRegion = "BD"
)

var (
ErrPhoneTooShort = errors.New("phone is too short - must be at least 5 symbols")
ErrCannotDetermineCountry = errors.New("cannot determine phone country code")
ErrCannotParsePhone = errors.New("cannot parse phone number")

TrimmedPhoneRegexp = regexp.MustCompile(`\D+`)
UndefinedUSCodes = []string{"1445", "1945", "1840", "1448", "1279", "1839"}
)

// FormatNumberForWA forms in the format according to the rules https://faq.whatsapp.com/1294841057948784
func FormatNumberForWA(number string) (string, error) {
parsedPhone, err := ParsePhone(number)

if err != nil {
return "", err
}

var formattedPhoneNumber string
switch parsedPhone.GetCountryCode() {
case CountryPhoneCodeAG:
formattedPhoneNumber = Add9AGIFNeed(parsedPhone)
default:
formattedPhoneNumber = pn.Format(parsedPhone, pn.E164)
}

return formattedPhoneNumber, nil
}

// ParsePhone this function parses the number as a string
// For Mexican numbers `1` is always added to the national number because it is always removed during parsing.
// Attention when formatted in libphonenumber.INTERNATIONAL 1 will not be after the country code, even though
// it is in the national number.
// But for Argentine numbers there is no automatic addition 9 to the country code.
func ParsePhone(phoneNumber string) (*pn.PhoneNumber, error) {
trimmedPhone := TrimmedPhoneRegexp.ReplaceAllString(phoneNumber, "")
if len(trimmedPhone) < MinPhoneSymbolCount {
return nil, ErrPhoneTooShort
}

countryCode := getCountryCode(trimmedPhone)
if countryCode == "" {
return nil, ErrCannotDetermineCountry
}

parsedPhone, err := pn.Parse(trimmedPhone, countryCode)
if err != nil {
return nil, ErrCannotParsePhone
}

if CountryPhoneCodeDE == parsedPhone.GetCountryCode() {
number, err := getGermanNationalNumber(trimmedPhone, parsedPhone)
if err != nil {
return nil, err
}

parsedPhone.NationalNumber = &number
}

if CountryPhoneCodeUZ == parsedPhone.GetCountryCode() {
number, err := getUzbekistanNationalNumber(trimmedPhone, parsedPhone)
if err != nil {
return nil, err
}

parsedPhone.NationalNumber = &number
}

if IsMexicoNumber(parsedPhone) {
number, err := getMexicanNationalNumber(parsedPhone)
if err != nil {
return nil, err
}

parsedPhone.NationalNumber = &number
}

return parsedPhone, err
}

func IsRussianNumberWith8Prefix(phone string) bool {
return strings.HasPrefix(phone, "8") && len(phone) == 11 && phoneiso3166.E164.LookupString("7"+phone[1:]) == "RU"
}

func IsMexicoNumber(parsed *pn.PhoneNumber) bool {
return parsed.GetCountryCode() == CountryPhoneCodeMX
}

func IsUSNumber(phone string) bool {
return slices.Contains(UndefinedUSCodes, phone[:4]) &&
phoneiso3166.E164.LookupString(CountryPhoneCodeUS+phone[4:]) == "US"
}

func IsPLNumber(phone string) bool {
num, err := pn.Parse(phone, "PS")
return err == nil && num.GetCountryCode() == CountryPhoneCodePS && fmt.Sprintf("%d", CountryPhoneCodePS) == phone[0:3]
}

func Remove9AGIfNeed(parsedPhone *pn.PhoneNumber) string {
formattedPhone := pn.Format(parsedPhone, pn.E164)
numberWOCountry := fmt.Sprintf("%d", parsedPhone.GetNationalNumber())

if len(numberWOCountry) == 11 && string(numberWOCountry[0]) == "9" {
formattedPhone = fmt.Sprintf("+%d%s", CountryPhoneCodeAG, numberWOCountry[1:])
}

return formattedPhone
}

func Add9AGIFNeed(parsedPhone *pn.PhoneNumber) string {
formattedPhone := pn.Format(parsedPhone, pn.E164)
numberWOCountry := fmt.Sprintf("%d", parsedPhone.GetNationalNumber())

if len(numberWOCountry) == 10 { // nolint:mnd
formattedPhone = fmt.Sprintf("+%d%s", CountryPhoneCodeAG, "9"+numberWOCountry)
}

return formattedPhone
}

// getGermanNationalNumber some German numbers may not be parsed correctly.
// For example, for 491736276098 libphonenumber.PhoneNumber.NationalNumber
// will contain the country code(49). This function fix it and return correct libphonenumber.PhoneNumber.
func getGermanNationalNumber(phone string, parsedPhone *pn.PhoneNumber) (uint64, error) {
result := parsedPhone.GetNationalNumber()

if len(fmt.Sprintf("%d", parsedPhone.GetNationalNumber())) == len(phone) {
deduplicateCountryNumber := fmt.Sprintf("%d", parsedPhone.GetNationalNumber())[2:]

number, err := strconv.Atoi(deduplicateCountryNumber)
if err != nil {
return 0, err
}

result = uint64(number) //nolint:gosec
}

return result, nil
}

// For UZ numbers where 8 is deleted after the country code.
func getUzbekistanNationalNumber(phone string, parsedPhone *pn.PhoneNumber) (uint64, error) {
result := parsedPhone.GetNationalNumber()
numberWithEight := fmt.Sprintf("8%d", parsedPhone.GetNationalNumber())

if len(fmt.Sprintf("%d%s", parsedPhone.GetCountryCode(), numberWithEight)) == len(phone) {
number, err := strconv.Atoi(numberWithEight)
if err != nil {
return 0, err
}

result = uint64(number) //nolint:gosec
}

return result, nil
}

func getMexicanNationalNumber(parsedPhone *pn.PhoneNumber) (uint64, error) {
phoneWithDigit := fmt.Sprintf("1%d", parsedPhone.GetNationalNumber())

num, err := strconv.Atoi(phoneWithDigit)

if err != nil {
return 0, err
}

return uint64(num), nil //nolint:gosec
}

func getCountryCode(phone string) string {
countryCode := phoneiso3166.E164.LookupString(phone)

if countryCode == "" {
if IsRussianNumberWith8Prefix(phone) {
countryCode = phoneiso3166.E164.LookupString("7" + phone[1:])
}

if IsUSNumber(phone) {
countryCode = phoneiso3166.E164.LookupString(CountryPhoneCodeUS + phone[4:])
}

if IsPLNumber(phone) {
countryCode = PalestineRegion
}
}

// For russian numbers as 8800xxxxxxx
if strings.EqualFold(BangladeshRegion, countryCode) && IsRussianNumberWith8Prefix(phone) {
countryCode = phoneiso3166.E164.LookupString("7" + phone[1:])
}

return countryCode
}
126 changes: 126 additions & 0 deletions core/util/phone_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
package util

import (
"testing"

"github.com/stretchr/testify/require"

"github.com/stretchr/testify/assert"
)

func TestParsePhone(t *testing.T) {
t.Run("russian numers", func(t *testing.T) {
n := "+88002541213"
pn, err := ParsePhone(n)
require.NoError(t, err)
assert.Equal(t, uint64(8002541213), pn.GetNationalNumber())
assert.Equal(t, int32(7), pn.GetCountryCode())

n = "+78002541213"
pn, err = ParsePhone(n)
require.NoError(t, err)
assert.NotNil(t, pn)
assert.Equal(t, uint64(8002541213), pn.GetNationalNumber())
assert.Equal(t, int32(7), pn.GetCountryCode())

n = "89521548787"
pn, err = ParsePhone(n)
require.NoError(t, err)
assert.Equal(t, uint64(9521548787), pn.GetNationalNumber())
assert.Equal(t, int32(7), pn.GetCountryCode())

n = "+7-900-123-45-67"
pn, err = ParsePhone(n)
require.NoError(t, err)
assert.Equal(t, uint64(9001234567), pn.GetNationalNumber())
assert.Equal(t, int32(7), pn.GetCountryCode())

})

t.Run("german numbers", func(t *testing.T) {
n := "491736276098"
pn, err := ParsePhone(n)
require.NoError(t, err)
assert.Equal(t, uint64(1736276098), pn.GetNationalNumber())
assert.Equal(t, int32(CountryPhoneCodeDE), pn.GetCountryCode())

n = "4915229457499"
pn, err = ParsePhone(n)
require.NoError(t, err)
assert.Equal(t, uint64(15229457499), pn.GetNationalNumber())
assert.Equal(t, int32(CountryPhoneCodeDE), pn.GetCountryCode())
})

t.Run("mexican number", func(t *testing.T) {
n := "5219982418333"
pn, err := ParsePhone(n)
require.NoError(t, err)
assert.Equal(t, uint64(19982418333), pn.GetNationalNumber())
assert.Equal(t, int32(CountryPhoneCodeMX), pn.GetCountryCode())

n = "+521 (998) 241 83 33"
pn, err = ParsePhone(n)
require.NoError(t, err)
assert.Equal(t, uint64(19982418333), pn.GetNationalNumber())
assert.Equal(t, int32(CountryPhoneCodeMX), pn.GetCountryCode())

n = "529982418333"
pn, err = ParsePhone(n)
require.NoError(t, err)
assert.Equal(t, uint64(19982418333), pn.GetNationalNumber())
assert.Equal(t, int32(CountryPhoneCodeMX), pn.GetCountryCode())
})

t.Run("palestine number", func(t *testing.T) {
n := "970567800663"
pn, err := ParsePhone(n)
require.NoError(t, err)
assert.Equal(t, uint64(567800663), pn.GetNationalNumber())
assert.Equal(t, int32(CountryPhoneCodePS), pn.GetCountryCode())
})

t.Run("argentine number", func(t *testing.T) {
n := "5491131157821"
pn, err := ParsePhone(n)
require.NoError(t, err)
assert.Equal(t, uint64(91131157821), pn.GetNationalNumber())
assert.Equal(t, int32(CountryPhoneCodeAG), pn.GetCountryCode())
})

t.Run("uzbekistan number", func(t *testing.T) {
n := "998882207724"
pn, err := ParsePhone(n)
require.NoError(t, err)
assert.Equal(t, uint64(882207724), pn.GetNationalNumber())
assert.Equal(t, int32(CountryPhoneCodeUZ), pn.GetCountryCode())
})
}

func TestFormatNumberForWA(t *testing.T) {
numbers := map[string]string{
"79040000000": "+79040000000",
"491736276098": "+491736276098",
"89185553535": "+79185553535",
"4915229457499": "+4915229457499",
"5491131157821": "+5491131157821",
"541131157821": "+5491131157821",
"5219982418333": "+5219982418333",
"529982418333": "+5219982418333",
"14452385043": "+14452385043",
"19452090748": "+19452090748",
"19453003681": "+19453003681",
"19452141217": "+19452141217",
"18407778097": "+18407778097",
"14482074337": "+14482074337",
"18406665259": "+18406665259",
"19455009160": "+19455009160",
"19452381431": "+19452381431",
"12793006305": "+12793006305",
}

for orig, expected := range numbers {
actual, err := FormatNumberForWA(orig)
require.NoError(t, err)
require.Equal(t, expected, actual)
}
}
6 changes: 6 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,12 @@ require (
github.com/jessevdk/go-flags v1.6.1
github.com/jinzhu/gorm v1.9.11
github.com/nicksnyder/go-i18n/v2 v2.4.1
github.com/onlinecity/go-phone-iso3166 v0.0.1
github.com/retailcrm/api-client-go/v2 v2.1.17
github.com/retailcrm/mg-transport-api-client-go v1.3.19
github.com/retailcrm/zabbix-metrics-collector v1.0.0
github.com/stretchr/testify v1.10.0
github.com/ttacon/libphonenumber v1.2.1
go.uber.org/atomic v1.11.0
go.uber.org/zap v1.27.0
golang.org/x/text v0.21.0
Expand Down Expand Up @@ -62,8 +64,11 @@ require (
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-sql-driver/mysql v1.5.0 // indirect
github.com/golang/protobuf v1.5.0 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 // indirect
github.com/hashicorp/go-immutable-radix v1.1.0 // indirect
github.com/hashicorp/golang-lru v0.5.1 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
Expand All @@ -77,6 +82,7 @@ require (
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/ttacon/builder v0.0.0-20170518171403-c099f663e1c2 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
go.uber.org/multierr v1.10.0 // indirect
Expand Down
Loading

0 comments on commit b529c4f

Please sign in to comment.