# :title: rbot IRC logging facilities
#
# Author:: Giuseppe "Oblomov" Bilotta <giuseppe.bilotta@gmail.com>
-# Copyright:: (C) 2008 Giuseppe Bilotta
-# License:: GPL v2
class IrcLogModule < CoreBotModule
- MAX_OPEN_FILES = 20 # XXX: maybe add a config value
-
+ Config.register Config::IntegerValue.new('irclog.max_open_files',
+ :default => 20, :validate => Proc.new { |v| v > 0 },
+ :desc => "Maximum number of irclog files to keep open at any one time.")
+ Config.register Config::ArrayValue.new('irclog.no_log',
+ :default => [], :on_change => Proc.new { |bot, v|
+ bot.plugins.delegate 'event_irclog_list_changed', v, bot.config['irclog.do_log']
+ },
+ :desc => "List of channels and nicks for which logging is disabled. IRC patterns can be used too.")
+ Config.register Config::ArrayValue.new('irclog.do_log',
+ :default => [], :on_change => Proc.new { |bot, v|
+ bot.plugins.delegate 'event_irclog_list_changed', bot.config['irclog.no_log'], v
+ },
+ :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")
+ Config.register Config::StringValue.new('irclog.filename_format',
+ :default => '%%{where}', :requires_rescan => true,
+ :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)")
+ Config.register Config::StringValue.new('irclog.timestamp_format',
+ :default => '[%Y/%m/%d %H:%M:%S]', :requires_rescan => true,
+ :desc => "timestamp pattern for the IRC log, using typical strftime keys")
+
+ attr :nolog_rx, :dolog_rx
def initialize
super
+ @queue = Queue.new
+ @thread = Thread.new { loggers_thread }
@logs = Hash.new
- Dir.mkdir("#{@bot.botclass}/logs") unless File.exist?("#{@bot.botclass}/logs")
+ logdir = @bot.path 'logs'
+ Dir.mkdir(logdir) unless File.exist?(logdir)
+ # TODO what shall we do if the logdir couldn't be created? (e.g. it existed as a file)
+ event_irclog_list_changed(@bot.config['irclog.no_log'], @bot.config['irclog.do_log'])
+ @fn_format = @bot.config['irclog.filename_format']
+ end
+
+ def can_log_on(where)
+ return true if @dolog_rx and where.match @dolog_rx
+ return false if @nolog_rx and where.match @nolog_rx
+ return true
+ end
+
+ def timestamp(time)
+ return time.strftime @bot.config['irclog.timestamp_format']
+ end
+
+ def event_irclog_list_changed(nolist, dolist)
+ @nolog_rx = nolist.empty? ? nil : Regexp.union(*(nolist.map { |r| r.to_irc_regexp }))
+ debug "no log: #{@nolog_rx}"
+ @dolog_rx = dolist.empty? ? nil : Regexp.union(*(dolist.map { |r| r.to_irc_regexp }))
+ debug "do log: #{@dolog_rx}"
+ @logs.inject([]) { |ar, kv|
+ ar << kv.first unless can_log_on(kv.first)
+ ar
+ }.each { |w| logfile_close(w, 'logging disabled here') }
end
def logfile_close(where_str, reason = 'unknown reason')
f = @logs.delete(where_str) or return
- stamp = Time.now.strftime '%Y/%m/%d %H:%M:%S'
- f[1].puts "[#{stamp}] @ Log closed by #{@bot.myself.nick} (#{reason})"
+ stamp = timestamp(Time.now)
+ f[1].puts "#{stamp} @ Log closed by #{@bot.myself.nick} (#{reason})"
f[1].close
end
# log IRC-related message +message+ to a file determined by +where+.
# +where+ can be a channel name, or a nick for private message logging
def irclog(message, where="server")
- message = message.chomp
- now = Time.now
- stamp = now.strftime("%Y/%m/%d %H:%M:%S")
- if where.class <= Server
- where_str = "server"
- else
- where_str = where.downcase.gsub(/[:!?$*()\/\\<>|"']/, "_")
- end
- unless @logs.has_key? where_str
- if @logs.size > MAX_OPEN_FILES
- @logs.keys.sort do |a, b|
- @logs[a][0] <=> @logs[b][0]
- end.slice(0, @logs.size - MAX_OPEN_FILES).each do |w|
- logfile_close w, "idle since #{@logs[w][0]}"
- end
- end
- f = File.new("#{@bot.botclass}/logs/#{where_str}", "a")
- f.sync = true
- f.puts "[#{stamp}] @ Log started by #{@bot.myself.nick}"
- @logs[where_str] = [now, f]
- end
- @logs[where_str][1].puts "[#{stamp}] #{message}"
- @logs[where_str][0] = now
- #debug "[#{stamp}] <#{where}> #{message}"
+ @queue.push [message, where]
+ end
+
+ def cleanup
+ @queue << nil
+ @thread.join
+ @thread = nil
end
def sent(m)
when NoticeMessage
irclog "-#{m.source}- #{m.message}", m.target
when PrivMessage
- irclog "<#{m.source}> #{m.message}", m.target
+ logtarget = who = m.target
+ if m.ctcp
+ case m.ctcp.intern
+ when :ACTION
+ irclog "* #{m.source} #{m.logmessage}", logtarget
+ when :VERSION
+ irclog "@ #{m.source} asked #{who} about version info", logtarget
+ when :SOURCE
+ irclog "@ #{m.source} asked #{who} about source info", logtarget
+ when :PING
+ irclog "@ #{m.source} pinged #{who}", logtarget
+ when :TIME
+ irclog "@ #{m.source} asked #{who} what time it is", logtarget
+ else
+ irclog "@ #{m.source} asked #{who} about #{[m.ctcp, m.message].join(' ')}", logtarget
+ end
+ else
+ irclog "<#{m.source}> #{m.logmessage}", logtarget
+ end
when QuitMessage
m.was_on.each { |ch|
irclog "@ quit (#{m.message})", ch
irclog "@ #{m.source} asked #{who} about #{[m.ctcp, m.message].join(' ')}", logtarget
end
else
- if m.public?
+ if m.public?
irclog "<#{m.source}> #{m.logmessage}", m.target
else
irclog "<#{m.source}(#{m.sourceaddress})> #{m.logmessage}", m.source
end
def log_nick(m)
- m.is_on.each { |ch|
+ (m.is_on & @bot.myself.channels).each { |ch|
irclog "@ #{m.oldnick} is now known as #{m.newnick}", ch
}
end
def log_quit(m)
- m.was_on.each { |ch|
+ (m.was_on & @bot.myself.channels).each { |ch|
irclog "@ Quit: #{m.source}: #{m.logmessage}", ch
}
end
def unknown_message(m)
irclog m.logmessage, ".unknown"
end
+
+ def logfilepath(where_str, now)
+ @bot.path('logs', now.strftime(@fn_format) % { :where => where_str })
+ end
+
+ protected
+ def loggers_thread
+ ls = nil
+ debug 'loggers_thread starting'
+ while ls = @queue.pop
+ message, where = ls
+ message = message.chomp
+ now = Time.now
+ stamp = timestamp(now)
+ if where.class <= Server
+ where_str = "server"
+ else
+ where_str = where.downcase.gsub(/[:!?$*()\/\\<>|"']/, "_")
+ end
+ return unless can_log_on(where_str)
+
+ # close the previous logfile if we're rotating
+ if @logs.has_key? where_str
+ fp = logfilepath(where_str, now)
+ logfile_close(where_str, 'log rotation') if fp != @logs[where_str][1].path
+ end
+
+ # (re)open the logfile if necessary
+ unless @logs.has_key? where_str
+ if @logs.size > @bot.config['irclog.max_open_files']
+ @logs.keys.sort do |a, b|
+ @logs[a][0] <=> @logs[b][0]
+ end.slice(0, @logs.size - @bot.config['irclog.max_open_files']).each do |w|
+ logfile_close w, "idle since #{@logs[w][0]}"
+ end
+ end
+ fp = logfilepath(where_str, now)
+ begin
+ dir = File.dirname(fp)
+ # first of all, we check we're not trying to build a directory structure
+ # where one of the components exists already as a file, so we
+ # backtrack along dir until we come across the topmost existing name.
+ # If it's a file, we rename to filename.old.filedate
+ up = dir.dup
+ until File.exist? up
+ up.replace File.dirname up
+ end
+ unless File.directory? up
+ backup = up.dup
+ backup << ".old." << File.atime(up).strftime('%Y%m%d%H%M%S')
+ debug "#{up} is not a directory! renaming to #{backup}"
+ File.rename(up, backup)
+ end
+ FileUtils.mkdir_p(dir)
+ # conversely, it may happen that fp exists and is a directory, in
+ # which case we rename the directory instead
+ if File.directory? fp
+ backup = fp.dup
+ backup << ".old." << File.atime(fp).strftime('%Y%m%d%H%M%S')
+ debug "#{fp} is not a file! renaming to #{backup}"
+ File.rename(fp, backup)
+ end
+ # it should be fine to create the file now
+ f = File.new(fp, "a")
+ f.sync = true
+ f.puts "#{stamp} @ Log started by #{@bot.myself.nick}"
+ rescue Exception => e
+ error e
+ next
+ end
+ @logs[where_str] = [now, f]
+ end
+ @logs[where_str][1].puts "#{stamp} #{message}"
+ @logs[where_str][0] = now
+ #debug "#{stamp} <#{where}> #{message}"
+ end
+ @logs.keys.each { |w| logfile_close(w, 'rescan or shutdown') }
+ debug 'loggers_thread terminating'
+ end
end
ilm = IrcLogModule.new