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