]> git.netwichtig.de Git - user/henk/code/ruby/rbot.git/blob - data/rbot/plugins/translator.rb
338216384ef5ea0af98b7e880eadf4096469bc08
[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   # 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
45   # if it needs it.
46   def mechanize
47     return Mechanize if defined? Mechanize
48     return WWW::Mechanize
49   end
50
51   # whether the translator supports this direction
52   def support?(from, to)
53     from != to && @directions[from].include?(to)
54   end
55
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
66     else
67       @cache[request]
68     end
69   end
70
71   module Direction
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}
77       directions
78     end
79
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}
86       directions
87     end
88
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
95       end
96       directions
97     end
98
99     # an empty hash with empty sets as default values
100     def self.all_to_none(languages)
101       Hash.new do |h, k|
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
105           h[k] = Set.new
106         else
107           Set.new
108         end
109       end
110     end
111   end
112 end
113
114 class GoogleTranslator < Translator
115   INFO = 'Google Translate <http://www.google.com/translate_t>'
116   URL = 'https://translate.google.com/'
117
118   LANGUAGES =
119     %w[af sq am ar hy az eu be bn bh bg my ca chr zh zh_CN zh_TW hr
120     cs da dv en eo et tl fi fr gl ka de el gn gu iw hi hu is id iu
121     ga it ja kn kk km ko lv lt mk ms ml mt mr mn ne no or ps fa pl
122     pt_PT pa ro ru sa sr sd si sk sl es sw sv tg ta tl te th bo tr
123     uk ur uz ug vi cy yi auto]
124   def initialize(cache={})
125     require 'mechanize'
126     super(Translator::Direction.all_to_all(LANGUAGES), cache)
127   end
128
129   def do_translate(text, from, to)
130     agent = Mechanize.new
131     page = agent.get URL
132     form = page.form_with(:id => 'gt-form')
133     form.sl = from
134     form.tl = to
135     form.text = text
136     page = form.submit
137     return page.search('#result_box span').first.content
138   end
139 end
140
141 class YandexTranslator < Translator
142   INFO = 'Yandex Translator <http://translate.yandex.com/>'
143   LANGUAGES = %w{ar az be bg ca cs da de el en es et fi fr he hr hu hy it ka lt lv mk nl no pl pt ro ru sk sl sq sr sv tr uk}
144
145   URL = 'https://translate.yandex.net/api/v1.5/tr.json/translate?key=%s&lang=%s-%s&text=%s'
146   KEY = 'trnsl.1.1.20140326T031210Z.1e298c8adb4058ed.d93278fea8d79e0a0ba76b6ab4bfbf6ac43ada72'
147   def initialize(cache)
148     require 'uri'
149     require 'json'
150     super(Translator::Direction.all_to_all(LANGUAGES), cache)
151   end
152
153   def translate(text, from, to)
154     res = Irc::Utils.bot.httputil.get_response(URL % [KEY, from, to, URI.escape(text)])
155     res = JSON.parse(res.body)
156
157     if res['code'] != 200
158       raise Translator::NoTranslationError
159     else
160       res['text'].join(' ')
161     end
162   end
163
164 end
165
166 class TranslatorPlugin < Plugin
167   Config.register Config::IntegerValue.new('translator.timeout',
168     :default => 30, :validate => Proc.new{|v| v > 0},
169     :desc => _("Number of seconds to wait for the translation service before timeout"))
170   Config.register Config::StringValue.new('translator.destination',
171     :default => "en",
172     :desc => _("Default destination language to be used with translate command"))
173
174   TRANSLATORS = {
175     'google_translate' => GoogleTranslator,
176     'yandex' => YandexTranslator,
177   }
178
179   def initialize
180     super
181     @failed_translators = []
182     @translators = {}
183     TRANSLATORS.each_pair do |name, c|
184       watch_for_fail(name) do
185         @translators[name] = c.new(@registry.sub_registry(name))
186         map "#{name} :from :to *phrase",
187           :action => :cmd_translate, :thread => true
188       end
189     end
190
191     Config.register Config::ArrayValue.new('translator.default_list',
192       :default => TRANSLATORS.keys,
193       :validate => Proc.new {|l| l.all? {|t| TRANSLATORS.has_key?(t)}},
194       :desc => _("List of translators to try in order when translator name not specified"),
195       :on_change => Proc.new {|bot, v| update_default})
196     update_default
197   end
198
199   def watch_for_fail(name, &block)
200     begin
201       yield
202     rescue Exception
203       debug 'Translator error: '+$!.to_s
204       debug $@.join("\n")
205       @failed_translators << { :name => name, :reason => $!.to_s }
206
207       warning _("Translator %{name} cannot be used: %{reason}") %
208              {:name => name, :reason => $!}
209       map "#{name} [*args]", :action => :failed_translator,
210                              :defaults => {:name => name, :reason => $!}
211     end
212   end
213
214   def failed_translator(m, params)
215     m.reply _("Translator %{name} cannot be used: %{reason}") %
216             {:name => params[:name], :reason => params[:reason]}
217   end
218
219   def help(plugin, topic=nil)
220     case (topic.intern rescue nil)
221     when :failed
222       unless @failed_translators.empty?
223         failed_list = @failed_translators.map { |t| _("%{bold}%{translator}%{bold}: %{reason}") % {
224           :translator => t[:name],
225           :reason => t[:reason],
226           :bold => Bold
227         }}
228
229         _("Failed translators: %{list}") % { :list => failed_list.join(", ") }
230       else
231         _("None of the translators failed")
232       end
233     else
234       if @translators.has_key?(plugin)
235         translator = @translators[plugin]
236         _('%{translator} <from> <to> <phrase> => Look up phrase using %{info}, supported from -> to languages: %{directions}') % {
237           :translator => plugin,
238           :info => translator.class::INFO,
239           :directions => translator.directions.map do |source, targets|
240                            _('%{source} -> %{targets}') %
241                            {:source => source, :targets => targets.to_a.join(', ')}
242                          end.join(' | ')
243         }
244       else
245         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') %
246                      {:translators => @translators.keys.join(', ')}
247
248         help_str << "\n" + _("%{bold}Note%{bold}: %{failed_amt} translators failed, see %{reverse}%{prefix}help translate failed%{reverse} for details") % {
249           :failed_amt => @failed_translators.size,
250           :bold => Bold,
251           :reverse => Reverse,
252           :prefix => @bot.config['core.address_prefix'].first
253         }
254
255         help_str
256       end
257     end
258   end
259
260   def languages
261     @languages ||= @translators.map { |t| t.last.directions.keys }.flatten.uniq
262   end
263
264   def update_default
265     @default_translators = bot.config['translator.default_list'] & @translators.keys
266   end
267
268   def cmd_translator(m, params)
269     params[:to] = @bot.config['translator.destination'] if params[:to].nil?
270     params[:from] ||= 'auto'
271     translator = @default_translators.find {|t| @translators[t].support?(params[:from], params[:to])}
272
273     if translator
274       cmd_translate m, params.merge({:translator => translator, :show_provider => false})
275     else
276       # When translate command is used without source language, "auto" as source
277       # language is assumed. It means that google translator is used and we let google
278       # figure out what the source language is.
279       #
280       # Problem is that the google translator will fail if the system that the bot is
281       # running on does not have the json gem installed.
282       if params[:from] == 'auto'
283         m.reply _("Unable to auto-detect source language due to broken google translator, see %{reverse}%{prefix}help translate failed%{reverse} for details") % {
284           :reverse => Reverse,
285           :prefix => @bot.config['core.address_prefix'].first
286         }
287       else
288         m.reply _('None of the default translators (translator.default_list) supports translating from %{source} to %{target}') % {:source => params[:from], :target => params[:to]}
289       end
290     end
291   end
292
293   def cmd_translate(m, params)
294     # get the first word of the command
295     tname = params[:translator] || m.message[/\A(\w+)\s/, 1]
296     translator = @translators[tname]
297     from, to, phrase = params[:from], params[:to], params[:phrase].to_s
298     if translator
299       watch_for_fail(tname) do
300         begin
301           translation = Timeout.timeout(@bot.config['translator.timeout']) do
302             translator.translate(phrase, from, to)
303           end
304           m.reply(if params[:show_provider]
305                     _('%{translation} (provided by %{translator})') %
306                       {:translation => translation, :translator => tname.gsub("_", " ")}
307                   else
308                     translation
309                   end)
310
311         rescue Translator::UnsupportedDirectionError
312           m.reply _("%{translator} doesn't support translating from %{source} to %{target}") %
313                   {:translator => tname, :source => from, :target => to}
314         rescue Translator::NoTranslationError
315           m.reply _('%{translator} failed to provide a translation') %
316                   {:translator => tname}
317         rescue Timeout::Error
318           m.reply _('The translator timed out')
319         end
320       end
321     else
322       m.reply _('No translator called %{name}') % {:name => tname}
323     end
324   end
325
326   # URL translation has nothing to do with Translators so let's make it
327   # separate, and Google exclusive for now
328   def cmd_translate_url(m, params)
329     params[:to] = @bot.config['translator.destination'] if params[:to].nil?
330     params[:from] ||= 'auto'
331
332     translate_url = "http://translate.google.com/translate?sl=%{from}&tl=%{to}&u=%{url}" % {
333       :from => params[:from],
334       :to   => params[:to],
335       :url  => CGI.escape(params[:url].to_s)
336     }
337
338     m.reply(translate_url)
339   end
340 end
341
342 plugin = TranslatorPlugin.new
343 req = Hash[*%w(from to).map { |e| [e.to_sym, /#{plugin.languages.join("|")}/] }.flatten]
344
345 plugin.map 'translate [:from] [:to] :url',
346            :action => :cmd_translate_url, :requirements => req.merge(:url => %r{^https?://[^\s]*})
347 plugin.map 'translator [:from] [:to] :url',
348            :action => :cmd_translate_url, :requirements => req.merge(:url => %r{^https?://[^\s]*})
349 plugin.map 'translate [:from] [:to] *phrase',
350            :action => :cmd_translator, :thread => true, :requirements => req
351 plugin.map 'translator [:from] [:to] *phrase',
352            :action => :cmd_translator, :thread => true, :requirements => req