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 logdir = @bot.path 'logs'
37 Dir.mkdir(logdir) unless File.exist?(logdir)
38 # TODO what shall we do if the logdir couldn't be created? (e.g. it existed as a file)
39 event_irclog_list_changed(@bot.config['irclog.no_log'], @bot.config['irclog.do_log'])
40 @fn_format = @bot.config['irclog.filename_format']
44 return true if @dolog_rx and where.match @dolog_rx
45 return false if @nolog_rx and where.match @nolog_rx
50 return time.strftime @bot.config['irclog.timestamp_format']
53 def event_irclog_list_changed(nolist, dolist)
54 @nolog_rx = nolist.empty? ? nil : Regexp.union(*(nolist.map { |r| r.to_irc_regexp }))
55 debug "no log: #{@nolog_rx}"
56 @dolog_rx = dolist.empty? ? nil : Regexp.union(*(dolist.map { |r| r.to_irc_regexp }))
57 debug "do log: #{@dolog_rx}"
58 @logs.inject([]) { |ar, kv|
59 ar << kv.first unless can_log_on(kv.first)
61 }.each { |w| logfile_close(w, 'logging disabled here') }
64 def logfile_close(where_str, reason = 'unknown reason')
65 f = @logs.delete(where_str) or return
66 stamp = timestamp(Time.now)
67 f[1].puts "#{stamp} @ Log closed by #{@bot.myself.nick} (#{reason})"
71 # log IRC-related message +message+ to a file determined by +where+.
72 # +where+ can be a channel name, or a nick for private message logging
73 def irclog(message, where="server")
74 @queue.push [message, where]
86 irclog "-#{m.source}- #{m.message}", m.target
88 logtarget = who = m.target
92 irclog "* #{m.source} #{m.logmessage}", logtarget
94 irclog "@ #{m.source} asked #{who} about version info", logtarget
96 irclog "@ #{m.source} asked #{who} about source info", logtarget
98 irclog "@ #{m.source} pinged #{who}", logtarget
100 irclog "@ #{m.source} asked #{who} what time it is", logtarget
102 irclog "@ #{m.source} asked #{who} about #{[m.ctcp, m.message].join(' ')}", logtarget
105 irclog "<#{m.source}> #{m.logmessage}", logtarget
109 irclog "@ quit (#{m.message})", ch
115 irclog "joined server #{m.server} as #{m.target}", "server"
121 method = 'log_message'
123 method = 'log_' + m.class.name.downcase.match(/^irc::(\w+)message$/).captures.first
125 if self.respond_to?(method)
126 self.__send__(method, m)
128 warning "unhandled logging for #{m.pretty_inspect} (no such method #{method})"
135 who = m.private? ? "me" : m.target
136 logtarget = m.private? ? m.source : m.target
140 irclog "* #{m.source} #{m.logmessage}", m.target
142 irclog "* #{m.source}(#{m.sourceaddress}) #{m.logmessage}", m.source
145 irclog "@ #{m.source} asked #{who} about version info", logtarget
147 irclog "@ #{m.source} asked #{who} about source info", logtarget
149 irclog "@ #{m.source} pinged #{who}", logtarget
151 irclog "@ #{m.source} asked #{who} what time it is", logtarget
153 irclog "@ #{m.source} asked #{who} about #{[m.ctcp, m.message].join(' ')}", logtarget
157 irclog "<#{m.source}> #{m.logmessage}", m.target
159 irclog "<#{m.source}(#{m.sourceaddress})> #{m.logmessage}", m.source
166 irclog "-#{m.source}(#{m.sourceaddress})- #{m.logmessage}", m.source
168 irclog "-#{m.source}- #{m.logmessage}", m.target
173 m.message.each_line { |line|
174 irclog "MOTD: #{line}", "server"
179 (m.is_on & @bot.myself.channels).each { |ch|
180 irclog "@ #{m.oldnick} is now known as #{m.newnick}", ch
185 (m.was_on & @bot.myself.channels).each { |ch|
186 irclog "@ Quit: #{m.source}: #{m.logmessage}", ch
191 irclog "@ Mode #{m.logmessage} by #{m.source}", m.target
196 debug "joined channel #{m.channel}"
197 irclog "@ Joined channel #{m.channel}", m.channel
199 irclog "@ #{m.source} joined channel #{m.channel}", m.channel
205 debug "left channel #{m.channel}"
206 irclog "@ Left channel #{m.channel} (#{m.logmessage})", m.channel
208 irclog "@ #{m.source} left channel #{m.channel} (#{m.logmessage})", m.channel
214 debug "kicked from channel #{m.channel}"
215 irclog "@ You have been kicked from #{m.channel} by #{m.source} (#{m.logmessage})", m.channel
217 irclog "@ #{m.target} has been kicked from #{m.channel} by #{m.source} (#{m.logmessage})", m.channel
228 if m.source == @bot.myself
229 irclog "@ I set topic \"#{m.topic}\"", m.channel
231 irclog "@ #{m.source} set topic \"#{m.topic}\"", m.channel
234 topic = m.channel.topic
235 irclog "@ Topic is \"#{m.topic}\"", m.channel
236 irclog "@ Topic set by #{topic.set_by} on #{topic.set_on}", m.channel
244 def unknown_message(m)
245 irclog m.logmessage, ".unknown"
248 def logfilepath(where_str, now)
249 @bot.path('logs', now.strftime(@fn_format) % { :where => where_str })
255 debug 'loggers_thread starting'
256 while ls = @queue.pop
258 message = message.chomp
260 stamp = timestamp(now)
261 if where.class <= Server
264 where_str = where.downcase.gsub(/[:!?$*()\/\\<>|"']/, "_")
266 return unless can_log_on(where_str)
268 # close the previous logfile if we're rotating
269 if @logs.has_key? where_str
270 fp = logfilepath(where_str, now)
271 logfile_close(where_str, 'log rotation') if fp != @logs[where_str][1].path
274 # (re)open the logfile if necessary
275 unless @logs.has_key? where_str
276 if @logs.size > @bot.config['irclog.max_open_files']
277 @logs.keys.sort do |a, b|
278 @logs[a][0] <=> @logs[b][0]
279 end.slice(0, @logs.size - @bot.config['irclog.max_open_files']).each do |w|
280 logfile_close w, "idle since #{@logs[w][0]}"
283 fp = logfilepath(where_str, now)
285 dir = File.dirname(fp)
286 # first of all, we check we're not trying to build a directory structure
287 # where one of the components exists already as a file, so we
288 # backtrack along dir until we come across the topmost existing name.
289 # If it's a file, we rename to filename.old.filedate
292 up.replace File.dirname up
294 unless File.directory? up
296 backup << ".old." << File.atime(up).strftime('%Y%m%d%H%M%S')
297 debug "#{up} is not a directory! renaming to #{backup}"
298 File.rename(up, backup)
300 FileUtils.mkdir_p(dir)
301 # conversely, it may happen that fp exists and is a directory, in
302 # which case we rename the directory instead
303 if File.directory? fp
305 backup << ".old." << File.atime(fp).strftime('%Y%m%d%H%M%S')
306 debug "#{fp} is not a file! renaming to #{backup}"
307 File.rename(fp, backup)
309 # it should be fine to create the file now
310 f = File.new(fp, "a")
312 f.puts "#{stamp} @ Log started by #{@bot.myself.nick}"
313 rescue Exception => e
317 @logs[where_str] = [now, f]
319 @logs[where_str][1].puts "#{stamp} #{message}"
320 @logs[where_str][0] = now
321 #debug "#{stamp} <#{where}> #{message}"
323 @logs.keys.each { |w| logfile_close(w, 'rescan or shutdown') }
324 debug 'loggers_thread terminating'
328 ilm = IrcLogModule.new