diff --git a/Gemfile b/Gemfile
index ff0d4e53a..cc08fea3b 100644
--- a/Gemfile
+++ b/Gemfile
@@ -41,6 +41,8 @@ gem 'down', '~> 5.0'
gem 'gmail_xoauth'
# Prevent CSV injection
gem 'csv-safe'
+# Support message translation
+gem 'google-cloud-translate'
##-- for active storage --##
gem 'aws-sdk-s3', require: false
diff --git a/Gemfile.lock b/Gemfile.lock
index 28889bcf0..eb12e2b6f 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -289,6 +289,19 @@ GEM
google-cloud-core (~> 1.6)
googleauth (>= 0.16.2, < 2.a)
mini_mime (~> 1.0)
+ google-cloud-translate (3.3.0)
+ google-cloud-core (~> 1.6)
+ google-cloud-translate-v2 (>= 0.0, < 2.a)
+ google-cloud-translate-v3 (>= 0.0, < 2.a)
+ google-cloud-translate-v2 (0.4.0)
+ faraday (>= 0.17.3, < 2.a)
+ google-cloud-core (~> 1.6)
+ googleapis-common-protos (>= 1.3.10, < 2.a)
+ googleapis-common-protos-types (>= 1.0.5, < 2.a)
+ googleauth (>= 0.16.2, < 2.a)
+ google-cloud-translate-v3 (0.5.0)
+ gapic-common (>= 0.10, < 2.a)
+ google-cloud-errors (~> 1.0)
google-protobuf (3.21.7)
google-protobuf (3.21.7-x86_64-darwin)
google-protobuf (3.21.7-x86_64-linux)
@@ -774,6 +787,7 @@ DEPENDENCIES
gmail_xoauth
google-cloud-dialogflow
google-cloud-storage
+ google-cloud-translate
groupdate
haikunator
hairtrigger
diff --git a/app/controllers/api/v1/accounts/conversations/messages_controller.rb b/app/controllers/api/v1/accounts/conversations/messages_controller.rb
index 77a3a7081..319c6763f 100644
--- a/app/controllers/api/v1/accounts/conversations/messages_controller.rb
+++ b/app/controllers/api/v1/accounts/conversations/messages_controller.rb
@@ -18,6 +18,24 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
end
end
+ def translate
+ return head :ok if already_translated_content_available?
+
+ translated_content = Integrations::GoogleTranslate::ProcessorService.new(
+ message: message,
+ target_language: permitted_params[:target_language]
+ ).perform
+
+ if translated_content.present?
+ translations = {}
+ translations[permitted_params[:target_language]] = translated_content
+ translations = message.translations.merge!(translations) if message.translations.present?
+ message.update!(translations: translations)
+ end
+
+ render json: { content: translated_content }
+ end
+
private
def message
@@ -29,6 +47,10 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
end
def permitted_params
- params.permit(:id)
+ params.permit(:id, :target_language)
+ end
+
+ def already_translated_content_available?
+ message.translations.present? && message.translations[permitted_params[:target_language]].present?
end
end
diff --git a/app/javascript/dashboard/api/inbox/message.js b/app/javascript/dashboard/api/inbox/message.js
index f0096cf23..d671b86ff 100644
--- a/app/javascript/dashboard/api/inbox/message.js
+++ b/app/javascript/dashboard/api/inbox/message.js
@@ -80,6 +80,15 @@ class MessageApi extends ApiClient {
params: { before },
});
}
+
+ translateMessage(conversationId, messageId, targetLanguage) {
+ return axios.post(
+ `${this.url}/${conversationId}/messages/${messageId}/translate`,
+ {
+ target_language: targetLanguage,
+ }
+ );
+ }
}
export default new MessageApi();
diff --git a/app/javascript/dashboard/components/widgets/conversation/Message.vue b/app/javascript/dashboard/components/widgets/conversation/Message.vue
index 29e1eade5..461a02116 100644
--- a/app/javascript/dashboard/components/widgets/conversation/Message.vue
+++ b/app/javascript/dashboard/components/widgets/conversation/Message.vue
@@ -73,6 +73,39 @@
:created-at="createdAt"
/>
+
+
+
+ {{ $t('TRANSLATE_MODAL.ORIGINAL_CONTENT') }}
+
+
+
+
+
+
+ {{ $t('TRANSLATE_MODAL.TRANSLATED_CONTENT') }}
+
+
+
+ {{ language }}:
+
+
+
+
+
+
+ {{ $t('TRANSLATE_MODAL.NO_TRANSLATIONS_AVAILABLE') }}
+
+
+
@@ -138,6 +172,7 @@ import alertMixin from 'shared/mixins/alertMixin';
import contentTypeMixin from 'shared/mixins/contentTypeMixin';
import { MESSAGE_TYPE, MESSAGE_STATUS } from 'shared/constants/messages';
import { generateBotMessageContent } from './helpers/botMessageContentHelper';
+import { mapGetters } from 'vuex';
export default {
components: {
@@ -181,9 +216,14 @@ export default {
return {
showContextMenu: false,
hasImageError: false,
+ showTranslateModal: false,
};
},
computed: {
+ ...mapGetters({
+ getAccount: 'accounts/getAccount',
+ currentAccountId: 'getCurrentAccountId',
+ }),
shouldRenderMessage() {
return (
this.hasAttachments ||
@@ -199,6 +239,9 @@ export default {
} = this.contentAttributes.email || {};
return fullHTMLContent || fullTextContent || '';
},
+ translations() {
+ return this.contentAttributes.translations || {};
+ },
displayQuotedButton() {
if (!this.isIncoming) {
return false;
@@ -210,6 +253,9 @@ export default {
return false;
},
+ translationsAvailable() {
+ return !!Object.keys(this.translations).length;
+ },
message() {
if (this.contentType === 'input_csat') {
return this.$t('CONVERSATION.CSAT_REPLY_MESSAGE');
@@ -432,6 +478,19 @@ export default {
onImageLoadError() {
this.hasImageError = true;
},
+ handleTranslate() {
+ const { locale } = this.getAccount(this.currentAccountId);
+ const { conversation_id: conversationId, id: messageId } = this.data;
+ this.$store.dispatch('translateMessage', {
+ conversationId,
+ messageId,
+ targetLanguage: locale || 'en',
+ });
+ this.showTranslateModal = true;
+ },
+ onCloseTranslateModal() {
+ this.showTranslateModal = false;
+ },
},
};
diff --git a/app/javascript/dashboard/i18n/locale/en/conversation.json b/app/javascript/dashboard/i18n/locale/en/conversation.json
index b483d239f..444fd0011 100644
--- a/app/javascript/dashboard/i18n/locale/en/conversation.json
+++ b/app/javascript/dashboard/i18n/locale/en/conversation.json
@@ -165,7 +165,8 @@
"CONTEXT_MENU": {
"COPY": "Copy",
"DELETE": "Delete",
- "CREATE_A_CANNED_RESPONSE": "Add to canned responses"
+ "CREATE_A_CANNED_RESPONSE": "Add to canned responses",
+ "TRANSLATE": "Translate"
}
},
"EMAIL_TRANSCRIPT": {
@@ -253,5 +254,12 @@
"BCC": "Bcc",
"CC": "Cc",
"SUBJECT": "Subject"
+ },
+ "TRANSLATE_MODAL": {
+ "TITLE": "View translated content",
+ "DESC": "You can view the translated content in each langauge.",
+ "ORIGINAL_CONTENT": "Original Content",
+ "TRANSLATED_CONTENT": "Translated Content",
+ "NO_TRANSLATIONS_AVAILABLE": "No translations are available for this content"
}
}
diff --git a/app/javascript/dashboard/modules/conversations/components/MessageContextMenu.vue b/app/javascript/dashboard/modules/conversations/components/MessageContextMenu.vue
index 09ad9c547..d3b98bcac 100644
--- a/app/javascript/dashboard/modules/conversations/components/MessageContextMenu.vue
+++ b/app/javascript/dashboard/modules/conversations/components/MessageContextMenu.vue
@@ -57,6 +57,17 @@
{{ $t('CONVERSATION.CONTEXT_MENU.CREATE_A_CANNED_RESPONSE') }}
+
+
+ {{ $t('CONVERSATION.CONTEXT_MENU.TRANSLATE') }}
+
+
@@ -133,6 +144,11 @@ export default {
this.$track(ACCOUNT_EVENTS.ADDED_TO_CANNED_RESPONSE);
this.isCannedResponseModalOpen = true;
},
+
+ handleTranslate() {
+ this.$emit('translate');
+ this.handleContextMenuClick();
+ },
},
};
diff --git a/app/javascript/dashboard/store/modules/conversations/actions.js b/app/javascript/dashboard/store/modules/conversations/actions.js
index 48fa4fa58..b28c74ba3 100644
--- a/app/javascript/dashboard/store/modules/conversations/actions.js
+++ b/app/javascript/dashboard/store/modules/conversations/actions.js
@@ -12,6 +12,7 @@ import {
import messageReadActions from './actions/messageReadActions';
import AnalyticsHelper from '../../../helper/AnalyticsHelper';
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
+import messageTranslateActions from './actions/messageTranslateActions';
// actions
const actions = {
getConversation: async ({ commit }, conversationId) => {
@@ -341,6 +342,7 @@ const actions = {
commit(types.CLEAR_CONVERSATION_FILTERS);
},
...messageReadActions,
+ ...messageTranslateActions,
};
export default actions;
diff --git a/app/javascript/dashboard/store/modules/conversations/actions/messageTranslateActions.js b/app/javascript/dashboard/store/modules/conversations/actions/messageTranslateActions.js
new file mode 100644
index 000000000..a88c7cb0a
--- /dev/null
+++ b/app/javascript/dashboard/store/modules/conversations/actions/messageTranslateActions.js
@@ -0,0 +1,15 @@
+import MessageApi from '../../../../api/inbox/message';
+
+export default {
+ async translateMessage(_, { conversationId, messageId, targetLanguage }) {
+ try {
+ await MessageApi.translateMessage(
+ conversationId,
+ messageId,
+ targetLanguage
+ );
+ } catch (error) {
+ // ignore error
+ }
+ },
+};
diff --git a/app/javascript/dashboard/store/modules/integrations.js b/app/javascript/dashboard/store/modules/integrations.js
index af4a72cb0..c7518547d 100644
--- a/app/javascript/dashboard/store/modules/integrations.js
+++ b/app/javascript/dashboard/store/modules/integrations.js
@@ -17,7 +17,7 @@ const state = {
};
const isAValidAppIntegration = integration => {
- return ['dialogflow', 'dyte'].includes(integration.id);
+ return ['dialogflow', 'dyte', 'google_translate'].includes(integration.id);
};
export const getters = {
getIntegrations($state) {
diff --git a/app/javascript/shared/components/FluentIcon/dashboard-icons.json b/app/javascript/shared/components/FluentIcon/dashboard-icons.json
index e5c2b097c..49b470b74 100644
--- a/app/javascript/shared/components/FluentIcon/dashboard-icons.json
+++ b/app/javascript/shared/components/FluentIcon/dashboard-icons.json
@@ -170,5 +170,6 @@
"pane-close-outline": "M9.193 9.249a.75.75 0 0 1 1.059-.056l2.5 2.25a.75.75 0 0 1 0 1.114l-2.5 2.25a.75.75 0 0 1-1.004-1.115l1.048-.942H6.75a.75.75 0 1 1 0-1.5h3.546l-1.048-.942a.75.75 0 0 1-.055-1.06ZM22 17.25A2.75 2.75 0 0 1 19.25 20H4.75A2.75 2.75 0 0 1 2 17.25V6.75A2.75 2.75 0 0 1 4.75 4h14.5A2.75 2.75 0 0 1 22 6.75v10.5Zm-2.75 1.25c.69 0 1.25-.56 1.25-1.25V6.749c0-.69-.56-1.25-1.25-1.25h-3.254V18.5h3.254Zm-4.754 0V5.5H4.75c-.69 0-1.25.56-1.25 1.25v10.5c0 .69.56 1.25 1.25 1.25h9.746Z",
"chevron-left-solid": "M15.707 4.293a1 1 0 0 1 0 1.414L9.414 12l6.293 6.293a1 1 0 0 1-1.414 1.414l-7-7a1 1 0 0 1 0-1.414l7-7a1 1 0 0 1 1.414 0Z",
"chevron-right-solid": "M8.293 4.293a1 1 0 0 0 0 1.414L14.586 12l-6.293 6.293a1 1 0 1 0 1.414 1.414l7-7a1 1 0 0 0 0-1.414l-7-7a1 1 0 0 0-1.414 0Z",
- "comment-add-outline": "M12.022 3a6.473 6.473 0 0 0-.709 1.5H5.25A1.75 1.75 0 0 0 3.5 6.25v8.5c0 .966.784 1.75 1.75 1.75h2.249v3.75l5.015-3.75h6.236a1.75 1.75 0 0 0 1.75-1.75l.001-2.483a6.517 6.517 0 0 0 1.5-1.077L22 14.75A3.25 3.25 0 0 1 18.75 18h-5.738L8 21.75a1.25 1.25 0 0 1-1.999-1V18h-.75A3.25 3.25 0 0 1 2 14.75v-8.5A3.25 3.25 0 0 1 5.25 3h6.772ZM17.5 1a5.5 5.5 0 1 1 0 11 5.5 5.5 0 0 1 0-11Zm0 1.5-.09.008a.5.5 0 0 0-.402.402L17 3l-.001 3H14l-.09.008a.5.5 0 0 0-.402.402l-.008.09.008.09a.5.5 0 0 0 .402.402L14 7h2.999L17 10l.008.09a.5.5 0 0 0 .402.402l.09.008.09-.008a.5.5 0 0 0 .402-.402L18 10l-.001-3H21l.09-.008a.5.5 0 0 0 .402-.402l.008-.09-.008-.09a.5.5 0 0 0-.402-.402L21 6h-3.001L18 3l-.008-.09a.5.5 0 0 0-.402-.402L17.5 2.5Z"
+ "comment-add-outline": "M12.022 3a6.473 6.473 0 0 0-.709 1.5H5.25A1.75 1.75 0 0 0 3.5 6.25v8.5c0 .966.784 1.75 1.75 1.75h2.249v3.75l5.015-3.75h6.236a1.75 1.75 0 0 0 1.75-1.75l.001-2.483a6.517 6.517 0 0 0 1.5-1.077L22 14.75A3.25 3.25 0 0 1 18.75 18h-5.738L8 21.75a1.25 1.25 0 0 1-1.999-1V18h-.75A3.25 3.25 0 0 1 2 14.75v-8.5A3.25 3.25 0 0 1 5.25 3h6.772ZM17.5 1a5.5 5.5 0 1 1 0 11 5.5 5.5 0 0 1 0-11Zm0 1.5-.09.008a.5.5 0 0 0-.402.402L17 3l-.001 3H14l-.09.008a.5.5 0 0 0-.402.402l-.008.09.008.09a.5.5 0 0 0 .402.402L14 7h2.999L17 10l.008.09a.5.5 0 0 0 .402.402l.09.008.09-.008a.5.5 0 0 0 .402-.402L18 10l-.001-3H21l.09-.008a.5.5 0 0 0 .402-.402l.008-.09-.008-.09a.5.5 0 0 0-.402-.402L21 6h-3.001L18 3l-.008-.09a.5.5 0 0 0-.402-.402L17.5 2.5Z",
+ "translate-outline": "M16.953 5.303a1 1 0 0 0-1.906-.606c-.124.389-.236.899-.324 1.344-.565.012-1.12 0-1.652-.038a1 1 0 1 0-.142 1.995c.46.032.934.048 1.416.047a25.649 25.649 0 0 0-.24 1.698c-1.263.716-2.142 1.684-2.636 2.7-.624 1.283-.7 2.857.239 3.883.675.736 1.704.758 2.499.588.322-.068.654-.176.988-.32a1 1 0 0 0 1.746-.93 13.17 13.17 0 0 0-.041-.115 8.404 8.404 0 0 0 2.735-4.06c.286.251.507.55.658.864.284.594.334 1.271.099 1.91-.234.633-.78 1.313-1.84 1.843a1 1 0 0 0 .895 1.789c1.44-.72 2.385-1.758 2.821-2.94a4.436 4.436 0 0 0-.17-3.464 4.752 4.752 0 0 0-2.104-2.165C19.998 9.22 20 9.11 20 9a1 1 0 0 0-1.974-.23 5.984 5.984 0 0 0-1.796.138c.047-.305.102-.626.166-.964a20.142 20.142 0 0 0 2.842-.473 1 1 0 0 0-.476-1.942c-.622.152-1.286.272-1.964.358.048-.208.1-.409.155-.584Zm-3.686 8.015c.166-.34.414-.697.758-1.037.02.348.053.67.098.973.083.56.207 1.048.341 1.477a3.41 3.41 0 0 1-.674.227c-.429.092-.588.019-.614.006l-.004-.001c-.162-.193-.329-.774.095-1.645Zm4.498-2.562a6.362 6.362 0 0 1-1.568 2.73 7.763 7.763 0 0 1-.095-.525 10.294 10.294 0 0 1-.088-1.904c.033-.013.067-.024.1-.036l1.651-.265Zm0 0-1.651.265c.602-.212 1.155-.29 1.651-.265ZM7.536 6.29a6.342 6.342 0 0 0-4.456.331 1 1 0 0 0 .848 1.811 4.342 4.342 0 0 1 3.049-.222c.364.107.568.248.69.37.12.123.203.27.257.454.067.225.087.446.09.69a8.195 8.195 0 0 0-.555-.117c-1.146-.199-2.733-.215-4.262.64-1.271.713-1.796 2.168-1.682 3.448.12 1.326.94 2.679 2.572 3.136 1.48.414 2.913-.045 3.877-.507l.08-.04a1 1 0 0 0 1.96-.281V10.5c0-.053.002-.12.005-.2.012-.417.034-1.16-.168-1.838a3.043 3.043 0 0 0-.755-1.29c-.394-.398-.91-.694-1.547-.881h-.003Zm-.419 5.288c.344.06.647.143.887.222v2.197a7.021 7.021 0 0 1-.905.524c-.792.38-1.682.605-2.473.384-.698-.195-1.06-.742-1.119-1.389-.062-.693.243-1.286.667-1.523.987-.553 2.06-.569 2.943-.415Z"
}
diff --git a/app/models/message.rb b/app/models/message.rb
index e96ffd275..6f11d2690 100644
--- a/app/models/message.rb
+++ b/app/models/message.rb
@@ -69,7 +69,8 @@ class Message < ApplicationRecord
# [:external_created_at] : Can specify if the message was created at a different timestamp externally
# [:external_error : Can specify if the message creation failed due to an error at external API
store :content_attributes, accessors: [:submitted_email, :items, :submitted_values, :email, :in_reply_to, :deleted,
- :external_created_at, :story_sender, :story_id, :external_error], coder: JSON
+ :external_created_at, :story_sender, :story_id, :external_error,
+ :translations], coder: JSON
store :external_source_ids, accessors: [:slack], coder: JSON, prefix: :external_source_id
diff --git a/config/integration/apps.yml b/config/integration/apps.yml
index 97145e1c1..d4743a941 100644
--- a/config/integration/apps.yml
+++ b/config/integration/apps.yml
@@ -91,3 +91,40 @@ dyte:
},
]
visible_properties: ["organization_id"]
+google_translate:
+ id: google_translate
+ logo: googletranslate.png
+ i18n_key: google_translate
+ action: /google_translate
+ hook_type: account
+ allow_multiple_hooks: false
+ settings_json_schema: {
+ "type": "object",
+ "properties": {
+ "project_id": { "type": "string" },
+ "credentials": { "type": "object" },
+ },
+ "required": ["project_id", "credentials"],
+ "additionalProperties": false,
+ }
+ settings_form_schema: [
+ {
+ "label": "Google Cloud Project ID",
+ "type": "text",
+ "name": "project_id",
+ "validation": "required",
+ "validationName": "Project Id",
+ },
+ {
+ "label": "Google Cloud Project Key File",
+ "type": "textarea",
+ "name": "credentials",
+ "validation": "required|JSON",
+ "validationName": "Credentials",
+ "validation-messages": {
+ "JSON": "Invalid JSON",
+ "required": "Credentials is required"
+ },
+ },
+ ]
+ visible_properties: ['project_id']
diff --git a/config/locales/en.yml b/config/locales/en.yml
index ea7c24c06..be8904057 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -171,6 +171,9 @@ en:
fullcontact:
name: "Fullcontact"
description: "FullContact integration helps to enrich visitor profiles. Identify the users as soon as they share their email address and offer them tailored customer service. Connect your FullContact to your account by sharing the FullContact API Key."
+ google_translate:
+ name: "Google Translate"
+ description: "Make it easier for agents to translate messages by adding a Google Translate Integration. Google translate helps to identify the language automatically and convert it to the language chosen by the agent/account admin."
public_portal:
search:
search_placeholder: Search for article by title or body...
diff --git a/config/routes.rb b/config/routes.rb
index 02f1e4991..42e6be883 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -74,7 +74,11 @@ Rails.application.routes.draw do
post :filter
end
scope module: :conversations do
- resources :messages, only: [:index, :create, :destroy]
+ resources :messages, only: [:index, :create, :destroy] do
+ member do
+ post :translate
+ end
+ end
resources :assignments, only: [:create]
resources :labels, only: [:create, :index]
resource :participants, only: [:show, :create, :update, :destroy]
diff --git a/lib/integrations/google_translate/processor_service.rb b/lib/integrations/google_translate/processor_service.rb
new file mode 100644
index 000000000..5c0be7545
--- /dev/null
+++ b/lib/integrations/google_translate/processor_service.rb
@@ -0,0 +1,30 @@
+class Integrations::GoogleTranslate::ProcessorService
+ pattr_initialize [:message!, :target_language!]
+
+ def perform
+ return if message.content.blank?
+ return if hook.blank?
+
+ response = client.translate_text(
+ contents: [message.content],
+ target_language_code: target_language,
+ parent: "projects/#{hook.settings['project_id']}"
+ )
+
+ return if response.translations.first.blank?
+
+ response.translations.first.translated_text
+ end
+
+ private
+
+ def hook
+ @hook ||= message.account.hooks.find_by(app_id: 'google_translate')
+ end
+
+ def client
+ @client ||= Google::Cloud::Translate.translation_service do |config|
+ config.credentials = hook.settings['credentials']
+ end
+ end
+end
diff --git a/public/dashboard/images/integrations/googletranslate.png b/public/dashboard/images/integrations/googletranslate.png
new file mode 100644
index 000000000..d502ea051
Binary files /dev/null and b/public/dashboard/images/integrations/googletranslate.png differ