Skip to content

Commit

Permalink
Refactor urns package
Browse files Browse the repository at this point in the history
  • Loading branch information
rowanseymour committed May 6, 2024
1 parent 64e21d5 commit fb6f575
Show file tree
Hide file tree
Showing 7 changed files with 411 additions and 661 deletions.
49 changes: 49 additions & 0 deletions urns/phone.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package urns

import (
"strconv"
"strings"

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

// FromLocalPhone returns a validated tel URN
func FromLocalPhone(number string, country string) (URN, error) {
path, err := ParsePhone(number, country)
if err != nil {
return NilURN, err
}

return NewURNFromParts(Phone, path, "", "")
}

// ToLocalPhone converts a phone URN to a local number in the given country
func ToLocalPhone(u URN, country string) string {
_, path, _, _ := u.ToParts()

parsed, err := phonenumbers.Parse(path, country)
if err == nil {
return strconv.FormatUint(parsed.GetNationalNumber(), 10)
}
return path
}

// ParsePhone tries to parse the given string as a phone number and if successful returns it as E164
func ParsePhone(s, country string) (string, error) {
parsed, err := phonenumbers.Parse(s, country)
if err != nil {
return "", errors.Wrap(err, "unable to parse number")
}

if phonenumbers.IsPossibleNumberWithReason(parsed) != phonenumbers.IS_POSSIBLE {
// if it's not a possible number, try adding a + and parsing again
if !strings.HasPrefix(s, "+") {
return ParsePhone("+"+s, country)
}

return "", errors.New("not a possible number")
}

return phonenumbers.Format(parsed, phonenumbers.E164), nil
}
68 changes: 68 additions & 0 deletions urns/phone_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package urns_test

import (
"testing"

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

func TestFromLocalPhone(t *testing.T) {
testCases := []struct {
number string
country string
expected urns.URN
hasError bool
}{
{"tel:0788383383", "RW", "tel:+250788383383", false},
{"tel: +250788383383 ", "KE", "tel:+250788383383", false}, // already has country code
{"tel:(917)992-5253", "US", "tel:+19179925253", false},
{"tel:800-CABBAGE", "US", "tel:+18002222243", false},
{"tel:+62877747666", "ID", "tel:+62877747666", false},
{"tel:0877747666", "ID", "tel:+62877747666", false},
{"tel:07531669965", "GB", "tel:+447531669965", false},
{"tel:263780821000", "ZW", "tel:+263780821000", false},

{"0788383383", "ZZ", urns.NilURN, true}, // invalid country code
{"1", "RW", urns.NilURN, true},
{"123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890", "RW", urns.NilURN, true},
}

for i, tc := range testCases {
urn, err := urns.FromLocalPhone(tc.number, tc.country)

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

func TestParsePhone(t *testing.T) {
tcs := []struct {
input string
country string
parsed string
}{
{"+250788123123", "", "+250788123123"}, // international number fine without country
{"+250 788 123-123", "", "+250788123123"}, // fine if not E164 formatted
{"0788123123", "RW", "+250788123123"},
{"206 555 1212", "US", "+12065551212"},
{"12065551212", "US", "+12065551212"}, // country code but no +
{"5912705", "US", ""}, // is only possible as a local number so ignored
{"10000", "US", ""},
}

for _, tc := range tcs {
if tc.parsed != "" {
parsed, err := urns.ParsePhone(tc.input, tc.country)
assert.NoError(t, err, "unexpected error for '%s'", tc.input)
assert.Equal(t, parsed, tc.parsed, "result mismatch for '%s'", tc.input)
} else {
_, err := urns.ParsePhone(tc.input, tc.country)
assert.Error(t, err, "expected error for '%s'", tc.input)
}
}
}
185 changes: 185 additions & 0 deletions urns/schemes.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
package urns

import (
"regexp"
"strings"

"github.com/nyaruka/phonenumbers"
)

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

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{}

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

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 {
e164, err := ParsePhone(path, "")
if err != nil {
// could be a short code so uppercase and remove non alphanumeric characters
return nonTelCharsRegex.ReplaceAllString(strings.ToUpper(path), "")
}

return e164
},
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 fb6f575

Please sign in to comment.