namespaces: move rbot-specific classes and modules from Irc::* to Irc::Bot::*
[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 require 'set'
13 require 'timeout'
14
15 # base class for implementing a translation service
16 # = Attributes
17 # direction:: supported translation directions, a hash where each key is a source
18 #             language name, and each value is Set of target language names. The
19 #             methods in the Direction module are convenient for initializing this
20 #             attribute
21 class Translator
22   INFO = 'Some translation service'
23
24   class UnsupportedDirectionError < ArgumentError
25   end
26
27   class NoTranslationError < RuntimeError
28   end
29
30   attr_reader :directions, :cache
31
32   def initialize(directions, cache={})
33     @directions = directions
34     @cache = cache
35   end
36
37  
38   # whether the translator supports this direction
39   def support?(from, to)
40     from != to && @directions[from].include?(to)
41   end
42
43   # this implements argument checking and caching. subclasses should define the
44   # do_translate method to implement actual translation
45   def translate(text, from, to)
46     raise UnsupportedDirectionError unless support?(from, to)
47     raise ArgumentError, _("Cannot translate empty string") if text.empty?
48     request = [text, from, to]
49     unless @cache.has_key? request
50       translation = do_translate(text, from, to)
51       raise NoTranslationError if translation.empty?
52       @cache[request] = translation
53     else
54       @cache[request]
55     end
56   end
57
58   module Direction
59     # given the set of supported languages, return a hash suitable for the directions
60     # attribute which includes any language to any other language
61     def self.all_to_all(languages)
62       directions = all_to_none(languages)
63       languages.each {|l| directions[l] = languages.to_set}
64       directions
65     end
66
67     # a hash suitable for the directions attribute which includes any language from/to
68     # the given set of languages (center_languages)
69     def self.all_from_to(languages, center_languages)
70       directions = all_to_none(languages)
71       center_languages.each {|l| directions[l] = languages - [l]}
72       (languages - center_languages).each {|l| directions[l] = center_languages.to_set}
73       directions
74     end
75
76     # get a hash from a list of pairs
77     def self.pairs(list_of_pairs)
78       languages = list_of_pairs.flatten.to_set
79       directions = all_to_none(languages)
80       list_of_pairs.each do |(from, to)|
81         directions[from] << to
82       end
83       directions
84     end
85
86     # an empty hash with empty sets as default values
87     def self.all_to_none(languages)
88       Hash.new do |h, k|
89         # always return empty set when the key is non-existent, but put empty set in the
90         # hash only if the key is one of the languages
91         if languages.include? k
92           h[k] = Set.new
93         else
94           Set.new
95         end
96       end
97     end
98   end
99 end
100
101
102 class NiftyTranslator < Translator
103   INFO = '@nifty Translation <http://nifty.amikai.com/amitext/indexUTF8.jsp>'
104
105   def initialize(cache={})
106    require 'mechanize'
107    super(Translator::Direction.all_from_to(%w[ja en zh_CN ko], %w[ja]), cache)
108     @form = WWW::Mechanize.new.
109             get('http://nifty.amikai.com/amitext/indexUTF8.jsp').
110             forms.name('translateForm').first
111   end
112
113   def do_translate(text, from, to)
114     @form.radiobuttons.name('langpair').value = "#{from},#{to}".upcase
115     @form.fields.name('sourceText').value = text
116
117     @form.submit(@form.buttons.name('translate')).
118           forms.name('translateForm').fields.name('translatedText').value
119   end
120 end
121
122
123 class ExciteTranslator < Translator
124   INFO = 'Excite.jp Translation <http://www.excite.co.jp/world/>'
125
126   def initialize(cache={})
127     require 'mechanize'
128     require 'iconv'
129
130     super(Translator::Direction.all_from_to(%w[ja en zh_CN zh_TW ko], %w[ja]), cache)
131
132     @forms = Hash.new do |h, k|
133       case k
134       when 'en'
135         h[k] = open_form('english')
136       when 'zh_CN', 'zh_TW'
137         # this way we don't need to fetch the same page twice
138         h['zh_CN'] = h['zh_TW'] = open_form('chinese')
139       when 'ko'
140         h[k] = open_form('korean')
141       end
142     end
143   end
144
145   def open_form(name)
146     WWW::Mechanize.new.get("http://www.excite.co.jp/world/#{name}").
147                    forms.name('world').first
148   end
149
150   def do_translate(text, from, to)
151     non_ja_language = from != 'ja' ? from : to
152     form = @forms[non_ja_language]
153
154     if non_ja_language =~ /zh_(CN|TW)/
155       form.fields.name('wb_lp').value = "#{from}#{to}".sub(/_(?:CN|TW)/, '').upcase
156       form.fields.name('big5').value = ($1 == 'TW' ? 'yes' : 'no')
157     else
158       # the en<->ja page is in Shift_JIS while other pages are UTF-8
159       text = Iconv.iconv('Shift_JIS', 'UTF-8', text) if non_ja_language == 'en'
160       form.fields.name('wb_lp').value = "#{from}#{to}".upcase
161     end
162     form.fields.name('before').value = text
163     result = form.submit.forms.name('world').fields.name('after').value
164     # the en<->ja page is in Shift_JIS while other pages are UTF-8
165     if non_ja_language == 'en'
166       Iconv.iconv('UTF-8', 'Shift_JIS', result)
167     else
168       result
169     end
170
171   end
172 end
173
174
175 class GoogleTranslator < Translator
176   INFO = 'Google Translate <http://www.google.com/translate_t>'
177
178   def initialize(cache={})
179     require 'mechanize'
180     load_form!
181     language_pairs = @lang_list.options.map do |o|
182       # these options have values like "en|zh-CN"; map to things like ['en', 'zh_CN'].
183       o.value.split('|').map {|l| l.sub('-', '_')}
184     end
185     super(Translator::Direction.pairs(language_pairs), cache)
186   end
187
188   def load_form!
189     agent = WWW::Mechanize.new
190     # without faking the user agent, Google Translate will serve non-UTF-8 text
191     agent.user_agent_alias = 'Linux Konqueror'
192     @form = agent.get('http://www.google.com/translate_t').
193             forms.action('/translate_t').first
194     @lang_list = @form.fields.name('langpair')
195   end
196
197   def do_translate(text, from, to)
198     load_form!
199
200     @lang_list.value = "#{from}|#{to}".sub('_', '-')
201     @form.fields.name('text').value = text
202     @form.submit.parser.search('div#result_box').inner_html
203   end
204 end
205
206
207 class BabelfishTranslator < Translator
208   INFO = 'AltaVista Babel Fish Translation <http://babelfish.altavista.com/babelfish/>'
209
210   def initialize(cache)
211     require 'mechanize'
212
213     @form = WWW::Mechanize.new.get('http://babelfish.altavista.com/babelfish/').
214             forms.name('frmTrText').first
215     @lang_list = @form.fields.name('lp')
216     language_pairs = @lang_list.options.map {|o| o.value.split('_')}.
217                                             reject {|p| p.empty?}
218     super(Translator::Direction.pairs(language_pairs), cache)
219   end
220
221   def do_translate(text, from, to)
222     if @form.fields.name('trtext').empty?
223       @form.add_field!('trtext', text)
224     else
225       @form.fields.name('trtext').value = text
226     end
227     @lang_list.value = "#{from}_#{to}"
228     @form.submit.parser.search("td.s/div[@style]").inner_html
229   end
230 end
231
232 class WorldlingoTranslator < Translator
233   INFO = 'WorldLingo Free Online Translator <http://www.worldlingo.com/en/products_services/worldlingo_translator.html>'
234
235   LANGUAGES = %w[en fr de it pt es ru nl el sv ar ja ko zh_CN zh_TW]
236   def initialize(cache)
237     require 'uri'
238     super(Translator::Direction.all_to_all(LANGUAGES), cache)
239   end
240
241   def translate(text, from, to)
242     response = Irc::Utils.bot.httputil.get_response(URI.escape(
243                "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}"))
244     # WorldLingo seems to respond an XML when error occurs
245     case response['Content-Type']
246     when %r'text/plain'
247       response.body
248     else
249       raise Translator::NoTranslationError
250     end
251   end
252 end
253
254 class TranslatorPlugin < Plugin
255   Config.register Config::IntegerValue.new('translator.timeout',
256     :default => 30, :validate => Proc.new{|v| v > 0},
257     :desc => _("Number of seconds to wait for the translation service before timeout"))
258
259   TRANSLATORS = {
260     'nifty' => NiftyTranslator,
261     'excite' => ExciteTranslator,
262     'google_translate' => GoogleTranslator,
263     'babelfish' => BabelfishTranslator,
264     'worldlingo' => WorldlingoTranslator,
265   }
266
267   def initialize
268     super
269
270     @translators = {}
271     TRANSLATORS.each_pair do |name, c|
272       begin
273         @translators[name] = c.new(@registry.sub_registry(name))
274         map "#{name} :from :to *phrase",
275           :action => :cmd_translate, :thread => true
276       rescue Exception
277         warning _("Translator %{name} cannot be used: %{reason}") %
278                {:name => name, :reason => $!}
279       end
280     end
281
282     Config.register Config::ArrayValue.new('translator.default_list',
283       :default => TRANSLATORS.keys,
284       :validate => Proc.new {|l| l.all? {|t| TRANSLATORS.has_key?(t)}},
285       :desc => _("List of translators to try in order when translator name not specified"),
286       :on_change => Proc.new {|bot, v| update_default})
287     update_default
288   end
289
290   def help(plugin, topic=nil)
291     if @translators.has_key?(topic)
292       translator = @translators[topic]
293       _('%{info}, supported directions of translation: %{directions}') % {
294         :info => translator.class::INFO,
295         :directions => translator.directions.map do |source, targets|
296                          _('%{source} -> %{targets}') %
297                          {:source => source, :targets => targets.to_a.join(', ')}
298                        end.join(' | ')
299       }
300     else
301       _('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 <translator>" to look up supported from and to languages') %
302         {:translators => @translators.keys.join(', ')}
303     end
304   end
305
306   def update_default
307     @default_translators = bot.config['translator.default_list'] & @translators.keys 
308   end
309
310   def cmd_translator(m, params)
311     from, to = params[:from], params[:to]
312     translator = @default_translators.find {|t| @translators[t].support?(from, to)}
313     if translator
314       cmd_translate m, params.merge({:translator => translator})
315     else
316       m.reply _('None of the default translators (translator.default_list) supports translating from %{source} to %{target}') % {:source => from, :target => to}
317     end
318   end
319
320   def cmd_translate(m, params)
321     # get the first word of the command
322     tname = params[:translator] || m.message[/\A(\w+)\s/, 1]
323     translator = @translators[tname]
324     from, to, phrase = params[:from], params[:to], params[:phrase].to_s
325     if translator
326       begin
327         translation = Timeout.timeout(@bot.config['translator.timeout']) do
328           translator.translate(phrase, from, to)
329         end
330         m.reply translation
331       rescue Translator::UnsupportedDirectionError
332         m.reply _("%{translator} doesn't support translating from %{source} to %{target}") %
333                 {:translator => tname, :source => from, :target => to}
334       rescue Translator::NoTranslationError
335         m.reply _('%{translator} failed to provide a translation') %
336                 {:translator => tname}
337       rescue Timeout::Error
338         m.reply _('The translator timed out')
339       end
340     else
341       m.reply _('No translator called %{name}') % {:name => tname}
342     end
343   end
344 end
345
346 plugin = TranslatorPlugin.new
347 plugin.map 'translator :from :to *phrase',
348   :action => :cmd_translator, :thread => true