]> git.netwichtig.de Git - user/henk/code/ruby/rbot.git/blob - lib/rbot/core/irclog.rb
88d8b00f8d9c6726a276c7ae3581729039c0ecf6
[user/henk/code/ruby/rbot.git] / lib / rbot / core / irclog.rb
1 #-- vim:sw=2:et
2 #++
3 #
4 # :title: rbot IRC logging facilities
5 #
6 # Author:: Giuseppe "Oblomov" Bilotta <giuseppe.bilotta@gmail.com>
7
8 class IrcLogModule < CoreBotModule
9
10   Config.register Config::IntegerValue.new('irclog.max_open_files',
11     :default => 20, :validate => Proc.new { |v| v > 0 },
12     :desc => "Maximum number of irclog files to keep open at any one time.")
13   Config.register Config::ArrayValue.new('irclog.no_log',
14     :default => [], :on_change => Proc.new { |bot, v|
15       bot.plugins.delegate 'event_irclog_list_changed', v, bot.config['irclog.do_log']
16     },
17     :desc => "List of channels and nicks for which logging is disabled. IRC patterns can be used too.")
18   Config.register Config::ArrayValue.new('irclog.do_log',
19     :default => [], :on_change => Proc.new { |bot, v|
20       bot.plugins.delegate 'event_irclog_list_changed', bot.config['irclog.no_log'], v
21     },
22     :desc => "List of channels and nicks for which logging is enabled. IRC patterns can be used too. This can be used to override wide patters in irclog.no_log")
23   Config.register Config::StringValue.new('irclog.filename_format',
24     :default => '%%{where}', :requires_rescan => true,
25     :desc => "filename pattern for the IRC log. You can put typical strftime keys such as %Y for year and %m for month, plus the special %%{where} key for location (channel name or user nick)")
26   Config.register Config::StringValue.new('irclog.timestamp_format',
27     :default => '[%Y/%m/%d %H:%M:%S]', :requires_rescan => true,
28     :desc => "timestamp pattern for the IRC log, using typical strftime keys")
29
30   attr :nolog_rx, :dolog_rx
31   def initialize
32     super
33     @queue = Queue.new
34     @thread = Thread.new { loggers_thread }
35     @logs = Hash.new
36     Dir.mkdir("#{@bot.botclass}/logs") unless File.exist?("#{@bot.botclass}/logs")
37     event_irclog_list_changed(@bot.config['irclog.no_log'], @bot.config['irclog.do_log'])
38     @fn_format = @bot.config['irclog.filename_format']
39   end
40
41   def can_log_on(where)
42     return true if @dolog_rx and where.match @dolog_rx
43     return false if @nolog_rx and where.match @nolog_rx
44     return true
45   end
46
47   def timestamp(time)
48     return time.strftime @bot.config['irclog.timestamp_format']
49   end
50
51   def event_irclog_list_changed(nolist, dolist)
52     @nolog_rx = nolist.empty? ? nil : Regexp.union(*(nolist.map { |r| r.to_irc_regexp }))
53     debug "no log: #{@nolog_rx}"
54     @dolog_rx = dolist.empty? ? nil : Regexp.union(*(dolist.map { |r| r.to_irc_regexp }))
55     debug "do log: #{@dolog_rx}"
56     @logs.inject([]) { |ar, kv|
57       ar << kv.first unless can_log_on(kv.first)
58       ar
59     }.each { |w| logfile_close(w, 'logging disabled here') }
60   end
61
62   def logfile_close(where_str, reason = 'unknown reason')
63     f = @logs.delete(where_str) or return
64     stamp = timestamp(Time.now)
65     f[1].puts "#{stamp} @ Log closed by #{@bot.myself.nick} (#{reason})"
66     f[1].close
67   end
68
69   # log IRC-related message +message+ to a file determined by +where+.
70   # +where+ can be a channel name, or a nick for private message logging
71   def irclog(message, where="server")
72     @queue.push [message, where]
73   end
74
75   def cleanup
76     @queue << nil
77     @thread.join
78     @thread = nil
79   end
80
81   def sent(m)
82     case m
83     when NoticeMessage
84       irclog "-#{m.source}- #{m.message}", m.target
85     when PrivMessage
86       logtarget = who = m.target
87       if m.ctcp
88         case m.ctcp.intern
89         when :ACTION
90           irclog "* #{m.source} #{m.logmessage}", logtarget
91         when :VERSION
92           irclog "@ #{m.source} asked #{who} about version info", logtarget
93         when :SOURCE
94           irclog "@ #{m.source} asked #{who} about source info", logtarget
95         when :PING
96           irclog "@ #{m.source} pinged #{who}", logtarget
97         when :TIME
98           irclog "@ #{m.source} asked #{who} what time it is", logtarget
99         else
100           irclog "@ #{m.source} asked #{who} about #{[m.ctcp, m.message].join(' ')}", logtarget
101         end
102       else
103         irclog "<#{m.source}> #{m.logmessage}", logtarget
104       end
105     when QuitMessage
106       m.was_on.each { |ch|
107         irclog "@ quit (#{m.message})", ch
108       }
109     end
110   end
111
112   def welcome(m)
113     irclog "joined server #{m.server} as #{m.target}", "server"
114   end
115
116   def listen(m)
117     case m
118     when PrivMessage
119       method = 'log_message'
120     else
121       method = 'log_' + m.class.name.downcase.match(/^irc::(\w+)message$/).captures.first
122     end
123     if self.respond_to?(method)
124       self.__send__(method, m)
125     else
126       warning "unhandled logging for #{m.pretty_inspect} (no such method #{method})"
127       unknown_message(m)
128     end
129   end
130
131   def log_message(m)
132     if m.ctcp
133       who = m.private? ? "me" : m.target
134       logtarget = m.private? ? m.source : m.target
135       case m.ctcp.intern
136       when :ACTION
137         if m.public?
138           irclog "* #{m.source} #{m.logmessage}", m.target
139         else
140           irclog "* #{m.source}(#{m.sourceaddress}) #{m.logmessage}", m.source
141         end
142       when :VERSION
143         irclog "@ #{m.source} asked #{who} about version info", logtarget
144       when :SOURCE
145         irclog "@ #{m.source} asked #{who} about source info", logtarget
146       when :PING
147         irclog "@ #{m.source} pinged #{who}", logtarget
148       when :TIME
149         irclog "@ #{m.source} asked #{who} what time it is", logtarget
150       else
151         irclog "@ #{m.source} asked #{who} about #{[m.ctcp, m.message].join(' ')}", logtarget
152       end
153     else
154       if m.public? 
155         irclog "<#{m.source}> #{m.logmessage}", m.target
156       else
157         irclog "<#{m.source}(#{m.sourceaddress})> #{m.logmessage}", m.source
158       end
159     end
160   end
161
162   def log_notice(m)
163     if m.private?
164       irclog "-#{m.source}(#{m.sourceaddress})- #{m.logmessage}", m.source
165     else
166       irclog "-#{m.source}- #{m.logmessage}", m.target
167     end
168   end
169
170   def motd(m)
171     m.message.each_line { |line|
172       irclog "MOTD: #{line}", "server"
173     }
174   end
175
176   def log_nick(m)
177     (m.is_on & @bot.myself.channels).each { |ch|
178       irclog "@ #{m.oldnick} is now known as #{m.newnick}", ch
179     }
180   end
181
182   def log_quit(m)
183     (m.was_on & @bot.myself.channels).each { |ch|
184       irclog "@ Quit: #{m.source}: #{m.logmessage}", ch
185     }
186   end
187
188   def modechange(m)
189     irclog "@ Mode #{m.logmessage} by #{m.source}", m.target
190   end
191
192   def log_join(m)
193     if m.address?
194       debug "joined channel #{m.channel}"
195       irclog "@ Joined channel #{m.channel}", m.channel
196     else
197       irclog "@ #{m.source} joined channel #{m.channel}", m.channel
198     end
199   end
200
201   def log_part(m)
202     if(m.address?)
203       debug "left channel #{m.channel}"
204       irclog "@ Left channel #{m.channel} (#{m.logmessage})", m.channel
205     else
206       irclog "@ #{m.source} left channel #{m.channel} (#{m.logmessage})", m.channel
207     end
208   end
209
210   def log_kick(m)
211     if(m.address?)
212       debug "kicked from channel #{m.channel}"
213       irclog "@ You have been kicked from #{m.channel} by #{m.source} (#{m.logmessage})", m.channel
214     else
215       irclog "@ #{m.target} has been kicked from #{m.channel} by #{m.source} (#{m.logmessage})", m.channel
216     end
217   end
218
219   # def log_invite(m)
220   #   # TODO
221   # end
222
223   def log_topic(m)
224     case m.info_or_set
225     when :set
226       if m.source == @bot.myself
227         irclog "@ I set topic \"#{m.topic}\"", m.channel
228       else
229         irclog "@ #{m.source} set topic \"#{m.topic}\"", m.channel
230       end
231     when :info
232       topic = m.channel.topic
233       irclog "@ Topic is \"#{m.topic}\"", m.channel
234       irclog "@ Topic set by #{topic.set_by} on #{topic.set_on}", m.channel
235     end
236   end
237
238   # def names(m)
239   #   # TODO
240   # end
241
242   def unknown_message(m)
243     irclog m.logmessage, ".unknown"
244   end
245
246   def logfilepath(where_str, now)
247     File.join(@bot.botclass, 'logs', now.strftime(@fn_format) % { :where => where_str })
248   end
249
250   protected
251   def loggers_thread
252     ls = nil
253     debug 'loggers_thread starting'
254     while ls = @queue.pop
255       message, where = ls
256       message = message.chomp
257       now = Time.now
258       stamp = timestamp(now)
259       if where.class <= Server
260         where_str = "server"
261       else
262         where_str = where.downcase.gsub(/[:!?$*()\/\\<>|"']/, "_")
263       end
264       return unless can_log_on(where_str)
265
266       # close the previous logfile if we're rotating
267       if @logs.has_key? where_str
268         fp = logfilepath(where_str, now)
269         logfile_close(where_str, 'log rotation') if fp != @logs[where_str][1].path
270       end
271
272       # (re)open the logfile if necessary
273       unless @logs.has_key? where_str
274         if @logs.size > @bot.config['irclog.max_open_files']
275           @logs.keys.sort do |a, b|
276             @logs[a][0] <=> @logs[b][0]
277           end.slice(0, @logs.size - @bot.config['irclog.max_open_files']).each do |w|
278             logfile_close w, "idle since #{@logs[w][0]}"
279           end
280         end
281         fp = logfilepath(where_str, now)
282         begin
283           dir = File.dirname(fp)
284           # first of all, we check we're not trying to build a directory structure
285           # where one of the components exists already as a file, so we
286           # backtrack along dir until we come across the topmost existing name.
287           # If it's a file, we rename to filename.old.filedate
288           up = dir.dup
289           until File.exist? up
290             up.replace File.dirname up
291           end
292           unless File.directory? up
293             backup = up.dup
294             backup << ".old." << File.atime(up).strftime('%Y%m%d%H%M%S')
295             debug "#{up} is not a directory! renaming to #{backup}"
296             File.rename(up, backup)
297           end
298           FileUtils.mkdir_p(dir)
299           # conversely, it may happen that fp exists and is a directory, in
300           # which case we rename the directory instead
301           if File.directory? fp
302             backup = fp.dup
303             backup << ".old." << File.atime(fp).strftime('%Y%m%d%H%M%S')
304             debug "#{fp} is not a file! renaming to #{backup}"
305             File.rename(fp, backup)
306           end
307           # it should be fine to create the file now
308           f = File.new(fp, "a")
309           f.sync = true
310           f.puts "#{stamp} @ Log started by #{@bot.myself.nick}"
311         rescue Exception => e
312           error e
313           next
314         end
315         @logs[where_str] = [now, f]
316       end
317       @logs[where_str][1].puts "#{stamp} #{message}"
318       @logs[where_str][0] = now
319       #debug "#{stamp} <#{where}> #{message}"
320     end
321     @logs.keys.each { |w| logfile_close(w, 'rescan or shutdown') }
322     debug 'loggers_thread terminating'
323   end
324 end
325
326 ilm = IrcLogModule.new
327 ilm.priority = -1
328