Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
3 changes: 3 additions & 0 deletions dotnet/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
18 changes: 17 additions & 1 deletion dotnet/Src/Providers/TelesignProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
@"(?<![0-9])[0-9]{6}(?![0-9])",
RegexOptions.CultureInvariant);

public ProviderManifest Manifest { get; } = new(
Id: "telesign",
Auth: new AuthConfig("apiKey", KeyVaultSecretName: "telesign-api-key", IdentityKeyVaultSecretName: "telesign-customer-id"),
Expand All @@ -30,7 +37,8 @@ public ProviderHttpRequest BuildRequest(string channel, string endpoint, Dispatc
if (dispatch.Destination is null || !Regex.IsMatch(dispatch.Destination, @"\A\+[1-9][0-9]{1,14}\z"))
throw new InvalidOperationException("invalid recipient");
var authorization = "Basic " + Convert.ToBase64String(Encoding.UTF8.GetBytes($"{credential.Identity}:{credential.Secret}"));
var message = new Dictionary<string, string?> { ["text"] = dispatch.Message };
var messageText = channel == "voice" ? BuildVoiceMessage(dispatch.Message!) : dispatch.Message;
var message = new Dictionary<string, string?> { ["text"] = messageText };
if (!string.IsNullOrWhiteSpace(dispatch.Locale)) message["language"] = dispatch.Locale;
var body = new
{
Expand All @@ -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;
Expand Down
23 changes: 22 additions & 1 deletion dotnet/tests/ContractTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string?> { ["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<string, string?> { ["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()
{
Expand Down
4 changes: 3 additions & 1 deletion javascript/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 14 additions & 1 deletion javascript/src/functions/providers/telesign.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@

const { ParsedResponse } = require('../models');

const VOICE_PASSCODE_PATTERN = /(?<![0-9])[0-9]{6}(?![0-9])/g;
const VOICE_DIGIT_SEPARATOR = ', ';
const VOICE_REPEAT_COUNT = 2;
const VOICE_REPEAT_SEPARATOR = ' ';

const manifest = {
id: 'telesign',
auth: {
Expand All @@ -29,6 +34,14 @@ const manifest = {
},
};

function buildVoiceMessage(message) {
const pacedMessage = message.replace(
VOICE_PASSCODE_PATTERN,
(passcode) => [...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)
Expand All @@ -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,
Expand Down
17 changes: 16 additions & 1 deletion javascript/test/dispatch.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
}
Expand All @@ -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]) {
Expand Down
5 changes: 4 additions & 1 deletion javascript/test/sendotp.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
3 changes: 3 additions & 0 deletions python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
15 changes: 14 additions & 1 deletion python/src/providers/telesign.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,19 @@

from ..models import ParsedResponse

VOICE_PASSCODE_PATTERN = re.compile(r"(?<![0-9])[0-9]{6}(?![0-9])")
VOICE_DIGIT_SEPARATOR = ", "
VOICE_REPEAT_COUNT = 2
VOICE_REPEAT_SEPARATOR = " "


def _build_voice_message(message):
paced_message = VOICE_PASSCODE_PATTERN.sub(
lambda match: VOICE_DIGIT_SEPARATOR.join(match.group(0)),
message,
)
return VOICE_REPEAT_SEPARATOR.join([paced_message] * VOICE_REPEAT_COUNT)


class TelesignProvider:
manifest = {
Expand Down Expand Up @@ -31,7 +44,7 @@ def build_request(self, channel, endpoint, dispatch, credential, env):
correlation_id = dispatch.correlation_id
if not isinstance(correlation_id, str) or not correlation_id:
correlation_id = dispatch.message_id
message = {"text": dispatch.message}
message = {"text": _build_voice_message(dispatch.message) if channel == "voice" else dispatch.message}
if isinstance(dispatch.locale, str) and dispatch.locale.strip():
message["language"] = dispatch.locale
body = {
Expand Down
20 changes: 19 additions & 1 deletion python/tests/test_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,13 +107,31 @@ def test_telesign_epp_request_contract(channel, locale):
assert request["method"] == "POST" and request["url"] == f"https://verify.telesign.com/epp/{channel}"
assert request["headers"] == {"Authorization": "Basic " + base64.b64encode(b"customer:key").decode(),
"Content-Type": "application/json", "Accept": "application/json"}
expected_text = (
" Use 9, 1, 8, 2, 7, 3; then 1234.\nDo not rewrite + or café. "
" Use 9, 1, 8, 2, 7, 3; then 1234.\nDo not rewrite + or café. "
if channel == "voice" else MESSAGE
)
assert json.loads(request["body"]) == {
"recipient": {"phone_number": "+15551234567"},
"message": {"text": MESSAGE, "language": "en"} if locale == "en" else {"text": MESSAGE},
"message": {"text": expected_text, "language": "en"} if locale == "en" else {"text": expected_text},
"channels": [{"channel": channel}], "correlation_id": "correlation-id",
}


def test_telesign_voice_paces_only_six_digit_numeric_runs_and_repeats_message():
dispatch = _dispatch("voice")
dispatch.message = "Code 001234; ref 1234567; alternate 654321."
request = TelesignProvider().build_request(
"voice", "https://verify.telesign.com/epp/voice", dispatch,
{"mode": "apiKey", "secret": "key", "identity": "customer"}, {},
)
assert json.loads(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."
)


def test_telesign_epp_validates_recipients_and_status():
adapter = TelesignProvider()
response = adapter.parse_response(200, True, {"reference_id": "message-id", "status": {"code": 290}})
Expand Down
Loading