Support for localized voice verification
This commit is contained in:
parent
c2f2146872
commit
0c3dc3dea2
|
@ -131,9 +131,15 @@ public class WhisperServerConfiguration extends Configuration {
|
||||||
@JsonProperty
|
@JsonProperty
|
||||||
private UnidentifiedDeliveryConfiguration unidentifiedDelivery;
|
private UnidentifiedDeliveryConfiguration unidentifiedDelivery;
|
||||||
|
|
||||||
|
@Valid
|
||||||
@NotNull
|
@NotNull
|
||||||
@JsonProperty
|
@JsonProperty
|
||||||
private Map<String, Object> hystrix = new HashMap<>();
|
private VoiceVerificationConfiguration voiceVerification;
|
||||||
|
|
||||||
|
|
||||||
|
public VoiceVerificationConfiguration getVoiceVerificationConfiguration() {
|
||||||
|
return voiceVerification;
|
||||||
|
}
|
||||||
|
|
||||||
public WebSocketConfiguration getWebSocketConfiguration() {
|
public WebSocketConfiguration getWebSocketConfiguration() {
|
||||||
return webSocket;
|
return webSocket;
|
||||||
|
|
|
@ -38,6 +38,7 @@ import org.whispersystems.textsecuregcm.controllers.KeysController;
|
||||||
import org.whispersystems.textsecuregcm.controllers.MessageController;
|
import org.whispersystems.textsecuregcm.controllers.MessageController;
|
||||||
import org.whispersystems.textsecuregcm.controllers.ProfileController;
|
import org.whispersystems.textsecuregcm.controllers.ProfileController;
|
||||||
import org.whispersystems.textsecuregcm.controllers.ProvisioningController;
|
import org.whispersystems.textsecuregcm.controllers.ProvisioningController;
|
||||||
|
import org.whispersystems.textsecuregcm.controllers.VoiceVerificationController;
|
||||||
import org.whispersystems.textsecuregcm.limits.RateLimiters;
|
import org.whispersystems.textsecuregcm.limits.RateLimiters;
|
||||||
import org.whispersystems.textsecuregcm.liquibase.NameableMigrationsBundle;
|
import org.whispersystems.textsecuregcm.liquibase.NameableMigrationsBundle;
|
||||||
import org.whispersystems.textsecuregcm.mappers.DeviceLimitExceededExceptionMapper;
|
import org.whispersystems.textsecuregcm.mappers.DeviceLimitExceededExceptionMapper;
|
||||||
|
@ -213,6 +214,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
||||||
environment.jersey().register(new DirectoryController(rateLimiters, directory, directoryCredentialsGenerator));
|
environment.jersey().register(new DirectoryController(rateLimiters, directory, directoryCredentialsGenerator));
|
||||||
environment.jersey().register(new ProvisioningController(rateLimiters, pushSender));
|
environment.jersey().register(new ProvisioningController(rateLimiters, pushSender));
|
||||||
environment.jersey().register(new CertificateController(new CertificateGenerator(config.getDeliveryCertificate().getCertificate(), config.getDeliveryCertificate().getPrivateKey(), config.getDeliveryCertificate().getExpiresDays())));
|
environment.jersey().register(new CertificateController(new CertificateGenerator(config.getDeliveryCertificate().getCertificate(), config.getDeliveryCertificate().getPrivateKey(), config.getDeliveryCertificate().getExpiresDays())));
|
||||||
|
environment.jersey().register(new VoiceVerificationController(config.getVoiceVerificationConfiguration().getUrl(), config.getVoiceVerificationConfiguration().getLocales()));
|
||||||
environment.jersey().register(attachmentController);
|
environment.jersey().register(attachmentController);
|
||||||
environment.jersey().register(keysController);
|
environment.jersey().register(keysController);
|
||||||
environment.jersey().register(messageController);
|
environment.jersey().register(messageController);
|
||||||
|
|
|
@ -0,0 +1,32 @@
|
||||||
|
package org.whispersystems.textsecuregcm.configuration;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
|
||||||
|
import org.hibernate.validator.constraints.NotEmpty;
|
||||||
|
|
||||||
|
import javax.validation.Valid;
|
||||||
|
import javax.validation.constraints.NotNull;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
public class VoiceVerificationConfiguration {
|
||||||
|
|
||||||
|
@JsonProperty
|
||||||
|
@Valid
|
||||||
|
@NotEmpty
|
||||||
|
private String url;
|
||||||
|
|
||||||
|
@JsonProperty
|
||||||
|
@Valid
|
||||||
|
@NotNull
|
||||||
|
private List<String> locales;
|
||||||
|
|
||||||
|
public String getUrl() {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Set<String> getLocales() {
|
||||||
|
return new HashSet<>(locales);
|
||||||
|
}
|
||||||
|
}
|
|
@ -113,6 +113,7 @@ public class AccountController {
|
||||||
public Response createAccount(@PathParam("transport") String transport,
|
public Response createAccount(@PathParam("transport") String transport,
|
||||||
@PathParam("number") String number,
|
@PathParam("number") String number,
|
||||||
@HeaderParam("X-Forwarded-For") String requester,
|
@HeaderParam("X-Forwarded-For") String requester,
|
||||||
|
@HeaderParam("Accept-Language") Optional<String> locale,
|
||||||
@QueryParam("client") Optional<String> client)
|
@QueryParam("client") Optional<String> client)
|
||||||
throws IOException, RateLimitExceededException
|
throws IOException, RateLimitExceededException
|
||||||
{
|
{
|
||||||
|
@ -151,7 +152,7 @@ public class AccountController {
|
||||||
} else if (transport.equals("sms")) {
|
} else if (transport.equals("sms")) {
|
||||||
smsSender.deliverSmsVerification(number, client, verificationCode.getVerificationCodeDisplay());
|
smsSender.deliverSmsVerification(number, client, verificationCode.getVerificationCodeDisplay());
|
||||||
} else if (transport.equals("voice")) {
|
} else if (transport.equals("voice")) {
|
||||||
smsSender.deliverVoxVerification(number, verificationCode.getVerificationCodeSpeech());
|
smsSender.deliverVoxVerification(number, verificationCode.getVerificationCode(), locale);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Response.ok().build();
|
return Response.ok().build();
|
||||||
|
@ -337,8 +338,7 @@ public class AccountController {
|
||||||
@Path("/voice/twiml/{code}")
|
@Path("/voice/twiml/{code}")
|
||||||
@Produces(MediaType.APPLICATION_XML)
|
@Produces(MediaType.APPLICATION_XML)
|
||||||
public Response getTwiml(@PathParam("code") String encodedVerificationText) {
|
public Response getTwiml(@PathParam("code") String encodedVerificationText) {
|
||||||
return Response.ok().entity(String.format(TwilioSmsSender.SAY_TWIML,
|
return Response.ok().entity(String.format(TwilioSmsSender.SAY_TWIML, encodedVerificationText)).build();
|
||||||
encodedVerificationText)).build();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void createAccount(String number, String password, String userAgent, AccountAttributes accountAttributes) {
|
private void createAccount(String number, String password, String userAgent, AccountAttributes accountAttributes) {
|
||||||
|
|
|
@ -0,0 +1,108 @@
|
||||||
|
package org.whispersystems.textsecuregcm.controllers;
|
||||||
|
|
||||||
|
import javax.ws.rs.GET;
|
||||||
|
import javax.ws.rs.POST;
|
||||||
|
import javax.ws.rs.Path;
|
||||||
|
import javax.ws.rs.PathParam;
|
||||||
|
import javax.ws.rs.Produces;
|
||||||
|
import javax.ws.rs.QueryParam;
|
||||||
|
import javax.ws.rs.core.MediaType;
|
||||||
|
import javax.ws.rs.core.Response;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
|
||||||
|
@Path("/v1/voice/")
|
||||||
|
public class VoiceVerificationController {
|
||||||
|
|
||||||
|
private static final String PLAY_TWIML = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" +
|
||||||
|
"<Response>\n" +
|
||||||
|
" <Play>%s</Play>\n" +
|
||||||
|
" <Play>%s</Play>\n" +
|
||||||
|
" <Play>%s</Play>\n" +
|
||||||
|
" <Play>%s</Play>\n" +
|
||||||
|
" <Play>%s</Play>\n" +
|
||||||
|
" <Play>%s</Play>\n" +
|
||||||
|
" <Play>%s</Play>\n" +
|
||||||
|
" <Pause length=\"1\"/>\n" +
|
||||||
|
" <Play>%s</Play>\n" +
|
||||||
|
" <Play>%s</Play>\n" +
|
||||||
|
" <Play>%s</Play>\n" +
|
||||||
|
" <Play>%s</Play>\n" +
|
||||||
|
" <Play>%s</Play>\n" +
|
||||||
|
" <Play>%s</Play>\n" +
|
||||||
|
" <Play>%s</Play>\n" +
|
||||||
|
" <Pause length=\"1\"/>\n" +
|
||||||
|
" <Play>%s</Play>\n" +
|
||||||
|
" <Play>%s</Play>\n" +
|
||||||
|
" <Play>%s</Play>\n" +
|
||||||
|
" <Play>%s</Play>\n" +
|
||||||
|
" <Play>%s</Play>\n" +
|
||||||
|
" <Play>%s</Play>\n" +
|
||||||
|
" <Play>%s</Play>\n" +
|
||||||
|
"</Response>";
|
||||||
|
|
||||||
|
|
||||||
|
private final String baseUrl;
|
||||||
|
private final Set<String> supportedLocales;
|
||||||
|
|
||||||
|
public VoiceVerificationController(String baseUrl, Set<String> supportedLocales) {
|
||||||
|
this.baseUrl = baseUrl;
|
||||||
|
this.supportedLocales = supportedLocales;
|
||||||
|
}
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("/description/{code}")
|
||||||
|
@Produces(MediaType.APPLICATION_XML)
|
||||||
|
public Response getDescription(@PathParam("code") String code, @QueryParam("l") String locale) {
|
||||||
|
code = code.replaceAll("[^0-9]", "");
|
||||||
|
|
||||||
|
if (code.length() != 6) {
|
||||||
|
return Response.status(400).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (locale != null && supportedLocales.contains(locale)) {
|
||||||
|
return getLocalizedDescription(code, locale);
|
||||||
|
}
|
||||||
|
|
||||||
|
return getLocalizedDescription(code, "en-US");
|
||||||
|
}
|
||||||
|
|
||||||
|
private Response getLocalizedDescription(String code, String locale) {
|
||||||
|
String path = constructUrlForLocale(baseUrl, locale);
|
||||||
|
|
||||||
|
return Response.ok()
|
||||||
|
.entity(String.format(PLAY_TWIML,
|
||||||
|
path + "verification.mp3",
|
||||||
|
path + code.charAt(0) + "_middle.mp3",
|
||||||
|
path + code.charAt(1) + "_middle.mp3",
|
||||||
|
path + code.charAt(2) + "_middle.mp3",
|
||||||
|
path + code.charAt(3) + "_middle.mp3",
|
||||||
|
path + code.charAt(4) + "_middle.mp3",
|
||||||
|
path + code.charAt(5) + "_falling.mp3",
|
||||||
|
path + "verification.mp3",
|
||||||
|
path + code.charAt(0) + "_middle.mp3",
|
||||||
|
path + code.charAt(1) + "_middle.mp3",
|
||||||
|
path + code.charAt(2) + "_middle.mp3",
|
||||||
|
path + code.charAt(3) + "_middle.mp3",
|
||||||
|
path + code.charAt(4) + "_middle.mp3",
|
||||||
|
path + code.charAt(5) + "_falling.mp3",
|
||||||
|
path + "verification.mp3",
|
||||||
|
path + code.charAt(0) + "_middle.mp3",
|
||||||
|
path + code.charAt(1) + "_middle.mp3",
|
||||||
|
path + code.charAt(2) + "_middle.mp3",
|
||||||
|
path + code.charAt(3) + "_middle.mp3",
|
||||||
|
path + code.charAt(4) + "_middle.mp3",
|
||||||
|
path + code.charAt(5) + "_falling.mp3"))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String constructUrlForLocale(String baseUrl, String locale) {
|
||||||
|
if (!baseUrl.endsWith("/")) {
|
||||||
|
baseUrl += "/";
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseUrl + locale + "/";
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -55,11 +55,11 @@ public class SmsSender {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void deliverVoxVerification(String destination, String verificationCode)
|
public void deliverVoxVerification(String destination, String verificationCode, Optional<String> locale)
|
||||||
throws IOException
|
throws IOException
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
twilioSender.deliverVoxVerification(destination, verificationCode);
|
twilioSender.deliverVoxVerification(destination, verificationCode, locale);
|
||||||
} catch (TwilioRestException e) {
|
} catch (TwilioRestException e) {
|
||||||
logger.info("Twilio Vox Failed: " + e.getErrorMessage());
|
logger.info("Twilio Vox Failed: " + e.getErrorMessage());
|
||||||
}
|
}
|
||||||
|
|
|
@ -97,15 +97,21 @@ public class TwilioSmsSender {
|
||||||
smsMeter.mark();
|
smsMeter.mark();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void deliverVoxVerification(String destination, String verificationCode)
|
public void deliverVoxVerification(String destination, String verificationCode, Optional<String> locale)
|
||||||
throws IOException, TwilioRestException
|
throws IOException, TwilioRestException
|
||||||
{
|
{
|
||||||
|
String url = "https://" + localDomain + "/v1/voice/description/" + verificationCode;
|
||||||
|
|
||||||
|
if (locale.isPresent()) {
|
||||||
|
url += "?l=" + locale.get();
|
||||||
|
}
|
||||||
|
|
||||||
TwilioRestClient client = new TwilioRestClient(accountId, accountToken);
|
TwilioRestClient client = new TwilioRestClient(accountId, accountToken);
|
||||||
CallFactory callFactory = client.getAccount().getCallFactory();
|
CallFactory callFactory = client.getAccount().getCallFactory();
|
||||||
Map<String, String> callParams = new HashMap<>();
|
Map<String, String> callParams = new HashMap<>();
|
||||||
callParams.put("To", destination);
|
callParams.put("To", destination);
|
||||||
callParams.put("From", getRandom(random, numbers));
|
callParams.put("From", getRandom(random, numbers));
|
||||||
callParams.put("Url", "https://" + localDomain + "/v1/accounts/voice/twiml/" + verificationCode);
|
callParams.put("Url", url);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
callFactory.create(callParams);
|
callFactory.create(callParams);
|
||||||
|
|
|
@ -35,9 +35,12 @@ public class VerificationCode {
|
||||||
@VisibleForTesting VerificationCode() {}
|
@VisibleForTesting VerificationCode() {}
|
||||||
|
|
||||||
public VerificationCode(int verificationCode) {
|
public VerificationCode(int verificationCode) {
|
||||||
this.verificationCode = verificationCode + "";
|
this(verificationCode + "");
|
||||||
this.verificationCodeDisplay = this.verificationCode.substring(0, 3) + "-" +
|
}
|
||||||
this.verificationCode.substring(3, 6);
|
|
||||||
|
public VerificationCode(String verificationCode) {
|
||||||
|
this.verificationCode = verificationCode;
|
||||||
|
this.verificationCodeDisplay = this.verificationCode.substring(0, 3) + "-" + this.verificationCode.substring(3, 6);
|
||||||
this.verificationCodeSpeech = delimit(verificationCode + "");
|
this.verificationCodeSpeech = delimit(verificationCode + "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,96 @@
|
||||||
|
package org.whispersystems.textsecuregcm.tests.controllers;
|
||||||
|
|
||||||
|
import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory;
|
||||||
|
import org.junit.Rule;
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.whispersystems.textsecuregcm.controllers.VoiceVerificationController;
|
||||||
|
import org.whispersystems.textsecuregcm.mappers.RateLimitExceededExceptionMapper;
|
||||||
|
import org.whispersystems.textsecuregcm.storage.Account;
|
||||||
|
import org.whispersystems.textsecuregcm.tests.util.AuthHelper;
|
||||||
|
import org.whispersystems.textsecuregcm.util.SystemMapper;
|
||||||
|
|
||||||
|
import javax.ws.rs.core.Response;
|
||||||
|
import java.util.Collections;
|
||||||
|
|
||||||
|
import io.dropwizard.auth.AuthValueFactoryProvider;
|
||||||
|
import io.dropwizard.testing.FixtureHelpers;
|
||||||
|
import io.dropwizard.testing.junit.ResourceTestRule;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
public class VoiceVerificationControllerTest {
|
||||||
|
|
||||||
|
@Rule
|
||||||
|
public final ResourceTestRule resources = ResourceTestRule.builder()
|
||||||
|
.addProvider(AuthHelper.getAuthFilter())
|
||||||
|
.addProvider(new AuthValueFactoryProvider.Binder<>(Account.class))
|
||||||
|
.addProvider(new RateLimitExceededExceptionMapper())
|
||||||
|
.setMapper(SystemMapper.getMapper())
|
||||||
|
.setTestContainerFactory(new GrizzlyWebTestContainerFactory())
|
||||||
|
.addResource(new VoiceVerificationController("https://foo.com/bar",
|
||||||
|
Collections.singleton("pt-BR")))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testTwimlLocale() {
|
||||||
|
Response response =
|
||||||
|
resources.getJerseyTest()
|
||||||
|
.target("/v1/voice/description/123456")
|
||||||
|
.queryParam("l", "pt-BR")
|
||||||
|
.request()
|
||||||
|
.post(null);
|
||||||
|
|
||||||
|
assertThat(response.getStatus()).isEqualTo(200);
|
||||||
|
assertThat(response.readEntity(String.class)).isXmlEqualTo(FixtureHelpers.fixture("fixtures/voice_verification_pt_br.xml"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testTwimlUnsupportedLocale() {
|
||||||
|
Response response =
|
||||||
|
resources.getJerseyTest()
|
||||||
|
.target("/v1/voice/description/123456")
|
||||||
|
.queryParam("l", "es-MX")
|
||||||
|
.request()
|
||||||
|
.post(null);
|
||||||
|
|
||||||
|
assertThat(response.getStatus()).isEqualTo(200);
|
||||||
|
assertThat(response.readEntity(String.class)).isXmlEqualTo(FixtureHelpers.fixture("fixtures/voice_verification_en_us.xml"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testTwimlMissingLocale() {
|
||||||
|
Response response =
|
||||||
|
resources.getJerseyTest()
|
||||||
|
.target("/v1/voice/description/123456")
|
||||||
|
.request()
|
||||||
|
.post(null);
|
||||||
|
|
||||||
|
assertThat(response.getStatus()).isEqualTo(200);
|
||||||
|
assertThat(response.readEntity(String.class)).isXmlEqualTo(FixtureHelpers.fixture("fixtures/voice_verification_en_us.xml"));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testTwimlMalformedCode() {
|
||||||
|
Response response =
|
||||||
|
resources.getJerseyTest()
|
||||||
|
.target("/v1/voice/description/1234...56")
|
||||||
|
.request()
|
||||||
|
.post(null);
|
||||||
|
|
||||||
|
assertThat(response.getStatus()).isEqualTo(200);
|
||||||
|
assertThat(response.readEntity(String.class)).isXmlEqualTo(FixtureHelpers.fixture("fixtures/voice_verification_en_us.xml"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testTwimlBadCodeLength() {
|
||||||
|
Response response =
|
||||||
|
resources.getJerseyTest()
|
||||||
|
.target("/v1/voice/description/1234567")
|
||||||
|
.request()
|
||||||
|
.post(null);
|
||||||
|
|
||||||
|
assertThat(response.getStatus()).isEqualTo(400);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,26 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Response>
|
||||||
|
<Play>https://foo.com/bar/en-US/verification.mp3</Play>
|
||||||
|
<Play>https://foo.com/bar/en-US/1_middle.mp3</Play>
|
||||||
|
<Play>https://foo.com/bar/en-US/2_middle.mp3</Play>
|
||||||
|
<Play>https://foo.com/bar/en-US/3_middle.mp3</Play>
|
||||||
|
<Play>https://foo.com/bar/en-US/4_middle.mp3</Play>
|
||||||
|
<Play>https://foo.com/bar/en-US/5_middle.mp3</Play>
|
||||||
|
<Play>https://foo.com/bar/en-US/6_falling.mp3</Play>
|
||||||
|
<Pause length="1"/>
|
||||||
|
<Play>https://foo.com/bar/en-US/verification.mp3</Play>
|
||||||
|
<Play>https://foo.com/bar/en-US/1_middle.mp3</Play>
|
||||||
|
<Play>https://foo.com/bar/en-US/2_middle.mp3</Play>
|
||||||
|
<Play>https://foo.com/bar/en-US/3_middle.mp3</Play>
|
||||||
|
<Play>https://foo.com/bar/en-US/4_middle.mp3</Play>
|
||||||
|
<Play>https://foo.com/bar/en-US/5_middle.mp3</Play>
|
||||||
|
<Play>https://foo.com/bar/en-US/6_falling.mp3</Play>
|
||||||
|
<Pause length="1"/>
|
||||||
|
<Play>https://foo.com/bar/en-US/verification.mp3</Play>
|
||||||
|
<Play>https://foo.com/bar/en-US/1_middle.mp3</Play>
|
||||||
|
<Play>https://foo.com/bar/en-US/2_middle.mp3</Play>
|
||||||
|
<Play>https://foo.com/bar/en-US/3_middle.mp3</Play>
|
||||||
|
<Play>https://foo.com/bar/en-US/4_middle.mp3</Play>
|
||||||
|
<Play>https://foo.com/bar/en-US/5_middle.mp3</Play>
|
||||||
|
<Play>https://foo.com/bar/en-US/6_falling.mp3</Play>
|
||||||
|
</Response>
|
|
@ -0,0 +1,26 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Response>
|
||||||
|
<Play>https://foo.com/bar/pt-BR/verification.mp3</Play>
|
||||||
|
<Play>https://foo.com/bar/pt-BR/1_middle.mp3</Play>
|
||||||
|
<Play>https://foo.com/bar/pt-BR/2_middle.mp3</Play>
|
||||||
|
<Play>https://foo.com/bar/pt-BR/3_middle.mp3</Play>
|
||||||
|
<Play>https://foo.com/bar/pt-BR/4_middle.mp3</Play>
|
||||||
|
<Play>https://foo.com/bar/pt-BR/5_middle.mp3</Play>
|
||||||
|
<Play>https://foo.com/bar/pt-BR/6_falling.mp3</Play>
|
||||||
|
<Pause length="1"/>
|
||||||
|
<Play>https://foo.com/bar/pt-BR/verification.mp3</Play>
|
||||||
|
<Play>https://foo.com/bar/pt-BR/1_middle.mp3</Play>
|
||||||
|
<Play>https://foo.com/bar/pt-BR/2_middle.mp3</Play>
|
||||||
|
<Play>https://foo.com/bar/pt-BR/3_middle.mp3</Play>
|
||||||
|
<Play>https://foo.com/bar/pt-BR/4_middle.mp3</Play>
|
||||||
|
<Play>https://foo.com/bar/pt-BR/5_middle.mp3</Play>
|
||||||
|
<Play>https://foo.com/bar/pt-BR/6_falling.mp3</Play>
|
||||||
|
<Pause length="1"/>
|
||||||
|
<Play>https://foo.com/bar/pt-BR/verification.mp3</Play>
|
||||||
|
<Play>https://foo.com/bar/pt-BR/1_middle.mp3</Play>
|
||||||
|
<Play>https://foo.com/bar/pt-BR/2_middle.mp3</Play>
|
||||||
|
<Play>https://foo.com/bar/pt-BR/3_middle.mp3</Play>
|
||||||
|
<Play>https://foo.com/bar/pt-BR/4_middle.mp3</Play>
|
||||||
|
<Play>https://foo.com/bar/pt-BR/5_middle.mp3</Play>
|
||||||
|
<Play>https://foo.com/bar/pt-BR/6_falling.mp3</Play>
|
||||||
|
</Response>
|
Loading…
Reference in New Issue