diff --git a/README.md b/README.md index 189bb1e..32654a7 100644 --- a/README.md +++ b/README.md @@ -206,9 +206,11 @@ The adapter builds the following JSON from the decrypted delivery context and en } ``` -`phoneNumber` must match `^\+[1-9][0-9]{1,14}$`; the leading `+` is preserved. The complete -`message` is passed unchanged as `message.text`, including whitespace and OTP digit spacing. -Telesign performs text-to-speech for Voice; no separate speech object or OTP extraction is needed. +`phoneNumber` must match `^\+[1-9][0-9]{1,14}$`; the leading `+` is preserved. SMS passes the complete +`message` unchanged as `message.text`, including whitespace. For Voice, each six-digit numeric run +that is not part of a longer number is rendered with comma-separated digits, and the complete paced +message is sent twice with one separating space. Telesign performs text-to-speech for Voice; no +separate speech object is needed. A nonblank string `locale` becomes `message.language`; otherwise language is omitted. The envelope channel selects the single `sms` or `voice` entry. `correlation_id` uses a nonempty string request correlation ID, falling back to the message ID for absent, empty, or non-string values. Reserved diff --git a/docs/CONTRACT.md b/docs/CONTRACT.md index 05fa0bf..e29a002 100644 --- a/docs/CONTRACT.md +++ b/docs/CONTRACT.md @@ -76,7 +76,7 @@ runs once per language. Tag tampering and original-header-byte tests remain. |-------|----------|-------| | `nonce` | yes | value the endpoint MUST echo to prove decryption | | `phoneNumber` | yes | caller supplies an E.164 string; full E.164 validation is an implementation gap | -| `message` | yes | fully rendered, localized text containing the passcode; text-message adapters forward it unchanged, while Soprano voice extracts the first six consecutive digits | +| `message` | yes | fully rendered, localized text containing the passcode; text-message adapters forward it unchanged, Soprano voice extracts the first six consecutive digits, and Telesign voice paces standalone six-digit numeric runs and repeats the full message twice | | `extension` | no | office-voice contract field; not currently forwarded by the shared dispatch model | | `locale` | no | voice selection input where supported by the selected adapter | | `riskContext` | no | contextual request data; no risk-policy evaluation is implemented here | diff --git a/dotnet/README.md b/dotnet/README.md index eccb7d5..a9a4d4e 100644 --- a/dotnet/README.md +++ b/dotnet/README.md @@ -85,6 +85,9 @@ uses a nonblank SAS request locale as the language, falling back to `en-US`, and `1` and loop `2`. These values require no additional environment settings. Soprano SMS continues to forward the rendered message unchanged. +Telesign SMS also forwards the rendered message unchanged. Telesign voice comma-separates each +six-digit numeric run that is not part of a longer number and repeats the complete paced message twice. + ## Source | Source | Purpose | diff --git a/dotnet/Src/Providers/TelesignProvider.cs b/dotnet/Src/Providers/TelesignProvider.cs index 02e8f30..3f1aa40 100644 --- a/dotnet/Src/Providers/TelesignProvider.cs +++ b/dotnet/Src/Providers/TelesignProvider.cs @@ -6,6 +6,13 @@ namespace Epp.Otp.Providers; public sealed class TelesignProvider : IProviderAdapter { + private const string VoiceDigitSeparator = ", "; + private const int VoiceRepeatCount = 2; + private const string VoiceRepeatSeparator = " "; + private static readonly Regex VoicePasscodePattern = new( + @"(? { ["text"] = dispatch.Message }; + var messageText = channel == "voice" ? BuildVoiceMessage(dispatch.Message!) : dispatch.Message; + var message = new Dictionary { ["text"] = messageText }; if (!string.IsNullOrWhiteSpace(dispatch.Locale)) message["language"] = dispatch.Locale; var body = new { @@ -48,6 +56,14 @@ public ProviderHttpRequest BuildRequest(string channel, string endpoint, Dispatc return new ProviderHttpRequest(endpoint, "POST", headers, JsonSerializer.Serialize(body)); } + private static string BuildVoiceMessage(string message) + { + var pacedMessage = VoicePasscodePattern.Replace( + message, + match => string.Join(VoiceDigitSeparator, match.Value.ToCharArray())); + return string.Join(VoiceRepeatSeparator, Enumerable.Repeat(pacedMessage, VoiceRepeatCount)); + } + public ParsedResponse ParseResponse(int httpStatus, bool ok, JsonElement json) { string? refId = null, statusCode = "UNKNOWN", statusDesc = null; diff --git a/dotnet/tests/ContractTests.cs b/dotnet/tests/ContractTests.cs index 7ae317a..7746079 100644 --- a/dotnet/tests/ContractTests.cs +++ b/dotnet/tests/ContractTests.cs @@ -133,13 +133,34 @@ public void TelesignUsesEppJsonContract(string channel, string? locale) Assert.Equal("Basic " + Convert.ToBase64String(Encoding.UTF8.GetBytes("test-id:test-key")), request.Headers["Authorization"]); Assert.Equal("application/json", request.Headers["Content-Type"]); Assert.Equal("application/json", request.Headers["Accept"]); - var message = new Dictionary { ["text"] = dispatch.Message }; + var expectedText = channel == "voice" + ? " Your code is 9, 1, 8, 2, 7, 3.\nDo not share. " + + " Your code is 9, 1, 8, 2, 7, 3.\nDo not share. " + : dispatch.Message; + var message = new Dictionary { ["text"] = expectedText }; if (locale == "en") message["language"] = locale; var expected = new { recipient = new { phone_number = dispatch.Destination }, message, channels = new[] { new { channel } }, correlation_id = dispatch.CorrelationId }; Assert.Equal(JsonSerializer.Serialize(expected), request.Body); } + [Fact] + public void TelesignVoicePacesOnlySixDigitNumericRunsAndRepeatsMessage() + { + var dispatch = Request("voice") with { Message = "Code 001234; ref 1234567; alternate 654321." }; + var request = new TelesignProvider().BuildRequest( + "voice", + "https://verify.telesign.com/epp/voice", + dispatch, + new ProviderCredential("apiKey", "test-key", "test-id"), + new TestEnv()); + using var body = JsonDocument.Parse(request.Body); + Assert.Equal( + "Code 0, 0, 1, 2, 3, 4; ref 1234567; alternate 6, 5, 4, 3, 2, 1. " + + "Code 0, 0, 1, 2, 3, 4; ref 1234567; alternate 6, 5, 4, 3, 2, 1.", + body.RootElement.GetProperty("message").GetProperty("text").GetString()); + } + [Fact] public void TelesignValidatesRecipientAndFallsBackToMessageId() { diff --git a/javascript/README.md b/javascript/README.md index 5b56c75..92d4343 100644 --- a/javascript/README.md +++ b/javascript/README.md @@ -70,7 +70,9 @@ extracts the first six-digit passcode from the rendered message and sends fixed gender `1` and loop `2`. It uses a nonblank SAS request locale as the language, falling back to `en-US` when the locale is absent or invalid. These values require no additional environment settings. Soprano SMS continues to forward the rendered message unchanged. The Telesign adapter -uses the configured complete endpoint and API-key credentials from Key Vault. +uses the configured complete endpoint and API-key credentials from Key Vault. Telesign SMS forwards +the rendered message unchanged; Telesign voice comma-separates each six-digit numeric run that is +not part of a longer number and repeats the complete paced message twice. For Azure, set these application variables on the Function App/slot's **Environment variables → App settings** page and use a Key Vault reference for the private PEM. The provider-secret resolver uses diff --git a/javascript/src/functions/providers/telesign.js b/javascript/src/functions/providers/telesign.js index f50544f..129c9cc 100644 --- a/javascript/src/functions/providers/telesign.js +++ b/javascript/src/functions/providers/telesign.js @@ -6,6 +6,11 @@ const { ParsedResponse } = require('../models'); +const VOICE_PASSCODE_PATTERN = /(? [...passcode].join(VOICE_DIGIT_SEPARATOR), + ); + return Array(VOICE_REPEAT_COUNT).fill(pacedMessage).join(VOICE_REPEAT_SEPARATOR); +} + function buildRequest({ channel, endpoint, dispatch, credential }) { if (!['sms', 'voice'].includes(channel)) throw new Error('unsupported channel'); if (typeof dispatch.destination !== 'string' || !/^\+[1-9][0-9]{1,14}$/.test(dispatch.destination) @@ -38,7 +51,7 @@ function buildRequest({ channel, endpoint, dispatch, credential }) { const authorization = `Basic ${Buffer.from(`${credential.identity}:${credential.secret}`).toString('base64')}`; const correlationId = typeof dispatch.correlationId === 'string' && dispatch.correlationId ? dispatch.correlationId : dispatch.messageId; - const message = { text: dispatch.message }; + const message = { text: channel === 'voice' ? buildVoiceMessage(dispatch.message) : dispatch.message }; if (typeof dispatch.locale === 'string' && dispatch.locale.trim()) message.language = dispatch.locale; return { url: endpoint, diff --git a/javascript/test/dispatch.test.js b/javascript/test/dispatch.test.js index 81dfb4c..dfa0a47 100644 --- a/javascript/test/dispatch.test.js +++ b/javascript/test/dispatch.test.js @@ -152,9 +152,12 @@ test('Telesign EPP uses the selected endpoint with the same Basic-auth JSON cont assert.equal(request.method, 'POST'); assert.deepEqual(request.headers, { Authorization: `Basic ${Buffer.from('id:key').toString('base64')}`, 'Content-Type': 'application/json', Accept: 'application/json' }); + const expectedText = channel === 'voice' + ? ' Your code is 9, 1, 8, 2, 7, 3.\n Your code is 9, 1, 8, 2, 7, 3.\n' + : dispatch.message; assert.deepEqual(JSON.parse(request.body), { recipient: { phone_number: dispatch.destination }, - message: locale === 'en' ? { text: dispatch.message, language: 'en' } : { text: dispatch.message }, + message: locale === 'en' ? { text: expectedText, language: 'en' } : { text: expectedText }, channels: [{ channel }], correlation_id: dispatch.correlationId, }); } @@ -164,6 +167,18 @@ test('Telesign EPP uses the selected endpoint with the same Basic-auth JSON cont providerMessageId: 'message-id', providerStatusCode: '290' })); }); +test('Telesign Voice paces only six-digit numeric runs and repeats the full message', () => { + const message = 'Code 001234; ref 1234567; alternate 654321.'; + const request = getProvider('telesign').adapter.buildRequest({ + ...input, + channel: 'voice', + dispatch: { ...dispatch, message }, + }); + assert.equal(JSON.parse(request.body).message.text, + 'Code 0, 0, 1, 2, 3, 4; ref 1234567; alternate 6, 5, 4, 3, 2, 1. ' + + 'Code 0, 0, 1, 2, 3, 4; ref 1234567; alternate 6, 5, 4, 3, 2, 1.'); +}); + test('Telesign EPP rejects invalid recipients and fails closed on unknown status', () => { const { adapter, manifest } = getProvider('telesign'); for (const destination of ['15551234567', '+0123', '+1', '+1234567890123456', '+123\n', '+123\r', '+12 34', null]) { diff --git a/javascript/test/sendotp.test.js b/javascript/test/sendotp.test.js index a8b7fdf..5ff12d7 100644 --- a/javascript/test/sendotp.test.js +++ b/javascript/test/sendotp.test.js @@ -237,8 +237,11 @@ test('Telesign EPP sends decrypted SMS and voice content with Basic auth and pri assert.equal(result.status, 200); const [url, init] = fetchMock.mock.calls.at(-1).arguments; assert.equal(url, 'https://verify.telesign.com/epp/send'); + const expectedText = name === 'voice' + ? ' PRIVATE-MESSAGE 9, 1, 8, 2, 7, 3.\n PRIVATE-MESSAGE 9, 1, 8, 2, 7, 3.\n' + : delivery.message; assert.deepEqual(JSON.parse(init.body), { recipient: { phone_number: delivery.phoneNumber }, - message: { text: delivery.message, language: delivery.locale }, channels: [{ channel: name }], correlation_id: 'correlation-id' }); + message: { text: expectedText, language: delivery.locale }, channels: [{ channel: name }], correlation_id: 'correlation-id' }); assert.deepEqual(init.headers, { Authorization: `Basic ${Buffer.from('PRIVATE-API-KEY:PRIVATE-API-KEY').toString('base64')}`, 'Content-Type': 'application/json', Accept: 'application/json' }); assert.equal(init.redirect, 'manual'); diff --git a/python/README.md b/python/README.md index 27c3d4f..b9cc577 100644 --- a/python/README.md +++ b/python/README.md @@ -85,6 +85,9 @@ uses a nonblank SAS request locale as the language, falling back to `en-US`, and `1` and loop `2`. These values require no additional environment settings. Soprano SMS continues to forward the rendered message unchanged. +Telesign SMS also forwards the rendered message unchanged. Telesign voice comma-separates each +six-digit numeric run that is not part of a longer number and repeats the complete paced message twice. + ## Source | Source | Purpose | diff --git a/python/src/providers/telesign.py b/python/src/providers/telesign.py index 19f4098..e3c9fd2 100644 --- a/python/src/providers/telesign.py +++ b/python/src/providers/telesign.py @@ -4,6 +4,19 @@ from ..models import ParsedResponse +VOICE_PASSCODE_PATTERN = re.compile(r"(?