diff --git a/src/backend/core/api/__init__.py b/src/backend/core/api/__init__.py index b212c5ad..5cf1c671 100644 --- a/src/backend/core/api/__init__.py +++ b/src/backend/core/api/__init__.py @@ -8,6 +8,8 @@ from rest_framework import views as drf_views from rest_framework.decorators import api_view from rest_framework.response import Response +from core.utils import build_telephony_config + def exception_handler(exc, context): """Handle Django ValidationError as an accepted exception. @@ -58,13 +60,7 @@ def get_frontend_configuration(request): "allowed_mimetypes" ], }, - "telephony": { - "enabled": settings.ROOM_TELEPHONY_ENABLED, - "phone_number": settings.ROOM_TELEPHONY_PHONE_NUMBER - if settings.ROOM_TELEPHONY_ENABLED - else None, - "default_country": settings.ROOM_TELEPHONY_DEFAULT_COUNTRY, - }, + "telephony": build_telephony_config(), "subtitle": {"enabled": settings.ROOM_SUBTITLE_ENABLED}, "livekit": { "url": settings.LIVEKIT_CONFIGURATION["url"], diff --git a/src/backend/core/tests/test_utils_build_telephony_config.py b/src/backend/core/tests/test_utils_build_telephony_config.py new file mode 100644 index 00000000..25c15784 --- /dev/null +++ b/src/backend/core/tests/test_utils_build_telephony_config.py @@ -0,0 +1,58 @@ +""" +Test utils.build_telephony_config +""" + +import logging + +from core.utils import build_telephony_config + + +def test_build_telephony_config_disabled(settings): + """Returns {"enabled": False} when telephony is disabled.""" + settings.ROOM_TELEPHONY_ENABLED = False + config = build_telephony_config() + assert config == {"enabled": False} + + +def test_build_telephony_config_enabled_with_valid_number(settings): + """Returns full config with country and international number when telephony is enabled.""" + settings.ROOM_TELEPHONY_ENABLED = True + settings.ROOM_TELEPHONY_PHONE_NUMBER = "0123456789" + settings.ROOM_TELEPHONY_DEFAULT_COUNTRY = "FR" + config = build_telephony_config() + assert config == { + "enabled": True, + "default_country": "FR", + "international_phone_number": "+33 1 23 45 67 89", + } + + +def test_build_telephony_config_enabled_with_invalid_number(settings): + """Returns {"enabled": False} when phone number cannot be parsed.""" + settings.ROOM_TELEPHONY_ENABLED = True + settings.ROOM_TELEPHONY_PHONE_NUMBER = "not-a-number" + settings.ROOM_TELEPHONY_DEFAULT_COUNTRY = "FR" + config = build_telephony_config() + assert config == {"enabled": False} + + +def test_build_telephony_config_enabled_with_missing_number(settings): + """Returns {"enabled": False} when phone number is not configured.""" + settings.ROOM_TELEPHONY_ENABLED = True + settings.ROOM_TELEPHONY_PHONE_NUMBER = "" + settings.ROOM_TELEPHONY_DEFAULT_COUNTRY = "FR" + config = build_telephony_config() + assert config == {"enabled": False} + + +def test_build_telephony_config_enabled_with_missing_number_warns(settings, caplog): + """Logs a warning when telephony is enabled but phone number is not configured.""" + + settings.ROOM_TELEPHONY_ENABLED = True + settings.ROOM_TELEPHONY_PHONE_NUMBER = "" + settings.ROOM_TELEPHONY_DEFAULT_COUNTRY = "FR" + + with caplog.at_level(logging.WARNING): + build_telephony_config() + + assert "ROOM_TELEPHONY_PHONE_NUMBER" in caplog.text diff --git a/src/backend/core/tests/test_utils_format_telephony_phone_number.py b/src/backend/core/tests/test_utils_format_telephony_phone_number.py new file mode 100644 index 00000000..f5f34491 --- /dev/null +++ b/src/backend/core/tests/test_utils_format_telephony_phone_number.py @@ -0,0 +1,102 @@ +""" +Test utils._format_telephony_phone_number +""" + +import logging + +import pytest + +from core.utils import _format_telephony_phone_number + + +@pytest.fixture(autouse=True) +def clear_lru_cache(): + """Clear the lru_cache before each test to ensure isolation.""" + _format_telephony_phone_number.cache_clear() + yield + _format_telephony_phone_number.cache_clear() + + +def test_format_telephony_phone_number_missing_raw_number(): + """Returns (None, None) when raw_number is empty.""" + country, international = _format_telephony_phone_number("", "FR") + assert country is None + assert international is None + + +def test_format_telephony_phone_number_none_raw_number(): + """Returns (None, None) when raw_number is None.""" + country, international = _format_telephony_phone_number(None, "FR") + assert country is None + assert international is None + + +def test_format_telephony_phone_number_missing_default_country(): + """Returns (None, None) when default_country is empty.""" + country, international = _format_telephony_phone_number("+33123456789", "") + assert country is None + assert international is None + + +def test_format_telephony_phone_number_none_default_country(): + """Returns (None, None) when default_country is None.""" + country, international = _format_telephony_phone_number("+33123456789", None) + assert country is None + assert international is None + + +def test_format_telephony_phone_number_both_missing(): + """Returns (None, None) when both inputs are missing.""" + country, international = _format_telephony_phone_number(None, None) + assert country is None + assert international is None + + +def test_format_telephony_phone_number_invalid_number(caplog): + """Returns (None, None) and logs a warning when the number cannot be parsed.""" + + with caplog.at_level(logging.WARNING): + country, international = _format_telephony_phone_number("not-a-number", "FR") + + assert country is None + assert international is None + assert "not-a-number" in caplog.text + assert "FR" in caplog.text + + +def test_format_telephony_phone_number_valid_french_number(): + """Returns correct country and international format for a valid French number.""" + country, international = _format_telephony_phone_number("0123456789", "FR") + assert country == "FR" + assert international == "+33 1 23 45 67 89" + + +def test_format_telephony_phone_number_valid_e164_number(): + """Returns correct result for an E.164-formatted number (no default country needed).""" + country, international = _format_telephony_phone_number("+33123456789", "US") + assert country == "FR" + assert international == "+33 1 23 45 67 89" + + +def test_format_telephony_phone_number_valid_us_number(): + """Returns correct country and international format for a valid US number.""" + country, international = _format_telephony_phone_number("2025550123", "US") + assert country == "US" + assert international == "+1 202-555-0123" + + +def test_format_telephony_phone_number_valid_german_number(): + """Returns correct country and international format for a valid German number.""" + country, international = _format_telephony_phone_number("03012345678", "DE") + assert country == "DE" + assert international == "+49 30 12345678" + + +def test_format_telephony_phone_number_lru_cache(): + """Results are cached: the same inputs return the same object.""" + result1 = _format_telephony_phone_number("0123456789", "FR") + result2 = _format_telephony_phone_number("0123456789", "FR") + assert result1 is result2 + # pylint: disable=no-value-for-parameter + cache_info = _format_telephony_phone_number.cache_info() + assert cache_info.hits >= 1 diff --git a/src/backend/core/utils.py b/src/backend/core/utils.py index cbacbe1e..39135b37 100644 --- a/src/backend/core/utils.py +++ b/src/backend/core/utils.py @@ -12,6 +12,7 @@ import mimetypes import random import secrets import string +from functools import lru_cache from typing import List, Optional from uuid import uuid4 @@ -22,6 +23,7 @@ import aiohttp import boto3 import botocore import magic +import phonenumbers from asgiref.sync import async_to_sync from livekit.api import ( # pylint: disable=E0611 AccessToken, @@ -455,3 +457,58 @@ def generate_upload_policy(file): ) return policy + + +@lru_cache(maxsize=1) +def _format_telephony_phone_number(raw_number, default_country): + """Parse a configured phone number and return (country, international_format). + + Returns (None, None) if the inputs are missing or the number cannot be + parsed. Logs a warning on parse failure so operators see the misconfiguration. + """ + if not raw_number or not default_country: + return None, None + + try: + parsed = phonenumbers.parse(raw_number, default_country) + except phonenumbers.NumberParseException: + logger.warning( + "ROOM_TELEPHONY_PHONE_NUMBER %r is not a valid phone number for " + "default country %r; telephony block will be returned without " + "formatted number.", + raw_number, + default_country, + ) + return None, None + + country = phonenumbers.region_code_for_number(parsed) + international = phonenumbers.format_number( + parsed, phonenumbers.PhoneNumberFormat.INTERNATIONAL + ) + return country, international + + +def build_telephony_config(): + """Build the telephony block of the frontend configuration.""" + if not settings.ROOM_TELEPHONY_ENABLED: + return {"enabled": False} + + country, international = _format_telephony_phone_number( + settings.ROOM_TELEPHONY_PHONE_NUMBER, + settings.ROOM_TELEPHONY_DEFAULT_COUNTRY, + ) + + if international is None: + logger.warning( + "Telephony is enabled but ROOM_TELEPHONY_PHONE_NUMBER %r with " + "default country %r could not be formatted; telephony will be disabled.", + settings.ROOM_TELEPHONY_PHONE_NUMBER, + settings.ROOM_TELEPHONY_DEFAULT_COUNTRY, + ) + return {"enabled": False} + + return { + "enabled": True, + "default_country": country, + "international_phone_number": international, + } diff --git a/src/backend/pyproject.toml b/src/backend/pyproject.toml index 01b4a3aa..4695a025 100644 --- a/src/backend/pyproject.toml +++ b/src/backend/pyproject.toml @@ -62,6 +62,7 @@ dependencies = [ "livekit-api==1.1.0", "aiohttp==3.13.4", "urllib3==2.7.0", + "phonenumbers==9.0.30", ] [project.urls] diff --git a/src/backend/uv.lock b/src/backend/uv.lock index 9ce7c36e..46619093 100644 --- a/src/backend/uv.lock +++ b/src/backend/uv.lock @@ -1204,6 +1204,7 @@ dependencies = [ { name = "markdown" }, { name = "mozilla-django-oidc" }, { name = "nested-multipart-parser" }, + { name = "phonenumbers" }, { name = "psycopg", extra = ["binary"] }, { name = "pydantic" }, { name = "pyjwt" }, @@ -1266,6 +1267,7 @@ requires-dist = [ { name = "markdown", specifier = "==3.10.2" }, { name = "mozilla-django-oidc", specifier = "==5.0.2" }, { name = "nested-multipart-parser", specifier = "==1.6.0" }, + { name = "phonenumbers", specifier = "==9.0.30" }, { name = "psycopg", extras = ["binary"], specifier = "==3.3.3" }, { name = "pydantic", specifier = "==2.12.5" }, { name = "pyjwt", specifier = "==2.12.1" }, @@ -1430,6 +1432,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" }, ] +[[package]] +name = "phonenumbers" +version = "9.0.30" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/f1/249f843f4107c6a6ed17e5ece17620d75e532c2a355106e26d889a0c72c7/phonenumbers-9.0.30.tar.gz", hash = "sha256:d42d232ccde69c1af1bb5916a7e46f4edbcc72975b02759830f4ea1fba7b00c9", size = 2306521, upload-time = "2026-05-07T10:20:38.884Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/22/e4442aabea04daf16fda50d89bce2ff585e44f204089986b2cc6679cae10/phonenumbers-9.0.30-py2.py3-none-any.whl", hash = "sha256:e0890d4cda206ef6ac18ef07e8f3ab225c31c7edce237ac870b4729d4c1d2520", size = 2595222, upload-time = "2026-05-07T10:20:35.387Z" }, +] + [[package]] name = "pillow" version = "12.2.0" diff --git a/src/frontend/package-lock.json b/src/frontend/package-lock.json index 4bfd7275..8f6d7ff5 100644 --- a/src/frontend/package-lock.json +++ b/src/frontend/package-lock.json @@ -30,7 +30,6 @@ "i18next-browser-languagedetector": "8.2.1", "i18next-parser": "9.3.0", "i18next-resources-to-backend": "1.2.1", - "libphonenumber-js": "1.12.10", "livekit-client": "2.17.1", "posthog-js": "1.342.1", "react": "18.3.1", @@ -8760,12 +8759,6 @@ "node": ">= 0.8.0" } }, - "node_modules/libphonenumber-js": { - "version": "1.12.10", - "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.12.10.tgz", - "integrity": "sha512-E91vHJD61jekHHR/RF/E83T/CMoaLXT7cwYA75T4gim4FZjnM6hbJjVIGg7chqlSqRsSvQ3izGmOjHy1SQzcGQ==", - "license": "MIT" - }, "node_modules/lightningcss": { "version": "1.31.1", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz", diff --git a/src/frontend/package.json b/src/frontend/package.json index a0295847..993753f6 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -36,7 +36,6 @@ "i18next-browser-languagedetector": "8.2.1", "i18next-parser": "9.3.0", "i18next-resources-to-backend": "1.2.1", - "libphonenumber-js": "1.12.10", "livekit-client": "2.17.1", "posthog-js": "1.342.1", "react": "18.3.1", diff --git a/src/frontend/src/api/useConfig.ts b/src/frontend/src/api/useConfig.ts index 81007ddb..e7c84e67 100644 --- a/src/frontend/src/api/useConfig.ts +++ b/src/frontend/src/api/useConfig.ts @@ -44,7 +44,7 @@ export interface ApiConfig { } telephony: { enabled: boolean - phone_number?: string + international_phone_number?: string default_country?: string } manifest_link?: string diff --git a/src/frontend/src/features/rooms/livekit/hooks/useTelephony.ts b/src/frontend/src/features/rooms/livekit/hooks/useTelephony.ts index b570e17e..4ced7636 100644 --- a/src/frontend/src/features/rooms/livekit/hooks/useTelephony.ts +++ b/src/frontend/src/features/rooms/livekit/hooks/useTelephony.ts @@ -1,20 +1,17 @@ -import { useConfig } from '@/api/useConfig.ts' -import { useMemo } from 'react' -import { parseConfigPhoneNumber } from '../../utils/telephony' +import { useConfig } from '@/api/useConfig' export const useTelephony = () => { const { data } = useConfig() - const parsedPhoneNumber = useMemo(() => { - return parseConfigPhoneNumber( - data?.telephony?.phone_number, - data?.telephony?.default_country - ) - }, [data?.telephony]) + if (!data?.telephony?.enabled) { + return { + enabled: false, + } + } return { - enabled: data?.telephony?.enabled && parsedPhoneNumber, - country: parsedPhoneNumber?.country, - internationalPhoneNumber: parsedPhoneNumber?.formatInternational(), + enabled: data?.telephony?.enabled, + country: data?.telephony.default_country, + internationalPhoneNumber: data?.telephony.international_phone_number, } } diff --git a/src/frontend/src/features/rooms/utils/telephony.ts b/src/frontend/src/features/rooms/utils/telephony.ts index 4a5ee4bb..48ced8ab 100644 --- a/src/frontend/src/features/rooms/utils/telephony.ts +++ b/src/frontend/src/features/rooms/utils/telephony.ts @@ -1,23 +1,3 @@ -import { CountryCode, parsePhoneNumberWithError } from 'libphonenumber-js' - -export const parseConfigPhoneNumber = ( - rawPhoneNumber?: string, - defaultCountry?: string -) => { - if (!rawPhoneNumber || !defaultCountry) { - return null - } - try { - return parsePhoneNumberWithError( - rawPhoneNumber, - defaultCountry as CountryCode - ) - } catch (error) { - console.warn('Invalid phone number format:', rawPhoneNumber, error) - return null - } -} - export function formatPinCode(pinCode?: string) { return pinCode && `${pinCode.replace(/(\d{3})(\d{3})(\d{4})/, '$1 $2 $3')}#` }