]> git.netwichtig.de Git - user/henk/code/ruby/rbot.git/blob - data/rbot/plugins/translator.rb
autoop: Add a 'seed' command that makes sure current ops in a channel will be autoopped.
[user/henk/code/ruby/rbot.git] / 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     (_, lang_list) = parse_page
219     language_pairs = lang_list.options.map {|o| o.value.split('_')}.
220                                            reject {|p| p.empty?}
221     super(Translator::Direction.pairs(language_pairs), cache)
222   end
223
224   def parse_page
225     form = WWW::Mechanize.new.get('http://babelfish.altavista.com/babelfish/').
226            forms_with(:name => 'frmTrText').first
227     lang_list = form.fields_with(:name => 'lp').first
228     [form, lang_list]
229   end
230
231   def do_translate(text, from, to)
232     unless @form && @lang_list
233       @form, @lang_list = parse_page
234     end
235     
236     if @form.fields_with(:name => 'trtext').empty?
237       @form.add_field!('trtext', text)
238     else
239       @form.fields_with(:name => 'trtext').first.value = text
240     end
241     @lang_list.value = "#{from}_#{to}"
242     @form.submit.parser.search("div[@id='result']/div[@style]").inner_html
243   end
244 end
245
246 class WorldlingoTranslator < Translator
247   INFO = 'WorldLingo Free Online Translator <http://www.worldlingo.com/en/products_services/worldlingo_translator.html>'
248
249   LANGUAGES = %w[en fr de it pt es ru nl el sv ar ja ko zh_CN zh_TW]
250   def initialize(cache)
251     require 'uri'
252     super(Translator::Direction.all_to_all(LANGUAGES), cache)
253   end
254
255   def translate(text, from, to)
256     response = Irc::Utils.bot.httputil.get_response(URI.escape(
257                "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}"))
258     # WorldLingo seems to respond an XML when error occurs
259     case response['Content-Type']
260     when %r'text/plain'
261       response.body
262     else
263       raise Translator::NoTranslationError
264     end
265   end
266 end
267
268 class TranslatorPlugin < Plugin
269   Config.register Config::IntegerValue.new('translator.timeout',
270     :default => 30, :validate => Proc.new{|v| v > 0},
271     :desc => _("Number of seconds to wait for the translation service before timeout"))
272   Config.register Config::StringValue.new('translator.destination',
273     :default => "en",
274     :desc => _("Default destination language to be used with translate command"))
275
276   TRANSLATORS = {
277     'nifty' => NiftyTranslator,
278     'excite' => ExciteTranslator,
279     'google_translate' => GoogleTranslator,
280     'babelfish' => BabelfishTranslator,
281     'worldlingo' => WorldlingoTranslator,
282   }
283
284   def initialize
285     super
286     @failed_translators = []
287     @translators = {}
288     TRANSLATORS.each_pair do |name, c|
289       watch_for_fail(name) do
290         @translators[name] = c.new(@registry.sub_registry(name))
291         map "#{name} :from :to *phrase",
292           :action => :cmd_translate, :thread => true
293       end
294     end
295
296     Config.register Config::ArrayValue.new('translator.default_list',
297       :default => TRANSLATORS.keys,
298       :validate => Proc.new {|l| l.all? {|t| TRANSLATORS.has_key?(t)}},
299       :desc => _("List of translators to try in order when translator name not specified"),
300       :on_change => Proc.new {|bot, v| update_default})
301     update_default
302   end
303
304   def watch_for_fail(name, &block)
305     begin
306       yield
307     rescue Exception
308       @failed_translators << { :name => name, :reason => $!.to_s }
309
310       warning _("Translator %{name} cannot be used: %{reason}") %
311              {:name => name, :reason => $!}
312       map "#{name} [*args]", :action => :failed_translator,
313                              :defaults => {:name => name, :reason => $!}
314     end
315   end
316
317   def failed_translator(m, params)
318     m.reply _("Translator %{name} cannot be used: %{reason}") %
319             {:name => params[:name], :reason => params[:reason]}
320   end
321
322   def help(plugin, topic=nil)
323     case (topic.intern rescue nil)
324     when :failed
325       unless @failed_translators.empty?
326         failed_list = @failed_translators.map { |t| _("%{bold}%{translator}%{bold}: %{reason}") % {
327           :translator => t[:name],
328           :reason => t[:reason],
329           :bold => Bold
330         }}
331
332         _("Failed translators: %{list}") % { :list => failed_list.join(", ") }
333       else
334         _("None of the translators failed")
335       end
336     else
337       if @translators.has_key?(plugin)
338         translator = @translators[plugin]
339         _('%{translator} <from> <to> <phrase> => Look up phrase using %{info}, supported from -> to languages: %{directions}') % {
340           :translator => plugin,
341           :info => translator.class::INFO,
342           :directions => translator.directions.map do |source, targets|
343                            _('%{source} -> %{targets}') %
344                            {:source => source, :targets => targets.to_a.join(', ')}
345                          end.join(' | ')
346         }
347       else
348         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') %
349                      {:translators => @translators.keys.join(', ')}
350
351         help_str << "\n" + _("%{bold}Note%{bold}: %{failed_amt} translators failed, see %{reverse}%{prefix}help translate failed%{reverse} for details") % {
352           :failed_amt => @failed_translators.size,
353           :bold => Bold,
354           :reverse => Reverse,
355           :prefix => @bot.config['core.address_prefix'].first
356         }
357
358         help_str
359       end
360     end
361   end
362
363   def languages
364     @languages ||= @translators.map { |t| t.last.directions.keys }.flatten.uniq
365   end
366
367   def update_default
368     @default_translators = bot.config['translator.default_list'] & @translators.keys
369   end
370
371   def cmd_translator(m, params)
372     params[:to] = @bot.config['translator.destination'] if params[:to].nil?
373     params[:from] ||= 'auto'
374     translator = @default_translators.find {|t| @translators[t].support?(params[:from], params[:to])}
375
376     if translator
377       cmd_translate m, params.merge({:translator => translator, :show_provider => true})
378     else
379       # When translate command is used without source language, "auto" as source
380       # language is assumed. It means that google translator is used and we let google
381       # figure out what the source language is.
382       #
383       # Problem is that the google translator will fail if the system that the bot is
384       # running on does not have the json gem installed.
385       if params[:from] == 'auto'
386         m.reply _("Unable to auto-detect source language due to broken google translator, see %{reverse}%{prefix}help translate failed%{reverse} for details") % {
387           :reverse => Reverse,
388           :prefix => @bot.config['core.address_prefix'].first
389         }
390       else
391         m.reply _('None of the default translators (translator.default_list) supports translating from %{source} to %{target}') % {:source => params[:from], :target => params[:to]}
392       end
393     end
394   end
395
396   def cmd_translate(m, params)
397     # get the first word of the command
398     tname = params[:translator] || m.message[/\A(\w+)\s/, 1]
399     translator = @translators[tname]
400     from, to, phrase = params[:from], params[:to], params[:phrase].to_s
401     if translator
402       watch_for_fail(tname) do
403         begin
404           translation = Timeout.timeout(@bot.config['translator.timeout']) do
405             translator.translate(phrase, from, to)
406           end
407           m.reply(if params[:show_provider]
408                     _('%{translation} (provided by %{translator})') %
409                       {:translation => translation, :translator => tname.gsub("_", " ")}
410                   else
411                     translation
412                   end)
413
414         rescue Translator::UnsupportedDirectionError
415           m.reply _("%{translator} doesn't support translating from %{source} to %{target}") %
416                   {:translator => tname, :source => from, :target => to}
417         rescue Translator::NoTranslationError
418           m.reply _('%{translator} failed to provide a translation') %
419                   {:translator => tname}
420         rescue Timeout::Error
421           m.reply _('The translator timed out')
422         end
423       end
424     else
425       m.reply _('No translator called %{name}') % {:name => tname}
426     end
427   end
428 end
429
430 plugin = TranslatorPlugin.new
431 req = Hash[*%w(from to).map { |e| [e.to_sym, /#{plugin.languages.join("|")}/] }.flatten]
432
433 plugin.map 'translate [:from] [:to] *phrase',
434            :action => :cmd_translator, :thread => true, :requirements => req
435 plugin.map 'translator [:from] [:to] *phrase',
436            :action => :cmd_translator, :thread => true, :requirements => req