diff --git a/app/services/discourse_translator/provider/huaweicloud.rb b/app/services/discourse_translator/provider/huaweicloud.rb new file mode 100644 index 00000000..0b6c3431 --- /dev/null +++ b/app/services/discourse_translator/provider/huaweicloud.rb @@ -0,0 +1,387 @@ +# frozen_string_literal: true + +require 'connection_pool' +require 'json' + +module DiscourseTranslator + module Provider + class Huaweicloud < BaseProvider + # 常量定义 + TRANSLATE_URI = "https://nlp-ext.:project_name.myhuaweicloud.com/v1/:project_id/machine-translation/text-translation".freeze + DETECT_URI = "https://nlp-ext.:project_name.myhuaweicloud.com/v1/:project_id/machine-translation/language-detection".freeze + ISSUE_TOKEN_URI = "https://iam.:project_name.myhuaweicloud.com/v3/auth/tokens".freeze + LENGTH_LIMIT = 2000 + + LANG_SUPPORT_ERROR = 999 + TRANSLATE_API_ERROR = 777 + + SUPPORTED_LANG_MAPPING = { + ar: 'ar', + de: 'de', + ru: 'ru', + fr: 'fr', + ko: 'ko', + pt: 'pt', + ja: 'ja', + th: 'th', + tr: 'tr', + es: 'es', + en: 'en', + en_GB: 'en', + en_US: 'en', + vi: 'vi', + zh_CN: 'zh', + zh_TW: 'zh', + zh: "zh", + }.freeze + + CONNECTION_POOL = ConnectionPool.new(size: 3, timeout: 5) do + Faraday.new do |conn| + conn.adapter FinalDestination::FaradayAdapter + conn.options.timeout = 10 + conn.request :retry, { + max: 2, + interval: 0.5, + exceptions: [Faraday::TimeoutError, Faraday::ConnectionFailed] + } + end + end + + def self.access_token + existing_token = Discourse.redis.get(cache_key) + if existing_token + Rails.logger.info("Huaweicloud: Using cached access token") + return existing_token + end + + CONNECTION_POOL.with do |connection| + url = ISSUE_TOKEN_URI.dup.sub(':project_name', SiteSetting.translator_huaweicloud_project_name) + + body = { + auth: { + identity: { + methods: ["password"], + password: { + user: { + domain: { name: ENV["SENSITIVE_DOMAIN_NAME"] }, + name: ENV["SENSITIVE_NAME"], + password: ENV["SENSITIVE_PASSWORD"] + } + } + }, + scope: { + project: { + id: SiteSetting.translator_huaweicloud_project_id, + name: SiteSetting.translator_huaweicloud_project_name + } + } + } + }.to_json + + begin + response = connection.post( + url, + body, + 'Content-Type' => 'application/json;charset=utf8' + ) + + if response.status == 201 && response.headers['x-subject-token'] + token = response.headers['x-subject-token'] + Discourse.redis.setex(cache_key, 23.hours.to_i, token) + token + else + Rails.logger.error("Huaweicloud: Failed to obtain access token. Status: #{response.status}, Body: #{response.body}") + handle_token_error(response) + end + rescue => e + Rails.logger.error("Huaweicloud: Exception while requesting access token: #{e.class} - #{e.message}\n#{e.backtrace.join("\n")}") + raise + end + end + end + + def self.detect!(topic_or_post) + url = DETECT_URI.dup + .sub(':project_name', SiteSetting.translator_huaweicloud_project_name) + .sub(':project_id', SiteSetting.translator_huaweicloud_project_id) + + text = text_for_detection(topic_or_post).truncate(LENGTH_LIMIT, omission: nil) + + res = result( + url, + :post, + { + 'X-Auth-Token' => access_token, + 'Content-Type' => 'application/json;charset=utf8' + }, + { text: text } + ) + + if res == TRANSLATE_API_ERROR || res.nil? + Rails.logger.error("Huaweicloud: Language detection failed") + nil + else + Rails.logger.info("Huaweicloud: Detected language: #{res['detected_language']}") + res['detected_language'] + end + end + + def self.translate_post!(post, target_locale_sym = I18n.locale, opts = {}) + raw = opts.key?(:raw) ? opts[:raw] : !opts[:cooked] + text = text_for_translation(post, raw: raw) + + begin + if raw + translate_text!(text, target_locale_sym) + else + parsed_html = Nokogiri::HTML(text) + + traverse_result = traverse(parsed_html, target_locale_sym) + if traverse_result + result = parsed_html.inner_html + result + else + raise TranslatorError.new(I18n.t("translator.huaweicloud.fail")) + end + end + rescue => e + Rails.logger.error("Huaweicloud: Exception in translate_post!: #{e.class} - #{e.message}\n#{e.backtrace.join("\n")}") + raise + end + end + + def self.translate_topic!(topic, target_locale_sym = I18n.locale) + text = text_for_translation(topic) + translate_text!(text, target_locale_sym) + end + + def self.translate_text!(text, target_locale_sym = I18n.locale) + detected_lang = detect_text(text) + + target_lang = SUPPORTED_LANG_MAPPING[target_locale_sym] + + url = TRANSLATE_URI.dup + .sub(':project_name', SiteSetting.translator_huaweicloud_project_name) + .sub(':project_id', SiteSetting.translator_huaweicloud_project_id) + + res = result( + url, + :post, + { + 'X-Auth-Token' => access_token, + 'Content-Type' => 'application/json;charset=utf8' + }, + { + text: text, + from: detected_lang, + to: target_lang + } + ) + + case res + when Hash + res["translated_text"] + when LANG_SUPPORT_ERROR + LANG_SUPPORT_ERROR + when TRANSLATE_API_ERROR + TRANSLATE_API_ERROR + else + nil + end + end + + def self.translate_supported?(source_lang, target_lang) + return false unless source_lang && target_lang + + source_supported = SUPPORTED_LANG_MAPPING.key?(source_lang.to_sym) + target_code = SUPPORTED_LANG_MAPPING[target_lang.to_sym] || + target_lang.to_s.split('_').first + target_supported = SUPPORTED_LANG_MAPPING.value?(target_code) + + source_supported && target_supported + end + + private + + def self.access_token_key + "huaweicloud-translator" + end + + def self.traverse(html, target_locale_sym) + text_nodes = html.css('text').select { |n| n.content.present? && n.content != "\n" } + text_nodes = [] + html.traverse do |node| + if node.text? && node.content.present? && node.content.strip != "" + text_nodes << node + end + end + + return nil if text_nodes.empty? + + translation_requests = prepare_translation_requests(text_nodes) + + translated_texts = process_translation_requests(translation_requests, target_locale_sym) + + translated_texts = retract_out_char(translated_texts) + + replacement = parse_result(translated_texts) + + if translated_texts && text_nodes.size == replacement.size + i = 0 + html.traverse do |node| + if node.name == 'text' && !node.content.blank? && node.content != "\n" + node.content = replacement[i] + i = i + 1 + end + end + html + else + Rails.logger.error("Huaweicloud: Translation failed - nodes: #{text_nodes.size}, results: #{translated_texts&.size || 'nil'}") + nil + end + rescue => e + Rails.logger.error("Huaweicloud: Exception in traverse: #{e.class} - #{e.message}\n#{e.backtrace.join("\n")}") + nil + end + + def self.detect_text(text) + url = DETECT_URI.dup + .sub(':project_name', SiteSetting.translator_huaweicloud_project_name) + .sub(':project_id', SiteSetting.translator_huaweicloud_project_id) + + res = result( + url, + :post, + { + 'X-Auth-Token' => access_token, + 'Content-Type' => 'application/json;charset=utf8' + }, + { text: text.truncate(LENGTH_LIMIT, omission: nil) } + ) + + res == TRANSLATE_API_ERROR || res.nil? ? nil : res['detected_language'] + end + + def self.prepare_translation_requests(text_nodes) + requests = [] + current_request = "" + + #使用标签包含每个翻译节点 + text_nodes.each do |node| + content = node.content.gsub("\n", "
") + wrapped_content = "

#{content}

\n" + + if (current_request + wrapped_content).bytesize > LENGTH_LIMIT + requests << current_request unless current_request.empty? + current_request = wrapped_content + else + current_request += wrapped_content + end + end + + requests << current_request unless current_request.empty? + requests + end + + def self.process_translation_requests(requests, target_locale_sym) + + requests.map.with_index do |request, i| + response = translate_text!(request, target_locale_sym) + + case response + when Hash + response["translated_text"] || request + when String + response + when LANG_SUPPORT_ERROR + Rails.logger.warn("Huaweicloud: Unsupported language in request #{i+1}, using original text") + request + else + Rails.logger.error("Huaweicloud: Failed to translate request #{i+1}") + request + end + end + rescue => e + Rails.logger.error("Huaweicloud: Exception in process_translation_requests: #{e.class} - #{e.message}") + requests + end + + def self.retract_out_char(input) + str = input.is_a?(Array) ? input.join("\n") : input.to_s + + return "" if str.empty? + + paras = str.split("\n").map do |para| + para = para.gsub("

", '') + para = para.reverse.gsub(">p/<", '').reverse + para.gsub("
", "\n") + end + + paras.map { |para| "

#{para}

\n" }.join + end + + def self.parse_result(str) + parsed_res = Nokogiri::HTML(str) + ans = [] + parsed_res.traverse do |node| + if node.name == 'p' && !node.content.blank? && node.content != "\n" + ans << node.content + end + end + ans + end + + def self.apply_translations(text_nodes, translated_texts) + text_nodes.each_with_index do |node, i| + node.content = translated_texts[i] if translated_texts[i] + end + end + + def self.handle_token_error(response) + if response.body.blank? + error_msg = "Huaweicloud: Missing token in response" + Rails.logger.error(error_msg) + raise TranslatorError.new(I18n.t("translator.huaweicloud.missing_token")) + else + error = JSON.parse(response.body)["error"] rescue {} + error_message = "#{error['code'] || 'unknown'}: #{error['message'] || 'unknown error'}" + Rails.logger.error("Huaweicloud: Token error - #{error_message}") + raise TranslatorError.new(error_message) + end + end + + def self.result(url, method, headers, body) + CONNECTION_POOL.with do |connection| + begin + response = connection.run_request( + method, + url, + body.to_json, + headers + ) + + parsed_body = JSON.parse(response.body) rescue nil + + if response.status != 200 + Rails.logger.error("Huaweicloud: API returned error status: #{response.status}") + if parsed_body && "Language or Scene is not supported. ".in?(parsed_body["error_msg"]) + LANG_SUPPORT_ERROR + else + TRANSLATE_API_ERROR + end + else + parsed_body + end + rescue => e + Rails.logger.error("Huaweicloud: Exception in API request: #{e.class} - #{e.message}\n#{e.backtrace.join("\n")}") + TRANSLATE_API_ERROR + end + end + end + + def self.cache_key + "#{access_token_key}-#{SiteSetting.translator_huaweicloud_project_id}" + end + end + end +end \ No newline at end of file diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 886c63eb..f7efae0d 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -33,6 +33,11 @@ en: missing_token: "The translator was unable to retrieve a valid token." missing_key: "No Azure Subscription Key provided." + huaweicloud: + missing_token: "The HuaweiCloud translator was unable to retrieve a valid token." + no_lang: "Translatation error, no language detected." + fail: "Translatation error" + discourse_ai: not_installed: "You need to install the discourse-ai plugin to use this feature." ai_helper_required: 'You need to configure the ai helper to use this feature.' diff --git a/config/locales/server.zh_CN.yml b/config/locales/server.zh_CN.yml index bb956650..d05e8f93 100644 --- a/config/locales/server.zh_CN.yml +++ b/config/locales/server.zh_CN.yml @@ -22,6 +22,9 @@ zh_CN: translator_azure_custom_subdomain: "如果将虚拟网络或防火墙用于 Azure 认知服务,则为必需。注意:仅输入自定义子域,而不是完整的自定义端点。" restrict_translation_by_group: "只有允许的群组才能翻译" restrict_translation_by_poster_group: "仅允许翻译允许的群组中用户发布的帖子。如果为空,则允许翻译所有用户的帖子。" + translator_huaweicloud_project_name: "华为云文本翻译服务区域" + translator_huaweicloud_project_id: "华为云文本翻译服务区域ID" + translator_debug_info: "是否输出详细的调试日志" translator: not_supported: "翻译器不支持此语言。" too_long: "帖子过长,翻译器无法翻译。" @@ -34,6 +37,10 @@ zh_CN: discourse_ai: not_installed: "您需要安装 discourse-ai 插件才能使用此功能。" ai_helper_required: '您需要配置 AI 助手才能使用此功能。' + huaweicloud: + missing_token: "华为云翻译器无法检索有效令牌。" + no_lang: "翻译器出现错误, 未检测到语言种类。" + fail: "翻译器出现错误。" not_in_group: user_not_in_group: "您不属于允许翻译的群组。" poster_not_in_group: "帖子不是由允许的群组中的用户发布的。" diff --git a/config/settings.yml b/config/settings.yml index 65bdf0ff..99efe3ad 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -3,7 +3,7 @@ discourse_translator: default: false client: true translator_provider: - default: 'Microsoft' + default: 'Huaweicloud' client: true type: enum choices: @@ -12,6 +12,7 @@ discourse_translator: - Amazon - Yandex - LibreTranslate + - Huaweicloud translator_azure_subscription_key: default: '' translator_azure_region: @@ -83,6 +84,12 @@ discourse_translator: default: '' translator_yandex_api_key: default: '' + translator_huaweicloud_project_name: + default: '' + translator_huaweicloud_project_id: + default: '' + translator_debug_info: + default: false translator_libretranslate_endpoint: default: '' translator_libretranslate_api_key: