4 # :title: Translator plugin for rbot
6 # Author:: Yaohan Chen <yaohan.chen@gmail.com>
7 # Copyright:: (C) 2007 Yaohan Chen
10 # This plugin allows using rbot to translate text on a few translation services
14 # * Configuration for whether to show translation engine
15 # * Optionally sync default translators with karma.rb ranking
20 # base class for implementing a translation service
22 # direction:: supported translation directions, a hash where each key is a source
23 # language name, and each value is Set of target language names. The
24 # methods in the Direction module are convenient for initializing this
27 INFO = 'Some translation service'
29 class UnsupportedDirectionError < ArgumentError
32 class NoTranslationError < RuntimeError
35 attr_reader :directions, :cache
37 def initialize(directions, cache={})
38 @directions = directions
42 # Many translators use Mechanize, which changed namespace around version 1.0
43 # To support both pre-1.0 and post-1.0 namespaces, we use these auxiliary
44 # method. The translator still needs to require 'mechanize' on initialization
47 return Mechanize if defined? Mechanize
51 # whether the translator supports this direction
52 def support?(from, to)
53 from != to && @directions[from].include?(to)
56 # this implements argument checking and caching. subclasses should define the
57 # do_translate method to implement actual translation
58 def translate(text, from, to)
59 raise UnsupportedDirectionError unless support?(from, to)
60 raise ArgumentError, _("Cannot translate empty string") if text.empty?
61 request = [text, from, to]
62 unless @cache.has_key? request
63 translation = do_translate(text, from, to)
64 raise NoTranslationError if translation.empty?
65 @cache[request] = translation
72 # given the set of supported languages, return a hash suitable for the directions
73 # attribute which includes any language to any other language
74 def self.all_to_all(languages)
75 directions = all_to_none(languages)
76 languages.each {|l| directions[l] = languages.to_set}
80 # a hash suitable for the directions attribute which includes any language from/to
81 # the given set of languages (center_languages)
82 def self.all_from_to(languages, center_languages)
83 directions = all_to_none(languages)
84 center_languages.each {|l| directions[l] = languages - [l]}
85 (languages - center_languages).each {|l| directions[l] = center_languages.to_set}
89 # get a hash from a list of pairs
90 def self.pairs(list_of_pairs)
91 languages = list_of_pairs.flatten.to_set
92 directions = all_to_none(languages)
93 list_of_pairs.each do |(from, to)|
94 directions[from] << to
99 # an empty hash with empty sets as default values
100 def self.all_to_none(languages)
102 # always return empty set when the key is non-existent, but put empty set in the
103 # hash only if the key is one of the languages
104 if languages.include? k
115 class NiftyTranslator < Translator
116 INFO = '@nifty Translation <http://nifty.amikai.com/amitext/indexUTF8.jsp>'
118 def initialize(cache={})
120 super(Translator::Direction.all_from_to(%w[ja en zh_CN ko], %w[ja]), cache)
123 def do_translate(text, from, to)
124 @form ||= mechanize.new.
125 get('http://nifty.amikai.com/amitext/indexUTF8.jsp').
126 forms_with(:name => 'translateForm').last
127 @radio = @form.radiobuttons_with(:name => 'langpair').first
128 @radio.value = "#{from},#{to}".upcase
130 @form.fields_with(:name => 'sourceText').last.value = text
132 @form.submit(@form.buttons_with(:name => 'translate').last).
133 forms_with(:name => 'translateForm').last.fields_with(:name => 'translatedText').last.value
138 class ExciteTranslator < Translator
139 INFO = 'Excite.jp Translation <http://www.excite.co.jp/world/>'
141 def initialize(cache={})
145 super(Translator::Direction.all_from_to(%w[ja en zh_CN zh_TW ko], %w[ja]), cache)
147 @forms = Hash.new do |h, k|
150 h[k] = open_form('english')
151 when 'zh_CN', 'zh_TW'
152 # this way we don't need to fetch the same page twice
153 h['zh_CN'] = h['zh_TW'] = open_form('chinese')
155 h[k] = open_form('korean')
161 mechanize.new.get("http://www.excite.co.jp/world/#{name}").
162 forms_with(:name => 'world').first
165 def do_translate(text, from, to)
166 non_ja_language = from != 'ja' ? from : to
167 form = @forms[non_ja_language]
169 if non_ja_language =~ /zh_(CN|TW)/
170 form_with_fields(:name => 'wb_lp').first.value = "#{from}#{to}".sub(/_(?:CN|TW)/, '').upcase
171 form_with_fields(:name => 'big5').first.value = ($1 == 'TW' ? 'yes' : 'no')
173 # the en<->ja page is in Shift_JIS while other pages are UTF-8
174 text = Iconv.iconv('Shift_JIS', 'UTF-8', text) if non_ja_language == 'en'
175 form.fields_with(:name => 'wb_lp').first.value = "#{from}#{to}".upcase
177 form.fields_with(:name => 'before').first.value = text
178 result = form.submit.forms_with(:name => 'world').first.fields_with(:name => 'after').first.value
179 # the en<->ja page is in Shift_JIS while other pages are UTF-8
180 if non_ja_language == 'en'
181 Iconv.iconv('UTF-8', 'Shift_JIS', result)
190 class GoogleTranslator < Translator
191 INFO = 'Google Translate <http://www.google.com/translate_t>'
194 %w[af sq am ar hy az eu be bn bh bg my ca chr zh zh_CN zh_TW hr
195 cs da dv en eo et tl fi fr gl ka de el gn gu iw hi hu is id iu
196 ga it ja kn kk km ko lv lt mk ms ml mt mr mn ne no or ps fa pl
197 pt_PT pa ro ru sa sr sd si sk sl es sw sv tg ta tl te th bo tr
198 uk ur uz ug vi cy yi auto]
199 def initialize(cache={})
202 super(Translator::Direction.all_to_all(LANGUAGES), cache)
205 def do_translate(text, from, to)
206 langpair = [from == 'auto' ? '' : from, to].map { |e| e.tr('_', '-') }.join("|")
207 raw_json = Irc::Utils.bot.httputil.get_response(URI.escape(
208 "http://ajax.googleapis.com/ajax/services/language/translate?v=1.0&q=#{text}&langpair=#{langpair}")).body
209 response = JSON.parse(raw_json)
211 if response["responseStatus"] != 200
212 raise Translator::NoTranslationError, response["responseDetails"]
214 translation = response["responseData"]["translatedText"]
215 return Utils.decode_html_entities(translation)
221 class BabelfishTranslator < Translator
222 INFO = 'AltaVista Babel Fish Translation <http://babelfish.altavista.com/babelfish/>'
224 def initialize(cache)
226 (_, lang_list) = parse_page
227 language_pairs = lang_list.options.map {|o| o.value.split('_')}.
228 reject {|p| p.empty?}
229 super(Translator::Direction.pairs(language_pairs), cache)
233 form = mechanize.new.get('http://babelfish.altavista.com/babelfish/').
234 forms_with(:name => 'frmTrText').first
235 lang_list = form.fields_with(:name => 'lp').first
239 def do_translate(text, from, to)
240 unless @form && @lang_list
241 @form, @lang_list = parse_page
244 if @form.fields_with(:name => 'trtext').empty?
245 @form.add_field!('trtext', text)
247 @form.fields_with(:name => 'trtext').first.value = text
249 @lang_list.value = "#{from}_#{to}"
250 @form.submit.parser.search("div[@id='result']/div[@style]").inner_html
254 class WorldlingoTranslator < Translator
255 INFO = 'WorldLingo Free Online Translator <http://www.worldlingo.com/en/products_services/worldlingo_translator.html>'
257 LANGUAGES = %w[en fr de it pt es ru nl el sv ar ja ko zh_CN zh_TW]
258 def initialize(cache)
260 super(Translator::Direction.all_to_all(LANGUAGES), cache)
263 def translate(text, from, to)
264 response = Irc::Utils.bot.httputil.get_response(URI.escape(
265 "http://www.worldlingo.com/SEfpX0LV2xIxsIIELJ,2E5nOlz5RArCY,/texttranslate?wl_srcenc=utf-8&wl_trgenc=utf-8&wl_text=#{text}&wl_srclang=#{from.upcase}&wl_trglang=#{to.upcase}"))
266 # WorldLingo seems to respond an XML when error occurs
267 case response['Content-Type']
271 raise Translator::NoTranslationError
276 class TranslatorPlugin < Plugin
277 Config.register Config::IntegerValue.new('translator.timeout',
278 :default => 30, :validate => Proc.new{|v| v > 0},
279 :desc => _("Number of seconds to wait for the translation service before timeout"))
280 Config.register Config::StringValue.new('translator.destination',
282 :desc => _("Default destination language to be used with translate command"))
285 'nifty' => NiftyTranslator,
286 'excite' => ExciteTranslator,
287 'google_translate' => GoogleTranslator,
288 'babelfish' => BabelfishTranslator,
289 'worldlingo' => WorldlingoTranslator,
294 @failed_translators = []
296 TRANSLATORS.each_pair do |name, c|
297 watch_for_fail(name) do
298 @translators[name] = c.new(@registry.sub_registry(name))
299 map "#{name} :from :to *phrase",
300 :action => :cmd_translate, :thread => true
304 Config.register Config::ArrayValue.new('translator.default_list',
305 :default => TRANSLATORS.keys,
306 :validate => Proc.new {|l| l.all? {|t| TRANSLATORS.has_key?(t)}},
307 :desc => _("List of translators to try in order when translator name not specified"),
308 :on_change => Proc.new {|bot, v| update_default})
312 def watch_for_fail(name, &block)
316 @failed_translators << { :name => name, :reason => $!.to_s }
318 warning _("Translator %{name} cannot be used: %{reason}") %
319 {:name => name, :reason => $!}
320 map "#{name} [*args]", :action => :failed_translator,
321 :defaults => {:name => name, :reason => $!}
325 def failed_translator(m, params)
326 m.reply _("Translator %{name} cannot be used: %{reason}") %
327 {:name => params[:name], :reason => params[:reason]}
330 def help(plugin, topic=nil)
331 case (topic.intern rescue nil)
333 unless @failed_translators.empty?
334 failed_list = @failed_translators.map { |t| _("%{bold}%{translator}%{bold}: %{reason}") % {
335 :translator => t[:name],
336 :reason => t[:reason],
340 _("Failed translators: %{list}") % { :list => failed_list.join(", ") }
342 _("None of the translators failed")
345 if @translators.has_key?(plugin)
346 translator = @translators[plugin]
347 _('%{translator} <from> <to> <phrase> => Look up phrase using %{info}, supported from -> to languages: %{directions}') % {
348 :translator => plugin,
349 :info => translator.class::INFO,
350 :directions => translator.directions.map do |source, targets|
351 _('%{source} -> %{targets}') %
352 {:source => source, :targets => targets.to_a.join(', ')}
356 help_str = _('Command: <translator> <from> <to> <phrase>, where <translator> is one of: %{translators}. If "translator" is used in place of the translator name, the first translator in translator.default_list which supports the specified direction will be picked automatically. Use "help <translator>" to look up supported from and to languages') %
357 {:translators => @translators.keys.join(', ')}
359 help_str << "\n" + _("%{bold}Note%{bold}: %{failed_amt} translators failed, see %{reverse}%{prefix}help translate failed%{reverse} for details") % {
360 :failed_amt => @failed_translators.size,
363 :prefix => @bot.config['core.address_prefix'].first
372 @languages ||= @translators.map { |t| t.last.directions.keys }.flatten.uniq
376 @default_translators = bot.config['translator.default_list'] & @translators.keys
379 def cmd_translator(m, params)
380 params[:to] = @bot.config['translator.destination'] if params[:to].nil?
381 params[:from] ||= 'auto'
382 translator = @default_translators.find {|t| @translators[t].support?(params[:from], params[:to])}
385 cmd_translate m, params.merge({:translator => translator, :show_provider => false})
387 # When translate command is used without source language, "auto" as source
388 # language is assumed. It means that google translator is used and we let google
389 # figure out what the source language is.
391 # Problem is that the google translator will fail if the system that the bot is
392 # running on does not have the json gem installed.
393 if params[:from] == 'auto'
394 m.reply _("Unable to auto-detect source language due to broken google translator, see %{reverse}%{prefix}help translate failed%{reverse} for details") % {
396 :prefix => @bot.config['core.address_prefix'].first
399 m.reply _('None of the default translators (translator.default_list) supports translating from %{source} to %{target}') % {:source => params[:from], :target => params[:to]}
404 def cmd_translate(m, params)
405 # get the first word of the command
406 tname = params[:translator] || m.message[/\A(\w+)\s/, 1]
407 translator = @translators[tname]
408 from, to, phrase = params[:from], params[:to], params[:phrase].to_s
410 watch_for_fail(tname) do
412 translation = Timeout.timeout(@bot.config['translator.timeout']) do
413 translator.translate(phrase, from, to)
415 m.reply(if params[:show_provider]
416 _('%{translation} (provided by %{translator})') %
417 {:translation => translation, :translator => tname.gsub("_", " ")}
422 rescue Translator::UnsupportedDirectionError
423 m.reply _("%{translator} doesn't support translating from %{source} to %{target}") %
424 {:translator => tname, :source => from, :target => to}
425 rescue Translator::NoTranslationError
426 m.reply _('%{translator} failed to provide a translation') %
427 {:translator => tname}
428 rescue Timeout::Error
429 m.reply _('The translator timed out')
433 m.reply _('No translator called %{name}') % {:name => tname}
437 # URL translation has nothing to do with Translators so let's make it
438 # separate, and Google exclusive for now
439 def cmd_translate_url(m, params)
440 params[:to] = @bot.config['translator.destination'] if params[:to].nil?
441 params[:from] ||= 'auto'
443 translate_url = "http://translate.google.com/translate?sl=%{from}&tl=%{to}&u=%{url}" % {
444 :from => params[:from],
446 :url => CGI.escape(params[:url].to_s)
449 m.reply(translate_url)
453 plugin = TranslatorPlugin.new
454 req = Hash[*%w(from to).map { |e| [e.to_sym, /#{plugin.languages.join("|")}/] }.flatten]
456 plugin.map 'translate [:from] [:to] :url',
457 :action => :cmd_translate_url, :requirements => req.merge(:url => %r{^https?://[^\s]*})
458 plugin.map 'translator [:from] [:to] :url',
459 :action => :cmd_translate_url, :requirements => req.merge(:url => %r{^https?://[^\s]*})
460 plugin.map 'translate [:from] [:to] *phrase',
461 :action => :cmd_translator, :thread => true, :requirements => req
462 plugin.map 'translator [:from] [:to] *phrase',
463 :action => :cmd_translator, :thread => true, :requirements => req