quotes: Make sure to turn date's into strings. Whoops.
[rbot] / data / rbot / plugins / quotes.rb
1 #-- vim:sw=2:et
2 #++
3 #
4 # :title: Quotes plugin
5 #
6 # TODO:: switch to db
7
8 require 'rexml/document'
9 require 'cgi'
10
11 define_structure :Quote, :num, :date, :source, :quote
12
13 class QuotePlugin < Plugin
14   Config.register Config::StringValue.new('quotes.html_path',
15     :default => '/var/www/html/quotes',
16     :desc => "Where to dump our output, the user the bot runs as needs write access here")
17
18   Config.register Config::StringValue.new('quotes.header_template',
19     :default => 'templates/header',
20     :desc => "HTML 'header' file that will be used to build HTML output. You can use the following variables: %%channel%% - channel name")
21
22   Config.register Config::StringValue.new('quotes.body_template',
23     :default => 'templates/body',
24     :desc => "HTML 'body' file that will be used to build HTML output. This will be used to generate HTML for each quote in the channel. You can use the following variables: %%timestamp%% - when the quote was recorded ; %%id%% - the quote's id number (for getquote) ; %%author%% - nick of the person who added the quote ; %%channel%% - channel name ; %%quote%% - the actual quote")
25
26   Config.register Config::StringValue.new('quotes.footer_template',
27     :default => 'templates/footer',
28     :desc => "HTML 'footer' file that will be used to build HTML output. You can use the following variables: %%channel%% - channel name")
29
30   def dirname
31     'quotes'
32   end
33
34   def initialize
35     super
36     @lists = Hash.new
37     @changed = Hash.new
38     Dir[datafile '*'].each {|f|
39       next if File.directory?(f)
40       channel = File.basename(f)
41       @lists[channel] = Array.new if(!@lists.has_key?(channel))
42       IO.foreach(f) {|line|
43         if(line =~ /^(\d+) \| ([^|]+) \| (\S+) \| (.*)$/)
44           num = $1.to_i
45           @lists[channel][num] = Quote.new(num, $2, $3, $4)
46         end
47       }
48       @changed[channel] = false
49     }
50   end
51
52   def save
53     Dir.mkdir(datafile) unless FileTest.directory? datafile
54     @lists.each {|channel, quotes|
55       begin
56         if @changed[channel]
57           debug "Writing new quotefile for channel #{channel} ..."
58           Utils.safe_save(datafile channel) {|file|
59             quotes.compact.each {|q|
60               file.puts "#{q.num} | #{q.date} | #{q.source} | #{q.quote}"
61             }
62           }
63           @changed[channel] = false
64         else
65           debug "Not writing quotefile for channel #{channel} (unchanged)"
66         end
67       rescue => e
68         error "failed to write quotefile for channel #{channel}!\n#{$!}"
69         error "#{e.class}: #{e}"
70         error e.backtrace.join("\n")
71       end
72     }
73   end
74
75   def cmd_dumptoxml(m, p)
76     destDir = @bot.config['quotes.html_path']
77
78     Dir.mkdir(destDir) unless FileTest.directory?(destDir)
79     @lists.each { |channel, quotes|
80       begin
81         doc = REXML::Document.new
82
83         doc.add_element("channel", {"name" => channel})
84         quoteList = REXML::Element.new("quotes")
85
86         quotes.compact.each{ |q|
87           node = REXML::Element.new("quote")
88           node.add_attributes({"date" => q.date, 
89                                  "id" => q.num, 
90                              "author" => q.source[0,q.source.index("!")]})
91           node.add_text(q.quote.gsub(/[\x00-\x1f]/, ''))
92           quoteList.add_element(node)
93         }
94
95         doc.root.add_element(quoteList)
96
97         filePath = "#{destDir}/#{channel.delete("#")}.xml"
98         outF = File.new(filePath, "w")
99         doc.write(outF, 2)
100         outF.close()
101       rescue => e
102         error "Failed to dump quotes for channel #{channel}!\n#{$!}"
103         error "#{e.class}: #{e}"
104         error e.backtrace.join("\n")
105         m.reply("Hrm, that didn't work for #{channel}. Check the logs")
106       end
107     }
108     m.reply("Done!")
109   end
110
111   def cmd_dumptohtml(m, p)
112     destDir = @bot.config['quotes.html_path']
113
114     begin
115       headerF = File.new(datafile(@bot.config['quotes.header_template']))
116       headerLines = headerF.readlines
117       headerF.close
118
119       bodyF = File.new(datafile(@bot.config['quotes.body_template']))
120       bodyLines = bodyF.readlines
121       bodyF.close
122
123       footerF = File.new(datafile(@bot.config['quotes.footer_template']))
124       footerLines = footerF.readlines
125       footerF.close
126     rescue => e
127       error "Had problems with templates!"
128       error "#{e.class}: #{e}"
129       error e.backtrace.join("\n")
130       m.reply("Had problems with the templates. Check the logs")
131     end
132
133     Dir.mkdir(destDir) unless FileTest.directory?(destDir)
134     @lists.each { |channel, quotes|
135       begin
136         filePath = "#{destDir}/#{channel.delete("#")}.html"
137         outF = File.new(filePath, "w")
138         
139         headerSubs = { "%%channel%%" => channel }
140
141         headerLines.each { |line|
142           headerSubs.each { |pattern, value| line = line.gsub(pattern, value) }
143           outF.puts line
144         }
145         
146         quotes.compact.each{ |q|
147           bodySubs = { "%%timestamp%%" => q.date.to_s,
148                               "%%id%%" => CGI.escapeHTML(q.num.to_s),
149                           "%%author%%" => CGI.escapeHTML(q.source[0, q.source.index("!")]),
150                          "%%channel%%" => CGI.escapeHTML(channel),
151                            "%%quote%%" => CGI.escapeHTML(q.quote.gsub(/[\x00-\x1f]/, '')),
152           }
153
154           bodyLines.each { |line|
155             bodySubs.each { |pattern, value| line = line.gsub(pattern, value) }
156             outF.puts line
157           }
158         }
159
160         footerSubs = { "%%channel%%" => channel }
161         footerLines.each { |line|
162           footerSubs.each { |pattern, value| line = line.gsub(pattern, value) }
163           outF.puts line
164         }
165
166         outF.close()
167       rescue => e
168         error "Failed to dump quotes for channel #{channel}!\n#{$!}"
169         error "#{e.class}: #{e}"
170         error e.backtrace.join("\n")
171         m.reply("Hrm, that didn't work for #{channel}. Check the logs")
172       end
173     }
174     m.reply("Done!")
175   end
176
177   def cleanup
178     @lists.clear
179     @changed.clear
180     super
181   end
182
183   def lastquote(channel)
184     @lists[channel].length-1
185   end
186
187   def addquote(source, channel, quote)
188     @lists[channel] = Array.new if(!@lists.has_key?(channel))
189     num = @lists[channel].length
190     @lists[channel][num] = Quote.new(num, Time.new, source.fullform, quote)
191     @changed[channel] = true
192     return num
193   end
194
195   def getquote(source, channel, num=nil)
196     return nil unless(@lists.has_key?(channel))
197     return nil unless(@lists[channel].length > 0)
198     if(num)
199       if(@lists[channel][num])
200         return @lists[channel][num], @lists[channel].length - 1
201       end
202     else
203       # random quote
204       return @lists[channel].compact[rand(@lists[channel].nitems)],
205       @lists[channel].length - 1
206     end
207   end
208
209   def delquote(channel, num)
210     return false unless(@lists.has_key?(channel))
211     return false unless(@lists[channel].length > 0)
212     if(@lists[channel][num])
213       @lists[channel][num] = nil
214       @lists[channel].pop if num == @lists[channel].length - 1
215       @changed[channel] = true
216       return true
217     end
218     return false
219   end
220
221   def countquote(source, channel=nil, regexp=nil)
222     unless(channel)
223       total=0
224       @lists.each_value {|l|
225         total += l.compact.length
226       }
227       return total
228     end
229     return 0 unless(@lists.has_key?(channel))
230     return 0 unless(@lists[channel].length > 0)
231     if(regexp)
232       matches = @lists[channel].compact.find_all {|a| a.quote =~ /#{regexp}/i }
233     else
234       matches = @lists[channel].compact
235     end
236     return matches.length
237   end
238
239   def searchquote(source, channel, regexp)
240     return nil unless(@lists.has_key?(channel))
241     return nil unless(@lists[channel].length > 0)
242     matches = @lists[channel].compact.find_all {|a| a.quote =~ /#{regexp}/i }
243     if(matches.length > 0)
244       return matches[rand(matches.length)], @lists[channel].length - 1
245     else
246       return nil
247     end
248   end
249
250   def listquotes(source, channel, regexp)
251     return nil unless(@lists.has_key?(channel))
252     return nil unless(@lists[channel].length > 0)
253     matches = @lists[channel].compact.find_all {|a| a.quote =~ /#{regexp}/i }
254     if matches.length > 0
255       return matches
256     else
257       return nil
258     end
259   end
260
261   def help(plugin, topic="")
262     case plugin
263     when "addquote"
264       _("addquote [<channel>] <quote> => Add quote <quote> for channel <channel>. You only need to supply <channel> if you are addressing %{nick} privately.") % { :nick => @bot.nick }
265     when "delquote"
266       _("delquote [<channel>] <num> => delete quote from <channel> with number <num>. You only need to supply <channel> if you are addressing %{nick} privately.") % { :nick => @bot.nick }
267     when "getquote"
268       _("getquote [<channel>] [<num>] => get quote from <channel> with number <num>. You only need to supply <channel> if you are addressing %{nick} privately. Without <num>, a random quote will be returned.") % { :nick => @bot.nick }
269     when "searchquote"
270       _("searchquote [<channel>] <regexp> => search for quote from <channel> that matches <regexp>. You only need to supply <channel> if you are addressing %{nick} privately.") % { :nick => @bot.nick }
271     when "listquotes"
272       _("listquotes [<channel>] <regexp> => list the quotes from <channel> that match <regexp>. You only need to supply <channel> if you are addressing %{nick} privately.") % { :nick => @bot.nick }
273     when "topicquote"
274       _("topicquote [<channel>] [<num>] => set topic to quote from <channel> with number <num>. You only need to supply <channel> if you are addressing %{nick} privately. Without <num>, a random quote will be set.") % { :nick => @bot.nick }
275     when "countquote"
276       _("countquote [<channel>] <regexp> => count quotes from <channel> that match <regexp>. You only need to supply <channel> if you are addressing %{nick} privately.") % { :nick => @bot.nick }
277     when "whoquote"
278       _("whoquote [<channel>] <num> => show who added quote <num>. You only need to supply <channel> if you are addressing %{nick} privately") % { :nick => @bot.nick }
279     when "whenquote"
280       _("whenquote [<channel>] <num> => show when quote <num> was added. You only need to supply <channel> if you are addressing %{nick} privately") % { :nick => @bot.nick }
281     when "lastquote"
282       _("lastquote [<channel>] => show the last quote in a given channel. You only need to supply <channel> if you are addressing %{nick} privately") % { :nick => @bot.nick }
283     else
284       _("Quote module (Quote storage and retrieval) topics: addquote, delquote, getquote, searchquote, listquotes, topicquote, countquote, whoquote, whenquote, lastquote") % { :nick => @bot.nick }
285     end
286   end
287
288   def cmd_addquote(m, p)
289     channel = p[:channel] || m.channel.to_s
290     quote = p[:quote].to_s
291     num = addquote(m.source, channel, quote)
292     m.reply _("added the quote (#%{num})") % { :num => num }
293   end
294
295   def cmd_delquote(m, p)
296     channel = p[:channel] || m.channel.to_s
297     num = p[:num].to_i
298     if delquote(channel, num)
299       m.okay
300     else
301       m.reply _("quote not found!")
302     end
303   end
304
305   def cmd_getquote(m, p)
306     channel = p[:channel] || m.channel.to_s
307     num = p[:num] ? p[:num].to_i : nil
308     quote, total = getquote(m.source, channel, num)
309     if quote
310       m.reply _("[%{num}] %{quote}") % {
311         :num => quote.num,
312         :quote => quote.quote
313       }
314     else
315       m.reply _("quote not found!")
316     end
317   end
318
319   def cmd_whoquote(m, p)
320     channel = p[:channel] || m.channel.to_s
321     num = p[:num] ? p[:num].to_i : nil
322     quote, total = getquote(m.source, channel, num)
323     if quote
324       m.reply _("quote %{num} added by %{source}") % {
325         :num => quote.num,
326         :source => quote.source
327       }
328     else
329       m.reply _("quote not found!")
330     end
331   end
332
333   def cmd_whenquote(m, p)
334     channel = p[:channel] || m.channel.to_s
335     num = p[:num] ? p[:num].to_i : nil
336     quote, total = getquote(m.source, channel, num)
337     if quote
338       m.reply _("quote %{num} added on %{date}") % {
339         :num => quote.num,
340         :date => quote.date
341       }
342     else
343       m.reply _("quote not found!")
344     end
345   end
346
347   def cmd_searchquote(m, p)
348     channel = p[:channel] || m.channel.to_s
349     reg = p[:reg].to_s
350     quote, total = searchquote(m.source, channel, reg)
351     if quote
352       m.reply _("[%{num}] %{quote}") % {
353         :num => quote.num,
354         :quote => quote.quote
355       }
356     else
357       m.reply _("quote not found!")
358     end
359   end
360
361   def cmd_listquotes(m, p)
362     channel = p[:channel] || m.channel.to_s
363     reg = p[:reg].to_s
364     if quotes = listquotes(m.source, channel, reg)
365       m.reply _("%{total} quotes matching %{reg} found: %{list}") % {
366         :total => quotes.size,
367         :reg => reg,
368         :list => quotes.map {|q| q.num }.join(', ')
369       }
370     else
371       m.reply _("quote not found!")
372     end
373   end
374
375   def cmd_countquote(m, p)
376     channel = p[:channel] || m.channel.to_s
377     reg = p[:reg] ? p[:reg].to_s : nil
378     total = countquote(m.source, channel, reg)
379     if reg.length > 0
380       m.reply _("%{total} quotes matching %{reg}") % {
381         :total => total,
382         :reg => reg
383       }
384     else
385       m.reply _("%{total} quotes") % { :total => total }
386     end
387   end
388
389   def cmd_topicquote(m, p)
390     channel = p[:channel] || m.channel.to_s
391     num = p[:num] ? p[:num].to_i : nil
392     quote, total = getquote(m.source, channel, num)
393     if quote
394       @bot.topic channel, _("[%{num}] %{quote}") % {
395         :num => quote.num,
396         :quote => quote.quote
397       }
398     else
399       m.reply _("quote not found!")
400     end
401   end
402
403   def cmd_lastquote(m, p)
404     channel = p[:channel] || m.channel.to_s
405     quote, total = getquote(m.source, channel, lastquote(channel))
406     if quote
407       m.reply _("[%{num}] %{quote}") % {
408         :num => quote.num,
409         :quote => quote.quote
410       }
411     else
412       m.reply _("quote not found!")
413     end
414   end
415 end
416
417 plugin = QuotePlugin.new
418 plugin.register("quotes")
419
420 plugin.default_auth('other::edit', false) # Prevent random people from editing other channels quote lists by default
421 plugin.default_auth('other::view', true) # But allow them to view them
422
423 plugin.map "addquote :channel *quote", :action => :cmd_addquote, :requirements => { :channel => Regexp::Irc::GEN_CHAN }, :auth_path => '!quote::other::edit::add!'
424 plugin.map "delquote :channel :num", :action => :cmd_delquote, :requirements => { :channel => Regexp::Irc::GEN_CHAN, :num => /^\d+$/ }, :auth_path => '!quote::other::edit::del!'
425 plugin.map "getquote :channel [:num]", :action => :cmd_getquote, :requirements => { :channel => Regexp::Irc::GEN_CHAN, :num => /^\d+$/ }, :auth_path => '!quote::other::view::get!'
426 plugin.map "whoquote :channel :num", :action => :cmd_whoquote, :requirements => { :channel => Regexp::Irc::GEN_CHAN, :num => /^\d+$/ }, :auth_path => '!quote::other::view::who!'
427 plugin.map "whenquote :channel :num", :action => :cmd_whenquote, :requirements => { :channel => Regexp::Irc::GEN_CHAN, :num => /^\d+$/ }, :auth_path => '!quote::other::view::when!'
428 plugin.map "searchquote :channel *reg", :action => :cmd_searchquote, :requirements => { :channel => Regexp::Irc::GEN_CHAN }, :auth_path => '!quote::other::view::search!'
429 plugin.map "listquotes :channel *reg", :action => :cmd_listquotes, :requirements => { :channel => Regexp::Irc::GEN_CHAN }, :auth_path => '!quote::other::view::list!'
430 plugin.map "countquote :channel [*reg]", :action => :cmd_countquote, :requirements => { :channel => Regexp::Irc::GEN_CHAN }, :auth_path => '!quote::other::view::count!'
431 plugin.map "topicquote :channel [:num]", :action => :cmd_topicquote, :requirements => { :channel => Regexp::Irc::GEN_CHAN, :num => /^\d+$/ }, :auth_path => '!quote::other::topic!'
432 plugin.map "lastquote :channel", :action => :cmd_lastquote, :requirements => { :channel => Regexp::Irc::GEN_CHAN }, :auth_path => '!quote::other::view::last!'
433
434 plugin.default_auth('edit', false) # Prevent random people from removing quotes
435 plugin.default_auth('edit::add', true) # But allow them to add them
436
437 plugin.map "addquote *quote", :action => :cmd_addquote, :private => false, :auth_path => '!quote::edit::add!'
438 plugin.map "delquote :num", :action => :cmd_delquote, :private => false, :requirements => { :num => /^\d+$/ }, :auth_path => '!quote::edit::del!'
439 plugin.map "getquote [:num]", :action => :cmd_getquote, :private => false, :requirements => { :num => /^\d+$/ }, :auth_path => '!quote::view::get!'
440 plugin.map "whoquote :num", :action => :cmd_whoquote, :private => false, :requirements => { :num => /^\d+$/ }, :auth_path => '!quote::view::who!'
441 plugin.map "whenquote :num", :action => :cmd_whenquote, :private => false, :requirements => { :num => /^\d+$/ }, :auth_path => '!quote::view::when!'
442 plugin.map "searchquote *reg", :action => :cmd_searchquote, :private => false, :auth_path => '!quote::view::search!'
443 plugin.map "listquotes *reg", :action => :cmd_listquotes, :private => false, :auth_path => '!quote::view::list!'
444 plugin.map "countquote [*reg]", :action => :cmd_countquote, :private => false, :auth_path => '!quote::view::count!'
445 plugin.map "topicquote [:num]", :action => :cmd_topicquote, :private => false, :requirements => { :num => /^\d+$/ }, :auth_path => '!quote::topic!'
446 plugin.map "lastquote", :action => :cmd_lastquote, :private => false, :auth_path => '!quote::view::last!'
447
448 plugin.default_auth('dump', false) # Prevent random people from dumping the database
449 plugin.map "dumpxml", :action => :cmd_dumptoxml, :auth_path => '!quote::dump!'
450 plugin.map "dumphtml", :action => :cmd_dumptohtml, :auth_path => '!quote::dump!'