Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add new TTS API #156

Merged
merged 2 commits into from
Dec 1, 2023
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
10 changes: 10 additions & 0 deletions core/api/routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,16 @@ module.exports.load = function Routes(app, io, controllers, middlewares) {
asyncMiddleware(controllers.openAIController.ask),
);

// TTS API
app.post(
'/tts/token',
asyncMiddleware(middlewares.accessTokenInstanceAuth),
middlewares.checkUserPlan('plus'),
middlewares.ttsRateLimit,
asyncMiddleware(controllers.ttsController.getTemporaryToken),
);
app.get('/tts/generate', asyncMiddleware(controllers.ttsController.generate));

// user
app.post('/users/signup', middlewares.rateLimiter, asyncMiddleware(controllers.userController.signup));
app.post('/users/verify', middlewares.rateLimiter, asyncMiddleware(controllers.userController.confirmEmail));
Expand Down
71 changes: 71 additions & 0 deletions core/api/tts/tts.controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
const axios = require('axios');
const uuid = require('uuid');

const { UnauthorizedError } = require('../../common/error');

const TTS_TOKEN_PREFIX = 'tts-token:';

module.exports = function TTSController(redisClient) {
/**
* @api {get} /tts/generate Generate a mp3 file from a text
* @apiName generate
* @apiGroup TTS
*
*
* @apiQuery {String} text The text to generate
* @apiQuery {String} token Temporary token to have access to
*
* @apiSuccessExample {binary} Success-Response:
* HTTP/1.1 200 OK
*/
async function generate(req, res, next) {
const instanceId = await redisClient.get(`${TTS_TOKEN_PREFIX}:${req.query.token}`);
if (!instanceId) {
throw new UnauthorizedError('Invalid TTS token.');
}
// Streaming response to client
const { data, headers } = await axios({
url: process.env.TEXT_TO_SPEECH_URL,
method: 'POST',
body: req.body,
headers: {
authorization: `Bearer ${process.env.TEXT_TO_SPEECH_API_KEY}`,
},
responseType: 'stream',
});
res.setHeader('content-type', headers['content-type']);
res.setHeader('content-length', headers['content-length']);
data.pipe(res);
}

/**
* @api {post} /tts/token Get temporary token to access TTS API
* @apiName getToken
* @apiGroup TTS
*
* @apiBody {String} text The text to generate
*
* @apiSuccessExample {binary} Success-Response:
* HTTP/1.1 200 OK
*
* {
* "token": "ac365e90-78f1-482a-8afa-af326d5647a4",
* "url": "https://url_of_the_file"
* }
*/
async function getTemporaryToken(req, res, next) {
const token = uuid.v4();
await redisClient.set(`${TTS_TOKEN_PREFIX}:${token}`, req.instance.id, {
EX: 5 * 60, // 5 minutes in seconds
});
const url = `${process.env.GLADYS_PLUS_BACKEND_URL}/tts/generate?token=${token}&text=${encodeURIComponent(
req.body.text,
)}`;
res.json({ token, url });
}

return {
generate,
getTemporaryToken,
};
};
4 changes: 4 additions & 0 deletions core/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ const AlexaController = require('./api/alexa/alexa.controller');
const EnedisController = require('./api/enedis/enedis.controller');
const EcowattController = require('./api/ecowatt/ecowatt.controller');
const CameraController = require('./api/camera/camera.controller');
const TTSController = require('./api/tts/tts.controller');

// Middlewares
const TwoFactorAuthMiddleware = require('./middleware/twoFactorTokenAuth');
Expand All @@ -69,6 +70,7 @@ const AdminApiAuth = require('./middleware/adminApiAuth');
const OpenAIAuthAndRateLimit = require('./middleware/openAIAuthAndRateLimit');
const CameraStreamAccessKeyAuth = require('./middleware/cameraStreamAccessKeyAuth');
const CheckUserPlan = require('./middleware/checkUserPlan');
const TTSRateLimit = require('./middleware/ttsRateLimit');

// Routes
const routes = require('./api/routes');
Expand Down Expand Up @@ -220,6 +222,7 @@ module.exports = async (port) => {
redisClient,
services.telegramService,
),
ttsController: TTSController(redisClient),
};

const middlewares = {
Expand All @@ -238,6 +241,7 @@ module.exports = async (port) => {
openAIAuthAndRateLimit: OpenAIAuthAndRateLimit(logger, legacyRedisClient, db),
cameraStreamAccessKeyAuth: CameraStreamAccessKeyAuth(redisClient, logger),
checkUserPlan: CheckUserPlan(models.userModel, models.instanceModel, logger),
ttsRateLimit: TTSRateLimit(logger, legacyRedisClient, db),
};

routes.load(app, io, controllers, middlewares);
Expand Down
47 changes: 47 additions & 0 deletions core/middleware/ttsRateLimit.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
const { RateLimiterRedis } = require('rate-limiter-flexible');

const { TooManyRequestsError } = require('../common/error');
const asyncMiddleware = require('./asyncMiddleware');

const MAX_REQUESTS = parseInt(process.env.TTS_MAX_REQUESTS_PER_MONTH_PER_ACCOUNT, 10);

module.exports = function TTSRateLimit(logger, redisClient, db) {
const limiter = new RateLimiterRedis({
storeClient: redisClient,
keyPrefix: 'rate_limit:tts_api',
points: MAX_REQUESTS, // max request per month
duration: 30 * 24 * 60 * 60, // 30 days
});
return asyncMiddleware(async (req, res, next) => {
const instanceWithAccount = await db.t_account
.join({
t_instance: {
type: 'INNER',
on: {
account_id: 'id',
},
},
})
.findOne({
't_instance.id': req.instance.id,
});
const uniqueIdentifier = instanceWithAccount.id;
// we check if the current account is rate limited
const limiterResult = await limiter.get(uniqueIdentifier);
if (limiterResult && limiterResult.consumedPoints > MAX_REQUESTS) {
logger.warn(`TTS Rate limit: Account ${uniqueIdentifier} has been querying too much this route`);
throw new TooManyRequestsError('Too many requests this month.');

Check warning on line 33 in core/middleware/ttsRateLimit.js

View check run for this annotation

Codecov / codecov/patch

core/middleware/ttsRateLimit.js#L32-L33

Added lines #L32 - L33 were not covered by tests
}

// We consume one credit
try {
await limiter.consume(uniqueIdentifier);
} catch (e) {
logger.warn(`TTS Rate limit: Account ${uniqueIdentifier} has been querying too much this route`);
logger.warn(e);
throw new TooManyRequestsError('Too many requests this month.');
}

next();
});
};
96 changes: 96 additions & 0 deletions test/core/api/tts/tts.controller.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
const request = require('supertest');
const nock = require('nock');
const fs = require('fs');
const path = require('path');
const { expect } = require('chai');
const { RateLimiterRedis } = require('rate-limiter-flexible');

const configTest = require('../../../tasks/config');

const voiceFile = fs.readFileSync(path.join(__dirname, './voice.mp3'));

describe('TTS API', () => {
before(() => {
process.env.TEXT_TO_SPEECH_URL = 'https://test-tts.com';
process.env.TEXT_TO_SPEECH_API_KEY = 'my-token';
process.env.GLADYS_PLUS_BACKEND_URL = 'http://test-api.com';
});
it('should get token + get mp3', async () => {
nock(process.env.TEXT_TO_SPEECH_URL, { encodedQueryParams: true })
.post('/', (body) => true)
.reply(200, voiceFile, {
'content-type': 'audio/mpeg',
'content-length': 36362,
});
await TEST_DATABASE_INSTANCE.t_account.update(
{
id: 'b2d23f66-487d-493f-8acb-9c8adb400def',
},
{
status: 'active',
},
);
const response = await request(TEST_BACKEND_APP)
.post('/tts/token')
.set('Accept', 'application/json')
.set('Authorization', configTest.jwtAccessTokenInstance)
.send({ text: 'Bonjour, je suis Gladys' })
.expect('Content-Type', /json/)
.expect(200);
expect(response.body).to.have.property('token');
expect(response.body).to.have.property(
'url',
`http://test-api.com/tts/generate?token=${response.body.token}&text=Bonjour%2C%20je%20suis%20Gladys`,
);
const responseMp3File = await request(TEST_BACKEND_APP)
.get(`/tts/generate?token=${response.body.token}&text=bonjour`)
.set('Accept', 'application/json')
.set('Authorization', configTest.jwtAccessTokenInstance)
.send()
.expect('Content-Type', 'audio/mpeg')
.expect(200);
expect(responseMp3File.text).to.deep.equal(voiceFile.toString());
});
it('should return 401', async () => {
const response = await request(TEST_BACKEND_APP)
.get(`/tts/generate?token=toto&text=bonjour`)
.set('Accept', 'application/json')
.set('Authorization', configTest.jwtAccessTokenInstance)
.send()
.expect('Content-Type', /json/)
.expect(401);
expect(response.body).to.deep.equal({
error_code: 'UNAUTHORIZED',
status: 401,
});
});
it('should return 429, too many requests', async () => {
await TEST_DATABASE_INSTANCE.t_account.update(
{
id: 'b2d23f66-487d-493f-8acb-9c8adb400def',
},
{
status: 'active',
},
);
const limiter = new RateLimiterRedis({
storeClient: TEST_LEGACY_REDIS_CLIENT,
keyPrefix: 'rate_limit:tts_api',
points: 100, // max request per month
duration: 30 * 24 * 60 * 60, // 30 days
});
await limiter.consume('b2d23f66-487d-493f-8acb-9c8adb400def', 100);
const response = await request(TEST_BACKEND_APP)
.post('/tts/token')
.set('Accept', 'application/json')
.set('Authorization', configTest.jwtAccessTokenInstance)
.send()
.expect('Content-Type', /json/)
.expect(429);
expect(response.body).to.deep.equal({
status: 429,
error_code: 'TOO_MANY_REQUESTS',
error_message: 'Too many requests this month.',
});
});
});
Binary file added test/core/api/tts/voice.mp3
Binary file not shown.
Loading