4 # :title: rbot IRC logging facilities
6 # Author:: Giuseppe "Oblomov" Bilotta <giuseppe.bilotta@gmail.com>
8 class IrcLogModule < CoreBotModule
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']
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
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")
30 attr :nolog_rx, :dolog_rx
34 @thread = Thread.new { loggers_thread }
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']
42 return true if @dolog_rx and where.match @dolog_rx
43 return false if @nolog_rx and where.match @nolog_rx
48 return time.strftime @bot.config['irclog.timestamp_format']
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)
59 }.each { |w| logfile_close(w, 'logging disabled here') }
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})"
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]
84 irclog "-#{m.source}- #{m.message}", m.target
86 logtarget = who = m.target
90 irclog "* #{m.source} #{m.logmessage}", logtarget
92 irclog "@ #{m.source} asked #{who} about version info", logtarget
94 irclog "@ #{m.source} asked #{who} about source info", logtarget
96 irclog "@ #{m.source} pinged #{who}", logtarget
98 irclog "@ #{m.source} asked #{who} what time it is", logtarget
100 irclog "@ #{m.source} asked #{who} about #{[m.ctcp, m.message].join(' ')}", logtarget
103 irclog "<#{m.source}> #{m.logmessage}", logtarget
107 irclog "@ quit (#{m.message})", ch
113 irclog "joined server #{m.server} as #{m.target}", "server"
119 method = 'log_message'
121 method = 'log_' + m.class.name.downcase.match(/^irc::(\w+)message$/).captures.first
123 if self.respond_to?(method)
124 self.__send__(method, m)
126 warning "unhandled logging for #{m.pretty_inspect} (no such method #{method})"
133 who = m.private? ? "me" : m.target
134 logtarget = m.private? ? m.source : m.target
138 irclog "* #{m.source} #{m.logmessage}", m.target
140 irclog "* #{m.source}(#{m.sourceaddress}) #{m.logmessage}", m.source
143 irclog "@ #{m.source} asked #{who} about version info", logtarget
145 irclog "@ #{m.source} asked #{who} about source info", logtarget
147 irclog "@ #{m.source} pinged #{who}", logtarget
149 irclog "@ #{m.source} asked #{who} what time it is", logtarget
151 irclog "@ #{m.source} asked #{who} about #{[m.ctcp, m.message].join(' ')}", logtarget
155 irclog "<#{m.source}> #{m.logmessage}", m.target
157 irclog "<#{m.source}(#{m.sourceaddress})> #{m.logmessage}", m.source
164 irclog "-#{m.source}(#{m.sourceaddress})- #{m.logmessage}", m.source
166 irclog "-#{m.source}- #{m.logmessage}", m.target
171 m.message.each_line { |line|
172 irclog "MOTD: #{line}", "server"
177 (m.is_on & @bot.myself.channels).each { |ch|
178 irclog "@ #{m.oldnick} is now known as #{m.newnick}", ch
183 (m.was_on & @bot.myself.channels).each { |ch|
184 irclog "@ Quit: #{m.source}: #{m.logmessage}", ch
189 irclog "@ Mode #{m.logmessage} by #{m.source}", m.target
194 debug "joined channel #{m.channel}"
195 irclog "@ Joined channel #{m.channel}", m.channel
197 irclog "@ #{m.source} joined channel #{m.channel}", m.channel
203 debug "left channel #{m.channel}"
204 irclog "@ Left channel #{m.channel} (#{m.logmessage})", m.channel
206 irclog "@ #{m.source} left channel #{m.channel} (#{m.logmessage})", m.channel
212 debug "kicked from channel #{m.channel}"
213 irclog "@ You have been kicked from #{m.channel} by #{m.source} (#{m.logmessage})", m.channel
215 irclog "@ #{m.target} has been kicked from #{m.channel} by #{m.source} (#{m.logmessage})", m.channel
226 if m.source == @bot.myself
227 irclog "@ I set topic \"#{m.topic}\"", m.channel
229 irclog "@ #{m.source} set topic \"#{m.topic}\"", m.channel
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
242 def unknown_message(m)
243 irclog m.logmessage, ".unknown"
246 def logfilepath(where_str, now)
247 File.join(@bot.botclass, 'logs', now.strftime(@fn_format) % { :where => where_str })
253 debug 'loggers_thread starting'
254 while ls = @queue.pop
256 message = message.chomp
258 stamp = timestamp(now)
259 if where.class <= Server
262 where_str = where.downcase.gsub(/[:!?$*()\/\\<>|"']/, "_")
264 return unless can_log_on(where_str)
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
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]}"
281 fp = logfilepath(where_str, now)
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
290 up.replace File.dirname up
292 unless File.directory? up
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)
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
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)
307 # it should be fine to create the file now
308 f = File.new(fp, "a")
310 f.puts "#{stamp} @ Log started by #{@bot.myself.nick}"
311 rescue Exception => e
315 @logs[where_str] = [now, f]
317 @logs[where_str][1].puts "#{stamp} #{message}"
318 @logs[where_str][0] = now
319 #debug "#{stamp} <#{where}> #{message}"
321 @logs.keys.each { |w| logfile_close(w, 'rescan or shutdown') }
322 debug 'loggers_thread terminating'
326 ilm = IrcLogModule.new