translator: connect to sites only when necessary
[rbot] / data / rbot / plugins / translator.rb
1 #-- vim:sw=2:et
2 #++
3 #
4 # :title: Translator plugin for rbot
5 #
6 # Author:: Yaohan Chen <yaohan.chen@gmail.com>
7 # Copyright:: (C) 2007 Yaohan Chen
8 # License:: GPLv2
9 #
10 # This plugin allows using rbot to translate text on a few translation services
11 #
12 # TODO
13 #
14 # * Configuration for whether to show translation engine
15 # * Optionally sync default translators with karma.rb ranking
16
17 require 'set'
18 require 'timeout'
19
20 # base class for implementing a translation service
21 # = Attributes
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
25 #             attribute
26 class Translator
27   INFO = 'Some translation service'
28
29   class UnsupportedDirectionError < ArgumentError
30   end
31
32   class NoTranslationError < RuntimeError
33   end
34
35   attr_reader :directions, :cache
36
37   def initialize(directions, cache={})
38     @directions = directions
39     @cache = cache
40   end
41
42
43   # whether the translator supports this direction
44   def support?(from, to)
45     from != to && @directions[from].include?(to)
46   end
47
48   # this implements argument checking and caching. subclasses should define the
49   # do_translate method to implement actual translation
50   def translate(text, from, to)
51     raise UnsupportedDirectionError unless support?(from, to)
52     raise ArgumentError, _("Cannot translate empty string") if text.empty?
53     request = [text, from, to]
54     unless @cache.has_key? request
55       translation = do_translate(text, from, to)
56       raise NoTranslationError if translation.empty?
57       @cache[request] = translation
58     else
59       @cache[request]
60     end
61   end
62
63   module Direction
64     # given the set of supported languages, return a hash suitable for the directions
65     # attribute which includes any language to any other language
66     def self.all_to_all(languages)
67       directions = all_to_none(languages)
68       languages.each {|l| directions[l] = languages.to_set}
69       directions
70     end
71
72     # a hash suitable for the directions attribute which includes any language from/to
73     # the given set of languages (center_languages)
74     def self.all_from_to(languages, center_languages)
75       directions = all_to_none(languages)
76       center_languages.each {|l| directions[l] = languages - [l]}
77       (languages - center_languages).each {|l| directions[l] = center_languages.to_set}
78       directions
79     end
80
81     # get a hash from a list of pairs
82     def self.pairs(list_of_pairs)
83       languages = list_of_pairs.flatten.to_set
84       directions = all_to_none(languages)
85       list_of_pairs.each do |(from, to)|
86         directions[from] << to
87       end
88       directions
89     end
90
91     # an empty hash with empty sets as default values
92     def self.all_to_none(languages)
93       Hash.new do |h, k|
94         # always return empty set when the key is non-existent, but put empty set in the
95         # hash only if the key is one of the languages
96         if languages.include? k
97           h[k] = Set.new
98         else
99           Set.new
100         end
101       end
102     end
103   end
104 end
105
106
107 class NiftyTranslator < Translator
108   INFO = '@nifty Translation <http://nifty.amikai.com/amitext/indexUTF8.jsp>'
109
110   def initialize(cache={})
111    require 'mechanize'
112    super(Translator::Direction.all_from_to(%w[ja en zh_CN ko], %w[ja]), cache)
113   end
114
115   def do_translate(text, from, to)
116     @form ||= WWW::Mechanize.new.
117               get('http://nifty.amikai.com/amitext/indexUTF8.jsp').
118               forms_with(:name => 'translateForm').last
119     @radio = @form.radiobuttons_with(:name => 'langpair').first
120     @radio.value = "#{from},#{to}".upcase
121     @radio.check
122     @form.fields_with(:name => 'sourceText').last.value = text
123
124     @form.submit(@form.buttons_with(:name => 'translate').last).
125           forms_with(:name => 'translateForm').last.fields_with(:name => 'translatedText').last.value
126   end
127 end
128
129
130 class ExciteTranslator < Translator
131   INFO = 'Excite.jp Translation <http://www.excite.co.jp/world/>'
132
133   def initialize(cache={})
134     require 'mechanize'
135     require 'iconv'
136
137     super(Translator::Direction.all_from_to(%w[ja en zh_CN zh_TW ko], %w[ja]), cache)
138
139     @forms = Hash.new do |h, k|
140       case k
141       when 'en'
142         h[k] = open_form('english')
143       when 'zh_CN', 'zh_TW'
144         # this way we don't need to fetch the same page twice
145         h['zh_CN'] = h['zh_TW'] = open_form('chinese')
146       when 'ko'
147         h[k] = open_form('korean')
148       end
149     end
150   end
151
152   def open_form(name)
153     WWW::Mechanize.new.get("http://www.excite.co.jp/world/#{name}").
154                    forms_with(:name => 'world').first
155   end
156
157   def do_translate(text, from, to)
158     non_ja_language = from != 'ja' ? from : to
159     form = @forms[non_ja_language]
160
161     if non_ja_language =~ /zh_(CN|TW)/
162       form_with_fields(:name => 'wb_lp').first.value = "#{from}#{to}".sub(/_(?:CN|TW)/, '').upcase
163       form_with_fields(:name => 'big5').first.value = ($1 == 'TW' ? 'yes' : 'no')
164     else
165       # the en<->ja page is in Shift_JIS while other pages are UTF-8
166       text = Iconv.iconv('Shift_JIS', 'UTF-8', text) if non_ja_language == 'en'
167       form.fields_with(:name => 'wb_lp').first.value = "#{from}#{to}".upcase
168     end
169     form.fields_with(:name => 'before').first.value = text
170     result = form.submit.forms_with(:name => 'world').first.fields_with(:name => 'after').first.value
171     # the en<->ja page is in Shift_JIS while other pages are UTF-8
172     if non_ja_language == 'en'
173       Iconv.iconv('UTF-8', 'Shift_JIS', result)
174     else
175       result
176     end
177
178   end
179 end
180
181
182 class GoogleTranslator < Translator
183   INFO = 'Google Translate <http://www.google.com/translate_t>'
184
185   LANGUAGES =
186     %w[af sq am ar hy az eu be bn bh bg my ca chr zh zh_CN zh_TW hr
187     cs da dv en eo et tl fi fr gl ka de el gn gu iw hi hu is id iu
188     ga it ja kn kk km ko lv lt mk ms ml mt mr mn ne no or ps fa pl
189     pt_PT pa ro ru sa sr sd si sk sl es sw sv tg ta tl te th bo tr
190     uk ur uz ug vi cy yi auto]
191   def initialize(cache={})
192     require "uri"
193     require "json"
194     super(Translator::Direction.all_to_all(LANGUAGES), cache)
195   end
196
197   def do_translate(text, from, to)
198     langpair = [from == 'auto' ? '' : from, to].map { |e| e.tr('_', '-') }.join("|")
199     raw_json = Irc::Utils.bot.httputil.get_response(URI.escape(
200                "http://ajax.googleapis.com/ajax/services/language/translate?v=1.0&q=#{text}&langpair=#{langpair}")).body
201     response = JSON.parse(raw_json)
202
203     if response["responseStatus"] != 200
204       raise Translator::NoTranslationError, response["responseDetails"]
205     else
206       translation = response["responseData"]["translatedText"]
207       return Utils.decode_html_entities(translation)
208     end
209   end
210 end
211
212
213 class BabelfishTranslator < Translator
214   INFO = 'AltaVista Babel Fish Translation <http://babelfish.altavista.com/babelfish/>'
215
216   def initialize(cache)
217     require 'mechanize'
218     form = WWW::Mechanize.new.get('http://babelfish.altavista.com/babelfish/').
219            forms_with(:name => 'frmTrText').first
220     lang_list = form.fields_with(:name => 'lp').first
221     language_pairs = lang_list.options.map {|o| o.value.split('_')}.
222                                            reject {|p| p.empty?}
223     super(Translator::Direction.pairs(language_pairs), cache)
224   end
225
226   def do_translate(text, from, to)
227     @form ||= WWW::Mechanize.new.get('http://babelfish.altavista.com/babelfish/').
228               forms_with(:name => 'frmTrText').first
229
230     if @form.fields_with(:name => 'trtext').empty?
231       @form.add_field!('trtext', text)
232     else
233       @form.fields_with(:name => 'trtext').first.value = text
234     end
235     @lang_list.value = "#{from}_#{to}"
236     @form.submit.parser.search("div[@id='result']/div[@style]").inner_html
237   end
238 end
239
240 class WorldlingoTranslator < Translator
241   INFO = 'WorldLingo Free Online Translator <http://www.worldlingo.com/en/products_services/worldlingo_translator.html>'
242
243   LANGUAGES = %w[en fr de it pt es ru nl el sv ar ja ko zh_CN zh_TW]
244   def initialize(cache)
245     require 'uri'
246     super(Translator::Direction.all_to_all(LANGUAGES), cache)
247   end
248
249   def translate(text, from, to)
250     response = Irc::Utils.bot.httputil.get_response(URI.escape(
251                "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}"))
252     # WorldLingo seems to respond an XML when error occurs
253     case response['Content-Type']
254     when %r'text/plain'
255       response.body
256     else
257       raise Translator::NoTranslationError
258     end
259   end
260 end
261
262 class TranslatorPlugin < Plugin
263   Config.register Config::IntegerValue.new('translator.timeout',
264     :default => 30, :validate => Proc.new{|v| v > 0},
265     :desc => _("Number of seconds to wait for the translation service before timeout"))
266   Config.register Config::StringValue.new('translator.destination',
267     :default => "en",
268     :desc => _("Default destination language to be used with translate command"))
269
270   TRANSLATORS = {
271     'nifty' => NiftyTranslator,
272     'excite' => ExciteTranslator,
273     'google_translate' => GoogleTranslator,
274     'babelfish' => BabelfishTranslator,
275     'worldlingo' => WorldlingoTranslator,
276   }
277
278   def initialize
279     super
280     @failed_translators = []
281     @translators = {}
282     TRANSLATORS.each_pair do |name, c|
283       watch_for_fail(name) do
284         @translators[name] = c.new(@registry.sub_registry(name))
285         map "#{name} :from :to *phrase",
286           :action => :cmd_translate, :thread => true
287       end
288     end
289
290     Config.register Config::ArrayValue.new('translator.default_list',
291       :default => TRANSLATORS.keys,
292       :validate => Proc.new {|l| l.all? {|t| TRANSLATORS.has_key?(t)}},
293       :desc => _("List of translators to try in order when translator name not specified"),
294       :on_change => Proc.new {|bot, v| update_default})
295     update_default
296   end
297
298   def watch_for_fail(name, &block)
299     begin
300       yield
301     rescue Exception
302       @failed_translators << { :name => name, :reason => $!.to_s }
303
304       warning _("Translator %{name} cannot be used: %{reason}") %
305              {:name => name, :reason => $!}
306       map "#{name} [*args]", :action => :failed_translator,
307                              :defaults => {:name => name, :reason => $!}
308     end
309   end
310
311   def failed_translator(m, params)
312     m.reply _("Translator %{name} cannot be used: %{reason}") %
313             {:name => params[:name], :reason => params[:reason]}
314   end
315
316   def help(plugin, topic=nil)
317     case (topic.intern rescue nil)
318     when :failed
319       unless @failed_translators.empty?
320         failed_list = @failed_translators.map { |t| _("%{bold}%{translator}%{bold}: %{reason}") % {
321           :translator => t[:name],
322           :reason => t[:reason],
323           :bold => Bold
324         }}
325
326         _("Failed translators: %{list}") % { :list => failed_list.join(", ") }
327       else
328         _("None of the translators failed")
329       end
330     else
331       if @translators.has_key?(plugin)
332         translator = @translators[plugin]
333         _('%{translator} <from> <to> <phrase> => Look up phrase using %{info}, supported from -> to languages: %{directions}') % {
334           :translator => plugin,
335           :info => translator.class::INFO,
336           :directions => translator.directions.map do |source, targets|
337                            _('%{source} -> %{targets}') %
338                            {:source => source, :targets => targets.to_a.join(', ')}
339                          end.join(' | ')
340         }
341       else
342         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') %
343                      {:translators => @translators.keys.join(', ')}
344
345         help_str << "\n" + _("%{bold}Note%{bold}: %{failed_amt} translators failed, see %{reverse}%{prefix}help translate failed%{reverse} for details") % {
346           :failed_amt => @failed_translators.size,
347           :bold => Bold,
348           :reverse => Reverse,
349           :prefix => @bot.config['core.address_prefix'].first
350         }
351
352         help_str
353       end
354     end
355   end
356
357   def languages
358     @languages ||= @translators.map { |t| t.last.directions.keys }.flatten.uniq
359   end
360
361   def update_default
362     @default_translators = bot.config['translator.default_list'] & @translators.keys
363   end
364
365   def cmd_translator(m, params)
366     params[:to] = @bot.config['translator.destination'] if params[:to].nil?
367     params[:from] ||= 'auto'
368     translator = @default_translators.find {|t| @translators[t].support?(params[:from], params[:to])}
369
370     if translator
371       cmd_translate m, params.merge({:translator => translator, :show_provider => true})
372     else
373       # When translate command is used without source language, "auto" as source
374       # language is assumed. It means that google translator is used and we let google
375       # figure out what the source language is.
376       #
377       # Problem is that the google translator will fail if the system that the bot is
378       # running on does not have the json gem installed.
379       if params[:from] == 'auto'
380         m.reply _("Unable to auto-detect source language due to broken google translator, see %{reverse}%{prefix}help translate failed%{reverse} for details") % {
381           :reverse => Reverse,
382           :prefix => @bot.config['core.address_prefix'].first
383         }
384       else
385         m.reply _('None of the default translators (translator.default_list) supports translating from %{source} to %{target}') % {:source => params[:from], :target => params[:to]}
386       end
387     end
388   end
389
390   def cmd_translate(m, params)
391     # get the first word of the command
392     tname = params[:translator] || m.message[/\A(\w+)\s/, 1]
393     translator = @translators[tname]
394     from, to, phrase = params[:from], params[:to], params[:phrase].to_s
395     if translator
396       watch_for_fail(tname) do
397         begin
398           translation = Timeout.timeout(@bot.config['translator.timeout']) do
399             translator.translate(phrase, from, to)
400           end
401           m.reply(if params[:show_provider]
402                     _('%{translation} (provided by %{translator})') %
403                       {:translation => translation, :translator => tname.gsub("_", " ")}
404                   else
405                     translation
406                   end)
407
408         rescue Translator::UnsupportedDirectionError
409           m.reply _("%{translator} doesn't support translating from %{source} to %{target}") %
410                   {:translator => tname, :source => from, :target => to}
411         rescue Translator::NoTranslationError
412           m.reply _('%{translator} failed to provide a translation') %
413                   {:translator => tname}
414         rescue Timeout::Error
415           m.reply _('The translator timed out')
416         end
417       end
418     else
419       m.reply _('No translator called %{name}') % {:name => tname}
420     end
421   end
422 end
423
424 plugin = TranslatorPlugin.new
425 req = Hash[*%w(from to).map { |e| [e.to_sym, /#{plugin.languages.join("|")}/] }.flatten]
426
427 plugin.map 'translate [:from] [:to] *phrase',
428            :action => :cmd_translator, :thread => true, :requirements => req
429 plugin.map 'translator [:from] [:to] *phrase',
430            :action => :cmd_translator, :thread => true, :requirements => req