diff --git a/app/autotranslate/server/index.js b/app/autotranslate/server/index.js index 186115b06c6d..9b39ad99e453 100644 --- a/app/autotranslate/server/index.js +++ b/app/autotranslate/server/index.js @@ -13,6 +13,7 @@ import './methods/saveSettings'; import './methods/translateMessage'; import './googleTranslate.js'; import './deeplTranslate.js'; +import './msTranslate.js'; import './methods/getProviderUiMetadata.js'; export { diff --git a/app/autotranslate/server/logger.js b/app/autotranslate/server/logger.js new file mode 100644 index 000000000000..8f104e75e88c --- /dev/null +++ b/app/autotranslate/server/logger.js @@ -0,0 +1,9 @@ +import { Logger } from '../../logger'; + +export const logger = new Logger('AutoTranslate', { + sections: { + google: 'Google', + deepl: 'DeepL', + microsoft: 'Microsoft', + }, +}); diff --git a/app/autotranslate/server/msTranslate.js b/app/autotranslate/server/msTranslate.js new file mode 100644 index 000000000000..efe7f6eeb877 --- /dev/null +++ b/app/autotranslate/server/msTranslate.js @@ -0,0 +1,164 @@ +/** + * @author Vigneshwaran Odayappan + */ + +import { TAPi18n } from 'meteor/rocketchat:tap-i18n'; +import { HTTP } from 'meteor/http'; +import _ from 'underscore'; + +import { TranslationProviderRegistry, AutoTranslate } from './autotranslate'; +import { logger } from './logger'; +import { settings } from '../../settings'; + +/** + * Microsoft translation service provider class representation. + * Encapsulates the service provider settings and information. + * Provides languages supported by the service provider. + * Resolves API call to service provider to resolve the translation request. + * @class + * @augments AutoTranslate + */ +class MsAutoTranslate extends AutoTranslate { + /** + * setup api reference to Microsoft translate to be used as message translation provider. + * @constructor + */ + constructor() { + super(); + this.name = 'microsoft-translate'; + this.apiEndPointUrl = 'https://api.cognitive.microsofttranslator.com/translate?api-version=3.0'; + this.apiDetectText = 'https://api.cognitive.microsofttranslator.com/detect?api-version=3.0'; + this.apiGetLanguages = 'https://api.cognitive.microsofttranslator.com/languages?api-version=3.0'; + this.breakSentence = 'https://api.cognitive.microsofttranslator.com/breaksentence?api-version=3.0'; + // Get the service provide API key. + settings.get('AutoTranslate_MicrosoftAPIKey', (key, value) => { + this.apiKey = value; + }); + } + + /** + * Returns metadata information about the service provide + * @private implements super abstract method. + * @return {object} + */ + _getProviderMetadata() { + return { + name: this.name, + displayName: TAPi18n.__('AutoTranslate_Microsoft'), + settings: this._getSettings(), + }; + } + + /** + * Returns necessary settings information about the translation service provider. + * @private implements super abstract method. + * @return {object} + */ + _getSettings() { + return { + apiKey: this.apiKey, + apiEndPointUrl: this.apiEndPointUrl, + }; + } + + /** + * Returns supported languages for translation by the active service provider. + * Microsoft does not provide an endpoint yet to retrieve the supported languages. + * So each supported languages are explicitly maintained. + * @private implements super abstract method. + * @param {string} target + * @returns {object} code : value pair + */ + getSupportedLanguages(target) { + if (this.autoTranslateEnabled && this.apiKey) { + if (this.supportedLanguages[target]) { + return this.supportedLanguages[target]; + } + const languages = HTTP.get(this.apiGetLanguages); + this.supportedLanguages[target] = Object.keys(languages.data.translation).map((language) => ({ + language, + name: languages.data.translation[language].name, + })); + return this.supportedLanguages[target || 'en']; + } + } + + /** + * Re-use method for REST API consumption of MS translate. + * @private + * @param {object} message + * @param {object} targetLanguages + * @throws Communication Errors + * @returns {object} translations: Translated messages for each language + */ + _translate(data, targetLanguages) { + let translations = {}; + const supportedLanguages = this.getSupportedLanguages('en'); + targetLanguages = targetLanguages.map((language) => { + if (language.indexOf('-') !== -1 && !_.findWhere(supportedLanguages, { language })) { + language = language.substr(0, 2); + } + return language; + }); + const url = `${ this.apiEndPointUrl }&to=${ targetLanguages.join('&to=') }`; + const result = HTTP.post(url, { + headers: { + 'Ocp-Apim-Subscription-Key': this.apiKey, + 'Content-Type': 'application/json; charset=UTF-8', + }, + data, + }); + + if (result.statusCode === 200 && result.data && result.data.length > 0) { + // store translation only when the source and target language are different. + translations = Object.assign({}, ...targetLanguages.map((language) => + ({ + [language]: result.data.map((line) => line.translations.find((translation) => translation.to === language).text).join('\n'), + }), + )); + } + + return translations; + } + + /** + * Returns translated message for each target language. + * @private + * @param {object} message + * @param {object} targetLanguages + * @returns {object} translations: Translated messages for each language + */ + _translateMessage(message, targetLanguages) { + // There are multi-sentence-messages where multiple sentences come from different languages + // This is a problem for translation services since the language detection fails. + // Thus, we'll split the message in sentences, get them translated, and join them again after translation + const msgs = message.msg.split('\n').map((msg) => ({ Text: msg })); + try { + return this._translate(msgs, targetLanguages); + } catch (e) { + logger.microsoft.error('Error translating message', e); + } + return {}; + } + + /** + * Returns translated message attachment description in target languages. + * @private + * @param {object} attachment + * @param {object} targetLanguages + * @returns {object} translated messages for each target language + */ + _translateAttachmentDescriptions(attachment, targetLanguages) { + try { + return this._translate([{ + Text: attachment.description || attachment.text, + }], targetLanguages); + } catch (e) { + logger.microsoft.error('Error translating message attachment', e); + } + return {}; + } +} + +// Register Microsoft translation provider to the registry. +TranslationProviderRegistry.registerProvider(new MsAutoTranslate()); diff --git a/app/autotranslate/server/settings.js b/app/autotranslate/server/settings.js index 08e8f6c92f0b..d58f5a9b3533 100644 --- a/app/autotranslate/server/settings.js +++ b/app/autotranslate/server/settings.js @@ -20,6 +20,9 @@ Meteor.startup(function() { }, { key: 'deepl-translate', i18nLabel: 'AutoTranslate_DeepL', + }, { + key: 'microsoft-translate', + i18nLabel: 'AutoTranslate_Microsoft', }], enableQuery: [{ _id: 'AutoTranslate_Enabled', value: true }], i18nLabel: 'AutoTranslate_ServiceProvider', @@ -30,7 +33,7 @@ Meteor.startup(function() { type: 'string', group: 'Message', section: 'AutoTranslate_Google', - public: true, + public: false, i18nLabel: 'AutoTranslate_APIKey', enableQuery: [ { @@ -45,7 +48,7 @@ Meteor.startup(function() { type: 'string', group: 'Message', section: 'AutoTranslate_DeepL', - public: true, + public: false, i18nLabel: 'AutoTranslate_APIKey', enableQuery: [ { @@ -54,4 +57,18 @@ Meteor.startup(function() { _id: 'AutoTranslate_ServiceProvider', value: 'deepl-translate', }], }); + + settings.add('AutoTranslate_MicrosoftAPIKey', '', { + type: 'string', + group: 'Message', + section: 'AutoTranslate_Microsoft', + public: false, + i18nLabel: 'AutoTranslate_Microsoft_API_Key', + enableQuery: [ + { + _id: 'AutoTranslate_Enabled', value: true, + }, { + _id: 'AutoTranslate_ServiceProvider', value: 'microsoft-translate', + }], + }); }); diff --git a/app/models/server/models/Messages.js b/app/models/server/models/Messages.js index b7c1bba880b1..5ba20bf50ff2 100644 --- a/app/models/server/models/Messages.js +++ b/app/models/server/models/Messages.js @@ -108,8 +108,8 @@ export class Messages extends Base { return this.createWithTypeRoomIdMessageAndUser('r', roomId, roomName, user, extraData); } - addTranslations(messageId, translations) { - const updateObj = {}; + addTranslations(messageId, translations, providerName) { + const updateObj = { translationProvider: providerName }; Object.keys(translations).forEach((key) => { const translation = translations[key]; updateObj[`translations.${ key }`] = translation; diff --git a/app/ui-message/client/message.html b/app/ui-message/client/message.html index 571d2b062f65..5542189c9cd1 100644 --- a/app/ui-message/client/message.html +++ b/app/ui-message/client/message.html @@ -44,6 +44,7 @@ {{#if showTranslated}} + {{ translationProvider }} {{/if}} {{#if msg.sentByEmail}} diff --git a/app/ui-message/client/message.js b/app/ui-message/client/message.js index 63c270511b69..437a35970eb6 100644 --- a/app/ui-message/client/message.js +++ b/app/ui-message/client/message.js @@ -15,6 +15,7 @@ import { t, roomTypes, getURL } from '../../utils'; import { upsertMessage } from '../../ui-utils/client/lib/RoomHistoryManager'; import './message.html'; import './messageThread.html'; +import { AutoTranslate } from '../../autotranslate/client'; async function renderPdfToCanvas(canvasId, pdfLink) { const isSafari = /constructor/i.test(window.HTMLElement) @@ -250,6 +251,11 @@ Template.message.helpers({ return msg.autoTranslateFetching || (!!autoTranslate !== !!msg.autoTranslateShowInverse && msg.translations && msg.translations[settings.translateLanguage]); } }, + translationProvider() { + const instance = Template.instance(); + const { translationProvider } = instance.data.msg; + return translationProvider && AutoTranslate.providersMetadata[translationProvider].displayName; + }, edited() { const { msg } = this; return msg.editedAt && !MessageTypes.isSystemMessage(msg); diff --git a/packages/rocketchat-i18n/i18n/en.i18n.json b/packages/rocketchat-i18n/i18n/en.i18n.json index 33b5e24608c0..51e7b5f8426f 100644 --- a/packages/rocketchat-i18n/i18n/en.i18n.json +++ b/packages/rocketchat-i18n/i18n/en.i18n.json @@ -450,6 +450,8 @@ "AutoTranslate_Enabled": "Enable Auto-Translate", "AutoTranslate_Enabled_Description": "Enabling auto-translation will allow people with the auto-translate permission to have all messages automatically translated into their selected language. Fees may apply.", "AutoTranslate_Google": "Google", + "AutoTranslate_Microsoft": "Microsoft", + "AutoTranslate_Microsoft_API_Key": "Ocp-Apim-Subscription-Key", "AutoTranslate_ServiceProvider": "Service Provider", "Available": "Available", "Available_agents": "Available agents",