Skip to content

Commit

Permalink
Merge pull request #119 from nyaruka/urns_refactor
Browse files Browse the repository at this point in the history
Refactor `urns` package
  • Loading branch information
rowanseymour authored May 7, 2024
2 parents b40a071 + cd756a1 commit 637081f
Show file tree
Hide file tree
Showing 7 changed files with 461 additions and 663 deletions.
67 changes: 67 additions & 0 deletions urns/phone.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package urns

import (
"regexp"
"strconv"
"strings"

"github.com/nyaruka/gocommon/i18n"
"github.com/nyaruka/phonenumbers"
"github.com/pkg/errors"
)

var nonTelCharsRegex = regexp.MustCompile(`[^0-9A-Za-z]`)

// ParsePhone returns a validated phone URN. If it can parse a possible number then that is used.. otherwise any value
// that validates as a phone URN is used.
func ParsePhone(raw string, country i18n.Country) (URN, error) {
// strip all non-tel characters.. only preserving an optional leading +
raw = strings.TrimSpace(raw)
hasPlus := strings.HasPrefix(raw, "+")
raw = nonTelCharsRegex.ReplaceAllString(raw, "")
if hasPlus {
raw = "+" + raw
}

number, err := parsePhoneOrShortcode(raw, country)
if err != nil {
if err == phonenumbers.ErrInvalidCountryCode {
return NilURN, errors.New("invalid country code")
}

return NewFromParts(Phone, raw, "", "")
}

return NewFromParts(Phone, number, "", "")
}

// tries to extract a valid phone number or shortcode from the given string
func parsePhoneOrShortcode(raw string, country i18n.Country) (string, error) {
parsed, err := phonenumbers.Parse(raw, string(country))
if err != nil {
return "", err
}

if phonenumbers.IsPossibleNumberWithReason(parsed) == phonenumbers.IS_POSSIBLE {
return phonenumbers.Format(parsed, phonenumbers.E164), nil
}

if phonenumbers.IsPossibleShortNumberForRegion(parsed, string(country)) {
return phonenumbers.Format(parsed, phonenumbers.NATIONAL), nil
}

return "", errors.New("unable to parse phone number or shortcode")
}

// ToLocalPhone converts a phone URN to a local phone number.. without any leading zeros. Kinda weird but used by
// Courier where channels want the number in that format.
func ToLocalPhone(u URN, country i18n.Country) string {
_, path, _, _ := u.ToParts()

parsed, err := phonenumbers.Parse(path, string(country))
if err != nil {
return path
}

return strconv.FormatUint(parsed.GetNationalNumber(), 10)
}
90 changes: 90 additions & 0 deletions urns/phone_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package urns

import (
"testing"

"github.com/nyaruka/gocommon/i18n"
"github.com/stretchr/testify/assert"
)

func TestParsePhone(t *testing.T) {
testCases := []struct {
input string
country i18n.Country
expected URN
}{
{" 0788383383 ", "RW", "tel:+250788383383"},
{"+250788383383 ", "RW", "tel:+250788383383"}, // already has country code and leading +
{"250788383383 ", "RW", "tel:+250788383383"}, // already has country code and no leading +
{"+250788383383 ", "KE", "tel:+250788383383"}, // already has a different country code
{"(917)992-5253", "US", "tel:+19179925253"},
{"800-CABBAGE", "US", "tel:+18002222243"},
{"+62877747666", "ID", "tel:+62877747666"},
{"0877747666", "ID", "tel:+62877747666"},
{"07531669965", "GB", "tel:+447531669965"},
{"263780821000", "ZW", "tel:+263780821000"},

{"1", "RW", "tel:1"},
{"123456", "RW", "tel:123456"},
{"mtn", "RW", "tel:mtn"},
{"!mtn!", "RW", "tel:mtn"}, // non tel chars stripped

{"0788383383", "ZZ", NilURN}, // invalid country code
{"1234567890123456789012345678901234567890123456789012345678901234567890123456789", "RW", NilURN}, // too long
}

for i, tc := range testCases {
urn, err := ParsePhone(tc.input, tc.country)

if tc.expected == NilURN {
assert.Error(t, err, "%d: expected error for %s, %s", i, tc.input, tc.country)
} else {
assert.NoError(t, err, "%d: unexpected error for %s, %s", i, tc.input, tc.country)
assert.Equal(t, tc.expected, urn, "%d: created URN mismatch for %s, %s", i, tc.input, tc.country)
}
}
}

func TestParsePhoneOrShortcode(t *testing.T) {
tcs := []struct {
input string
country i18n.Country
expected string
}{
{"+250788123123", "", "+250788123123"}, // international number fine without country
{"+250 788 123-123", "", "+250788123123"}, // still fine if not E164 formatted

{"0788123123", "RW", "+250788123123"}, // country code added
{" (206)555-1212 ", "US", "+12065551212"}, // punctiation removed
{"800-CABBAGE", "US", "+18002222243"}, // letters converted to numbers
{"12065551212", "US", "+12065551212"}, // country code but no +
{"10000", "US", "10000"}, // valid short code for US

{"5912705", "US", ""}, // is only possible as a local number so ignored
}

for _, tc := range tcs {
parsed, err := parsePhoneOrShortcode(tc.input, tc.country)

if tc.expected != "" {
assert.NoError(t, err, "unexpected error for '%s'", tc.input)
assert.Equal(t, tc.expected, parsed, "result mismatch for '%s'", tc.input)
} else {
assert.Error(t, err, "expected error for '%s'", tc.input)
}
}
}
func TestToLocalPhone(t *testing.T) {
tcs := []struct {
urn URN
country i18n.Country
expected string
}{
{"tel:+250788123123", "", "788123123"},
{"tel:123123", "", "123123"},
}

for _, tc := range tcs {
assert.Equal(t, tc.expected, ToLocalPhone(tc.urn, tc.country), "local mismatch for '%s'", tc.urn)
}
}
181 changes: 181 additions & 0 deletions urns/schemes.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
package urns

import (
"regexp"
"strings"

"github.com/nyaruka/phonenumbers"
)

var allDigitsRegex = regexp.MustCompile(`^[0-9]+$`)

var emailRegex = regexp.MustCompile(`^[^\s@]+@[^\s@]+$`)
var freshchatRegex = regexp.MustCompile(`^[0-9a-fA-F]{8}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{12}/[0-9a-fA-F]{8}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{12}$`)
var viberRegex = regexp.MustCompile(`^[a-zA-Z0-9_=/+]{1,24}$`)
var lineRegex = regexp.MustCompile(`^[a-zA-Z0-9_]{1,36}$`)
var telRegex = regexp.MustCompile(`^\+?[a-zA-Z0-9]{1,64}$`)
var twitterHandleRegex = regexp.MustCompile(`^[a-zA-Z0-9_]{1,15}$`)
var webchatRegex = regexp.MustCompile(`^[a-zA-Z0-9]{24}(:[^\s@]+@[^\s@]+)?$`)

const (
// FacebookRefPrefix is prefix used for facebook referral URNs
FacebookRefPrefix string = "ref:"
)

func init() {
register(Discord)
register(Email)
register(External)
register(Facebook)
register(Firebase)
register(FreshChat)
register(Instagram)
register(JioChat)
register(Line)
register(Phone)
register(RocketChat)
register(Slack)
register(Telegram)
register(Twitter)
register(TwitterID)
register(Viber)
register(VK)
register(WebChat)
register(WeChat)
register(WhatsApp)
}

var schemes = map[string]*Scheme{}
var schemePrefixes = []string{}

func register(s *Scheme) {
schemes[s.Prefix] = s
schemePrefixes = append(schemePrefixes, s.Prefix)
}

type Scheme struct {
Prefix string
Normalize func(string) string
Validate func(string) bool
Format func(string) string
}

var Discord = &Scheme{
Prefix: "discord",
Validate: func(path string) bool { return allDigitsRegex.MatchString(path) },
}

var Email = &Scheme{
Prefix: "mailto",
Normalize: func(path string) string { return strings.ToLower(path) },
Validate: func(path string) bool { return emailRegex.MatchString(path) },
}

var External = &Scheme{
Prefix: "ext",
}

var Facebook = &Scheme{
Prefix: "facebook",
Validate: func(path string) bool {
// we don't validate facebook refs since they come from the outside
if strings.HasPrefix(path, FacebookRefPrefix) {
return true
}
// otherwise, this should be an int
return allDigitsRegex.MatchString(path)
},
}

var Firebase = &Scheme{
Prefix: "fcm",
}

var FreshChat = &Scheme{
Prefix: "freshchat",
Validate: func(path string) bool { return freshchatRegex.MatchString(path) },
}

var Instagram = &Scheme{
Prefix: "instagram",
Validate: func(path string) bool { return allDigitsRegex.MatchString(path) },
}

var JioChat = &Scheme{
Prefix: "jiochat",
Validate: func(path string) bool { return allDigitsRegex.MatchString(path) },
}

var Line = &Scheme{
Prefix: "line",
Validate: func(path string) bool { return lineRegex.MatchString(path) },
}

var Phone = &Scheme{
Prefix: "tel",
Normalize: func(path string) string {
// might have alpha characters in it
return strings.ToUpper(path)
},
Validate: func(path string) bool { return telRegex.MatchString(path) },
Format: func(path string) string {
parsed, err := phonenumbers.Parse(path, "")
if err != nil {
return path
}
return phonenumbers.Format(parsed, phonenumbers.NATIONAL)
},
}

var RocketChat = &Scheme{
Prefix: "rocketchat",
}

var Slack = &Scheme{
Prefix: "slack",
}

var Telegram = &Scheme{
Prefix: "telegram",
Validate: func(path string) bool { return allDigitsRegex.MatchString(path) },
}

var Twitter = &Scheme{
Prefix: "twitter",
Normalize: func(path string) string {
// handles are case-insensitive, so we always store as lowercase
path = strings.ToLower(path)

// strip @ prefix if provided
return strings.TrimPrefix(path, "@")
},
Validate: func(path string) bool { return twitterHandleRegex.MatchString(path) },
}

var TwitterID = &Scheme{
Prefix: "twitterid",
Validate: func(path string) bool { return allDigitsRegex.MatchString(path) },
}

var Viber = &Scheme{
Prefix: "viber",
Validate: func(path string) bool { return viberRegex.MatchString(path) },
}

var VK = &Scheme{
Prefix: "vk",
}

var WebChat = &Scheme{
Prefix: "webchat",
Validate: func(path string) bool { return webchatRegex.MatchString(path) },
}

var WeChat = &Scheme{
Prefix: "wechat",
}

var WhatsApp = &Scheme{
Prefix: "whatsapp",
Validate: func(path string) bool { return allDigitsRegex.MatchString(path) },
}
Loading

0 comments on commit 637081f

Please sign in to comment.