diff --git a/src/sipmessage/uri.py b/src/sipmessage/uri.py index f622987..1475ca2 100644 --- a/src/sipmessage/uri.py +++ b/src/sipmessage/uri.py @@ -5,12 +5,22 @@ import dataclasses import re +import string import urllib.parse from . import grammar from .parameters import Parameters -URI_PATTERN = re.compile( +C_VISUAL_SEPARATOR = "-.()" + +PHONEDIGIT = grammar.cset(string.digits + C_VISUAL_SEPARATOR) +PHONEDIGIT_HEX = grammar.cset(string.hexdigits + "*#" + C_VISUAL_SEPARATOR) +GLOBAL_NUMBER_DIGITS = f"\\+{PHONEDIGIT}*{grammar.DIGIT}{PHONEDIGIT}*" +LOCAL_NUMBER_DIGITS = ( + f"{PHONEDIGIT_HEX}*{grammar.cset(string.hexdigits + '*#')}{PHONEDIGIT_HEX}*" +) + +SIP_URI_PATTERN = re.compile( "^" "(?Psip|sips):" f"(?:(?P{grammar.USER})(?::(?P{grammar.PASSWORD}))?@)?" @@ -19,16 +29,23 @@ f"(?P(?:;{grammar.URI_PARAM})*)" "$" ) +TEL_URI_PATTERN = re.compile( + "^" + "(?Ptel):" + f"(?P(?:{GLOBAL_NUMBER_DIGITS}|{LOCAL_NUMBER_DIGITS}))" + f"(?P(?:;{grammar.URI_PARAM})*)" + "$" +) @dataclasses.dataclass(frozen=True) class URI: """ - A SIP or SIPS URL as described by RFC3261. + A SIP, SIPS or TEL URI as described by RFC3261 and RFC3966. """ scheme: str - "The URL scheme specifier." + "The URI scheme specifier." host: str "The host providing the SIP resource." @@ -52,24 +69,32 @@ def parse(cls, value: str) -> "URI": If parsing fails, a :class:`ValueError` is raised. """ - m = URI_PATTERN.match(value) - if not m: + if m := SIP_URI_PATTERN.match(value): + port = m.group("port") + user = m.group("user") + password = m.group("password") + parameters = m.group("parameters") + + return cls( + scheme=m.group("scheme"), + host=m.group("host"), + port=int(port) if port else None, + user=urllib.parse.unquote(user) if user else None, + password=urllib.parse.unquote(password) if password else None, + parameters=Parameters.parse(parameters), + ) + elif m := TEL_URI_PATTERN.match(value): + parameters = m.group("parameters") + + return cls( + scheme=m.group("scheme"), + host="", + user=m.group("subscriber"), + parameters=Parameters.parse(parameters), + ) + else: raise ValueError("URI is not valid") - port = m.group("port") - user = m.group("user") - password = m.group("password") - parameters = m.group("parameters") - - return cls( - scheme=m.group("scheme"), - host=m.group("host"), - port=int(port) if port else None, - user=urllib.parse.unquote(user) if user else None, - password=urllib.parse.unquote(password) if password else None, - parameters=Parameters.parse(parameters), - ) - @property def global_phone_number(self) -> str | None: """ @@ -80,25 +105,31 @@ def global_phone_number(self) -> str | None: > userinfo = user | telephone-subscriber > telephone-subscriber = global-phone-number | local-phone-number + + The phone number is returned without any visual separators. """ if self.user is None: return None - if self.user[0] == "+" and self.user[1:].isnumeric(): - return self.user + if re.match(GLOBAL_NUMBER_DIGITS, self.user): + return re.sub(grammar.cset(C_VISUAL_SEPARATOR), "", self.user) else: return None def __str__(self) -> str: s = self.scheme + ":" - if self.user is not None: - s += urllib.parse.quote(self.user, safe=grammar.C_USER_SAFE) - if self.password is not None: - s += ":" + urllib.parse.quote( - self.password, safe=grammar.C_PASSWORD_SAFE - ) - s += "@" - s += self.host - if self.port is not None: - s += f":{self.port}" + if self.scheme == "tel": + assert self.user is not None + s += self.user + else: + if self.user is not None: + s += urllib.parse.quote(self.user, safe=grammar.C_USER_SAFE) + if self.password is not None: + s += ":" + urllib.parse.quote( + self.password, safe=grammar.C_PASSWORD_SAFE + ) + s += "@" + s += self.host + if self.port is not None: + s += f":{self.port}" s += str(self.parameters) return s diff --git a/tests/test_uri.py b/tests/test_uri.py index c69f59d..ac0bd8e 100644 --- a/tests/test_uri.py +++ b/tests/test_uri.py @@ -166,6 +166,7 @@ def test_password(self) -> None: self.assertEqual(uri.password, "secret") self.assertEqual(uri.parameters, {}) + self.assertEqual(uri.global_phone_number, None) self.assertEqual(str(uri), "sip:alice:secret@atlanta.com") def test_password_escaped(self) -> None: @@ -178,6 +179,7 @@ def test_password_escaped(self) -> None: self.assertEqual(uri.password, "sips:user@example.com") self.assertEqual(uri.parameters, {}) + self.assertEqual(uri.global_phone_number, None) self.assertEqual(str(uri), "sip:alice:sips%3Auser%40example.com@atlanta.com") def test_rfc4475_wide_range_of_valid_characters(self) -> None: @@ -189,7 +191,42 @@ def test_rfc4475_wide_range_of_valid_characters(self) -> None: self.assertEqual(uri.user, "1_unusual.URI~(to-be!sure)&isn't+it$/crazy?,/;;*") self.assertEqual(uri.password, "&it+has=1,weird!*pas$wo~d_too.(doesn't-it)") self.assertEqual(uri.parameters, {}) + + self.assertEqual(uri.global_phone_number, None) self.assertEqual( str(uri), "sip:1_unusual.URI~(to-be!sure)&isn't+it$/crazy?,/;;*:&it+has=1,weird!*pas$wo~d_too.(doesn't-it)@example.com", ) + + def test_tel_global_phone_number(self) -> None: + uri = URI.parse("tel:+12015550123") + self.assertEqual(uri.host, "") + self.assertEqual(uri.user, "+12015550123") + self.assertEqual(uri.password, None) + self.assertEqual(uri.parameters, {}) + + self.assertEqual(uri.global_phone_number, "+12015550123") + self.assertEqual(str(uri), "tel:+12015550123") + + def test_tel_uri_global_phone_number_with_visual_separators(self) -> None: + uri = URI.parse("tel:+1-201-555-0123") + self.assertEqual(uri.host, "") + self.assertEqual(uri.user, "+1-201-555-0123") + self.assertEqual(uri.password, None) + self.assertEqual(uri.parameters, {}) + + self.assertEqual(uri.global_phone_number, "+12015550123") + self.assertEqual(str(uri), "tel:+1-201-555-0123") + + def test_tel_uri_local_phone_number(self) -> None: + uri = URI.parse("tel:7042;phone-context=example.com") + self.assertEqual(uri.host, "") + self.assertEqual(uri.user, "7042") + self.assertEqual(uri.password, None) + self.assertEqual( + uri.parameters, + {"phone-context": "example.com"}, + ) + + self.assertEqual(uri.global_phone_number, None) + self.assertEqual(str(uri), "tel:7042;phone-context=example.com")