]> git.netwichtig.de Git - user/henk/code/ruby/rbot.git/blob - data/rbot/plugins/translator.rb
833701c36cb3eee19f8bef920a492811b9039a66
[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     agent.user_agent_alias = 'Linux Mozilla'
132     page = agent.get URL
133     form = page.form_with(:id => 'gt-form')
134     form.sl = from
135     form.tl = to
136     form.text = text
137     page = form.submit
138     return page.search('#result_box span').first.content
139   end
140 end
141
142 class YandexTranslator < Translator
143   INFO = 'Yandex Translator <http://translate.yandex.com/>'
144   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}
145
146   URL = 'https://translate.yandex.net/api/v1.5/tr.json/translate?key=%s&lang=%s-%s&text=%s'
147   KEY = 'trnsl.1.1.20140326T031210Z.1e298c8adb4058ed.d93278fea8d79e0a0ba76b6ab4bfbf6ac43ada72'
148   def initialize(cache)
149     require 'uri'
150     require 'json'
151     super(Translator::Direction.all_to_all(LANGUAGES), cache)
152   end
153
154   def translate(text, from, to)
155     res = Irc::Utils.bot.httputil.get_response(URL % [KEY, from, to, URI.escape(text)])
156     res = JSON.parse(res.body)
157
158     if res['code'] != 200
159       raise Translator::NoTranslationError
160     else
161       res['text'].join(' ')
162     end
163   end
164
165 end
166
167 class TranslatorPlugin < Plugin
168   Config.register Config::IntegerValue.new('translator.timeout',
169     :default => 30, :validate => Proc.new{|v| v > 0},
170     :desc => _("Number of seconds to wait for the translation service before timeout"))
171   Config.register Config::StringValue.new('translator.destination',
172     :default => "en",
173     :desc => _("Default destination language to be used with translate command"))
174
175   TRANSLATORS = {
176     'google_translate' => GoogleTranslator,
177     'yandex' => YandexTranslator,
178   }
179
180   def initialize
181     super
182     @failed_translators = []
183     @translators = {}
184     TRANSLATORS.each_pair do |name, c|
185       watch_for_fail(name) do
186         @translators[name] = c.new(@registry.sub_registry(name))
187         map "#{name} :from :to *phrase",
188           :action => :cmd_translate, :thread => true
189       end
190     end
191
192     Config.register Config::ArrayValue.new('translator.default_list',
193       :default => TRANSLATORS.keys,
194       :validate => Proc.new {|l| l.all? {|t| TRANSLATORS.has_key?(t)}},
195       :desc => _("List of translators to try in order when translator name not specified"),
196       :on_change => Proc.new {|bot, v| update_default})
197     update_default
198   end
199
200   def watch_for_fail(name, &block)
201     begin
202       yield
203     rescue Exception
204       debug 'Translator error: '+$!.to_s
205       debug $@.join("\n")
206       @failed_translators << { :name => name, :reason => $!.to_s }
207
208       warning _("Translator %{name} cannot be used: %{reason}") %
209              {:name => name, :reason => $!}
210       map "#{name} [*args]", :action => :failed_translator,
211                              :defaults => {:name => name, :reason => $!}
212     end
213   end
214
215   def failed_translator(m, params)
216     m.reply _("Translator %{name} cannot be used: %{reason}") %
217             {:name => params[:name], :reason => params[:reason]}
218   end
219
220   def help(plugin, topic=nil)
221     case (topic.intern rescue nil)
222     when :failed
223       unless @failed_translators.empty?
224         failed_list = @failed_translators.map { |t| _("%{bold}%{translator}%{bold}: %{reason}") % {
225           :translator => t[:name],
226           :reason => t[:reason],
227           :bold => Bold
228         }}
229
230         _("Failed translators: %{list}") % { :list => failed_list.join(", ") }
231       else
232         _("None of the translators failed")
233       end
234     else
235       if @translators.has_key?(plugin)
236         translator = @translators[plugin]
237         _('%{translator} <from> <to> <phrase> => Look up phrase using %{info}, supported from -> to languages: %{directions}') % {
238           :translator => plugin,
239           :info => translator.class::INFO,
240           :directions => translator.directions.map do |source, targets|
241                            _('%{source} -> %{targets}') %
242                            {:source => source, :targets => targets.to_a.join(', ')}
243                          end.join(' | ')
244         }
245       else
246         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') %
247                      {:translators => @translators.keys.join(', ')}
248
249         help_str << "\n" + _("%{bold}Note%{bold}: %{failed_amt} translators failed, see %{reverse}%{prefix}help translate failed%{reverse} for details") % {
250           :failed_amt => @failed_translators.size,
251           :bold => Bold,
252           :reverse => Reverse,
253           :prefix => @bot.config['core.address_prefix'].first
254         }
255
256         help_str
257       end
258     end
259   end
260
261   def languages
262     @languages ||= @translators.map { |t| t.last.directions.keys }.flatten.uniq
263   end
264
265   def update_default
266     @default_translators = bot.config['translator.default_list'] & @translators.keys
267   end
268
269   def cmd_translator(m, params)
270     params[:to] = @bot.config['translator.destination'] if params[:to].nil?
271     params[:from] ||= 'auto'
272     translator = @default_translators.find {|t| @translators[t].support?(params[:from], params[:to])}
273
274     if translator
275       cmd_translate m, params.merge({:translator => translator, :show_provider => false})
276     else
277       # When translate command is used without source language, "auto" as source
278       # language is assumed. It means that google translator is used and we let google
279       # figure out what the source language is.
280       #
281       # Problem is that the google translator will fail if the system that the bot is
282       # running on does not have the json gem installed.
283       if params[:from] == 'auto'
284         m.reply _("Unable to auto-detect source language due to broken google translator, see %{reverse}%{prefix}help translate failed%{reverse} for details") % {
285           :reverse => Reverse,
286           :prefix => @bot.config['core.address_prefix'].first
287         }
288       else
289         m.reply _('None of the default translators (translator.default_list) supports translating from %{source} to %{target}') % {:source => params[:from], :target => params[:to]}
290       end
291     end
292   end
293
294   def cmd_translate(m, params)
295     # get the first word of the command
296     tname = params[:translator] || m.message[/\A(\w+)\s/, 1]
297     translator = @translators[tname]
298     from, to, phrase = params[:from], params[:to], params[:phrase].to_s
299     if translator
300       watch_for_fail(tname) do
301         begin
302           translation = Timeout.timeout(@bot.config['translator.timeout']) do
303             translator.translate(phrase, from, to)
304           end
305           m.reply(if params[:show_provider]
306                     _('%{translation} (provided by %{translator})') %
307                       {:translation => translation, :translator => tname.gsub("_", " ")}
308                   else
309                     translation
310                   end)
311
312         rescue Translator::UnsupportedDirectionError
313           m.reply _("%{translator} doesn't support translating from %{source} to %{target}") %
314                   {:translator => tname, :source => from, :target => to}
315         rescue Translator::NoTranslationError
316           m.reply _('%{translator} failed to provide a translation') %
317                   {:translator => tname}
318         rescue Timeout::Error
319           m.reply _('The translator timed out')
320         end
321       end
322     else
323       m.reply _('No translator called %{name}') % {:name => tname}
324     end
325   end
326
327   # URL translation has nothing to do with Translators so let's make it
328   # separate, and Google exclusive for now
329   def cmd_translate_url(m, params)
330     params[:to] = @bot.config['translator.destination'] if params[:to].nil?
331     params[:from] ||= 'auto'
332
333     translate_url = "http://translate.google.com/translate?sl=%{from}&tl=%{to}&u=%{url}" % {
334       :from => params[:from],
335       :to   => params[:to],
336       :url  => CGI.escape(params[:url].to_s)
337     }
338
339     m.reply(translate_url)
340   end
341 end
342
343 plugin = TranslatorPlugin.new
344 req = Hash[*%w(from to).map { |e| [e.to_sym, /#{plugin.languages.join("|")}/] }.flatten]
345
346 plugin.map 'translate [:from] [:to] :url',
347            :action => :cmd_translate_url, :requirements => req.merge(:url => %r{^https?://[^\s]*})
348 plugin.map 'translator [:from] [:to] :url',
349            :action => :cmd_translate_url, :requirements => req.merge(:url => %r{^https?://[^\s]*})
350 plugin.map 'translate [:from] [:to] *phrase',
351            :action => :cmd_translator, :thread => true, :requirements => req
352 plugin.map 'translator [:from] [:to] *phrase',
353            :action => :cmd_translator, :thread => true, :requirements => req