· 9 years ago · Nov 27, 2016, 08:58 PM
1# encoding: utf-8
2#
3# ChatLogger Plugin for BigBrotherBot
4# Copyright (C) 2008 Courgette
5#
6# This program is free software; you can redistribute it and/or modify
7# it under the terms of the GNU General Public License as published by
8# the Free Software Foundation; either version 2 of the License, or
9# (at your option) any later version.
10#
11# This program is distributed in the hope that it will be useful,
12# but WITHOUT ANY WARRANTY; without even the implied warranty of
13# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14# GNU General Public License for more details.
15#
16# You should have received a copy of the GNU General Public License
17# along with this program; if not, write to the Free Software
18# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
19#
20# Changelog:
21#
22# 28/07/2008 - 0.0.1
23# - manage say, teamsay and privatesay messages
24# 14/08/2008 - 0.1.0
25# - fix security issue with player names of messages containing double quote or antislash characters (Thx to Anubis for report and tests)
26# - allows to setup a daily purge of old messages to keep your database size reasonable
27# 13/09/2008 - 0.1.1
28# - in config, the hour defined for the purge is now understood in the timezone defined in the main B3 config file (before, was understood as UTC time)
29# - fix mistake in log text
30# 7/11/2008 - 0.1.2 - xlr8or
31# - added missing 'import b3.timezones'
32# 22/12/2008 - 0.2.0 - Courgette
33# - allow to use a customized table name for storing the
34# log to database. Usefull if multiple instances of the
35# bot share the same database.
36# Thanks to Eire.32 for bringing up the idea and testing.
37# 11/04/2011 - 0.2.1 - Courgette
38# - update the sql script to use the utf8 charset
39# 16/04/2011 - 1.0.0 - Courgette
40# - can log to a file instead of logging to db (or both)
41# - requires B3 1.6+
42# 01/09/2011 - 1.1.0 - BlackMamba
43# - log commands to db
44# 01/09/2011 - 1.1.1 - Courgette
45# - refactoring to reduce code duplication
46# - better test coverage
47# 12/09/2011 - 1.1.2 - Courgette
48# - start without failure even if the plugin is loaded before the admin plugin
49# - do not fail to handle SQLite database errors
50# 20/12/2011 - 1.1.3 - Courgette
51# - fixes #2 : Error DELETE FROM cmdlog WHERE msg_time (thanks to Mariodu62)
52# 03/03/2012 - 1.2 - OliverWieland
53# - add new setting max_age_cmd
54# 09/08/2012 - 1.3 - Courgette
55# - now also log events EVT_CLIENT_RADIO, EVT_CLIENT_CALLVOTE and EVT_CLIENT_VOTE when available
56# 12/08/2012 - 1.3.1 - Courgette
57# - gracefully fallback on default values when part of the config is missing
58#
59__version__ = '1.3.1'
60__author__ = 'Courgette, xlr8or, BlackMamba, OliverWieland'
61
62import b3.config
63import b3.parser
64import b3, time
65import logging
66import b3.events
67import b3.plugin
68import b3.cron
69import b3.timezones
70
71#--------------------------------------------------------------------------------------------------
72class ChatloggerPlugin(b3.plugin.Plugin):
73 _cronTab = None
74 _max_age_in_days = None
75 _hours = None
76 _minutes = None
77 _db_table = None
78 _db_table_cmdlog = None
79 _file_name = None
80 _filelogger = None
81 _save2db = None
82 _save2file = None
83 _file_rotation_rate = None
84
85
86 def onLoadConfig(self):
87 # remove eventual existing crontab
88 if self._cronTab:
89 self.console.cron - self._cronTab
90
91 try:
92 self._save2db = self.config.getboolean('general', 'save_to_database')
93 self.debug('save chat to database : %s', 'enabled' if self._save2db else 'disabled')
94 except b3.config.ConfigParser.NoOptionError:
95 self._save2db = True
96 self.info("Using default value '%s' for save_to_database", self._save2db)
97 except ValueError, err:
98 self._save2db = True
99 self.warning('Unexpected value for save_to_database. Using default value (%s) instead. (%s)', self._save2db, err)
100
101 try:
102 self._save2file = self.config.getboolean('general', 'save_to_file')
103 self.debug('save chat to file : %s', 'enabled' if self._save2file else 'disabled')
104 except b3.config.ConfigParser.NoOptionError:
105 self._save2file = False
106 self.info("Using default value '%s' for save_to_file", self._save2file)
107 except ValueError, err:
108 self._save2file = False
109 self.warning('Unexpected value for save_to_file. Using default value (%s) instead. (%s)', self._save2file, err)
110
111 if not (self._save2db or self._save2file):
112 self.warning("your config explicitly specify to log nowhere. Disabling plugin")
113 self.disable()
114
115 if self._save2db:
116 self.loadConfig_database()
117 if self._save2file:
118 self.loadConfig_file()
119
120
121 def loadConfig_file(self):
122 try:
123 self._file_name = self.config.getpath('file', 'logfile')
124 self.info('Using file (%s) to store log', self._file_name)
125 except Exception, e:
126 self.error('error while reading logfile name. disabling logging to file. (%s)' % e)
127 self._save2file = False
128 return
129
130 try:
131 self._file_rotation_rate = self.config.get('file', 'rotation_rate')
132 if self._file_rotation_rate.upper() not in ('H', 'D', 'W0', 'W1', 'W2', 'W3', 'W4', 'W5', 'W6'):
133 raise ValueError, 'Invalid rate specified: %s' % self._file_rotation_rate
134 self.info("Using value '%s' for the file rotation rate", self._file_rotation_rate)
135 except b3.config.ConfigParser.NoOptionError:
136 self._file_rotation_rate = 'D'
137 self.info("Using default value '%s' for the file rotation rate", self._file_rotation_rate)
138 except ValueError, e:
139 self._file_rotation_rate = 'D'
140 self.warning("unexpected value for file rotation rate. Falling back on default value : '%s' (%s)", self._file_rotation_rate, e)
141
142 self.setup_fileLogger()
143
144
145 def setup_fileLogger(self):
146 try:
147 self._filelogger = logging.getLogger('chatlogfile')
148 handler = logging.handlers.TimedRotatingFileHandler(self._file_name, when=self._file_rotation_rate, encoding="UTF-8")
149 handler.setFormatter(logging.Formatter('%(asctime)s\t%(message)s', '%y-%m-%d %H:%M:%S'))
150 self._filelogger.addHandler(handler)
151 self._filelogger.setLevel(logging.INFO)
152 except Exception, e:
153 self._save2file = False
154 self.error("cannot setup file chat logger. disabling logging to file (%s)" % e, exc_info=e)
155
156
157 def loadConfig_database(self):
158 try:
159 self._db_table = self.config.get('database', 'db_table')
160 self.debug('Using table (%s) to store log', self._db_table)
161 except:
162 self._db_table = 'chatlog'
163 self.debug('Using default value (%s) for db_table', self._db_table)
164
165 try:
166 self._db_table_cmdlog = self.config.get('database', 'db_table_cmdlog')
167 self.debug('Using table (%s) to store command log', self._db_table_cmdlog)
168 except:
169 self._db_table_cmdlog = 'cmdlog'
170 self.debug('Using default value (%s) for db_table_cmdlog', self._db_table_cmdlog)
171
172 try:
173 max_age = self.config.get('purge', 'max_age')
174 except:
175 max_age = "0d"
176 self.debug('Using default value (%s) for max_age', max_age)
177 days = self.string2days(max_age)
178 self.debug('max age : %s => %s days' % (max_age, days))
179
180 # force max age to be at least one day
181 if days != 0 and days < 1:
182 self._max_age_in_days = 1
183 else:
184 self._max_age_in_days = days
185
186
187 try:
188 max_age_cmd = self.config.get('purge', 'max_age_cmd')
189 except:
190 max_age_cmd = "0d"
191 self.debug('Using default value (%s) for max_age_cmd', max_age_cmd)
192 days_cmd = self.string2days(max_age_cmd)
193 self.debug('max age cmd : %s => %s days'%(max_age_cmd, days_cmd))
194
195 # force max age to be at least one day
196 if days_cmd != 0 and days_cmd < 1:
197 self._max_age_cmd_in_days = 1
198 else:
199 self._max_age_cmd_in_days = days_cmd
200
201
202 try:
203 self._hours = self.config.getint('purge', 'hour')
204 if self._hours < 0:
205 self._hours = 0
206 elif self._hours > 23:
207 self._hours = 23
208 except:
209 self._hours = 0
210 self.debug('Using default value (%s) for hours', self._hours)
211
212 try:
213 self._minutes = self.config.getint('purge', 'min')
214 if self._minutes < 0:
215 self._minutes = 0
216 elif self._minutes > 59:
217 self._minutes = 59
218 except:
219 self._minutes = 0
220 self.debug('Using default value (%s) for minutes', self._minutes)
221
222 if (self._max_age_in_days != 0) or (self._max_age_cmd_in_days != 0):
223 # Get time_zone from main B3 config
224 tzName = self.console.config.get('b3', 'time_zone').upper()
225 tzOffest = b3.timezones.timezones[tzName]
226 hoursGMT = (self._hours - tzOffest)%24
227 self.debug("%02d:%02d %s => %02d:%02d UTC" % (self._hours, self._minutes, tzName, hoursGMT, self._minutes))
228 self.info('everyday at %2d:%2d %s, chat messages older than %s days will be deleted'%(self._hours, self._minutes, tzName, self._max_age_in_days))
229 self.info('everyday at %2d:%2d %s, chat commands older than %s days will be deleted'%(self._hours, self._minutes, tzName, self._max_age_cmd_in_days))
230 self._cronTab = b3.cron.PluginCronTab(self, self.purge, 0, self._minutes, hoursGMT, '*', '*', '*')
231 self.console.cron + self._cronTab
232 else:
233 self.info("chat log messages are kept forever")
234
235
236 def startup(self):
237 """\
238 Initialize plugin settings
239 """
240
241 # listen for client events
242 self.registerEvent(self.console.getEventID('EVT_CLIENT_SAY'))
243 self.registerEvent(self.console.getEventID('EVT_CLIENT_TEAM_SAY'))
244 self.registerEvent(self.console.getEventID('EVT_CLIENT_PRIVATE_SAY'))
245 self.registerEvent(self.console.getEventID('EVT_ADMIN_COMMAND'))
246
247 self.EVT_CLIENT_RADIO = self.console.getEventID('EVT_CLIENT_RADIO')
248 if self.EVT_CLIENT_RADIO:
249 self.registerEvent(self.EVT_CLIENT_RADIO)
250
251 self.EVT_CLIENT_CALLVOTE = self.console.getEventID('EVT_CLIENT_CALLVOTE')
252 if self.EVT_CLIENT_CALLVOTE:
253 self.registerEvent(self.EVT_CLIENT_CALLVOTE)
254
255 self.EVT_CLIENT_VOTE = self.console.getEventID('EVT_CLIENT_VOTE')
256 if self.EVT_CLIENT_VOTE:
257 self.registerEvent(self.EVT_CLIENT_VOTE)
258
259
260
261 def onEvent(self, event):
262 """\
263 Handle intercepted events
264 """
265 if not event.client or event.client.cid is None or len(event.data) <= 0:
266 return
267
268 if event.type == b3.events.EVT_CLIENT_SAY:
269 chat = ChatData(self, event)
270 chat._table = self._db_table
271 chat.save()
272 if event.type == b3.events.EVT_CLIENT_TEAM_SAY:
273 chat = TeamChatData(self, event)
274 chat._table = self._db_table
275 chat.save()
276 if event.type == b3.events.EVT_CLIENT_PRIVATE_SAY:
277 chat = PrivateChatData(self, event)
278 chat._table = self._db_table
279 chat.save()
280 if event.type == b3.events.EVT_ADMIN_COMMAND:
281 cmd = CmdData(self, event)
282 cmd._table = self._db_table_cmdlog
283 cmd.save()
284 if self.EVT_CLIENT_RADIO and event.type == self.EVT_CLIENT_RADIO:
285 data = ClientRadioData(self, event)
286 data._table = self._db_table
287 data.save()
288 if self.EVT_CLIENT_CALLVOTE and event.type == self.EVT_CLIENT_CALLVOTE:
289 data = ClientCallVoteData(self, event)
290 data._table = self._db_table
291 data.save()
292 if self.EVT_CLIENT_VOTE and event.type == self.EVT_CLIENT_VOTE:
293 data = ClientVoteData(self, event)
294 data._table = self._db_table
295 data.save()
296
297 def purge(self):
298 if self._max_age_in_days and (self._max_age_in_days != 0):
299 self.info('purge of chat messages older than %s days ...'%self._max_age_in_days)
300 q = "DELETE FROM %s WHERE msg_time < %i"%(self._db_table, self.console.time() - (self._max_age_in_days*24*60*60))
301 self.debug(q)
302 cursor = self.console.storage.query(q)
303 #self.debug('cursor : %s'%cursor)
304 else:
305 self.warning('max_age is invalid [%s]'%self._max_age_in_days)
306
307 if self._max_age_cmd_in_days and (self._max_age_cmd_in_days != 0):
308 self.info('purge of commands older than %s days ...'%self._max_age_cmd_in_days)
309 q = "DELETE FROM %s WHERE cmd_time < %i"%(self._db_table_cmdlog, self.console.time() - (self._max_age_cmd_in_days*24*60*60))
310 self.debug(q)
311 cursor = self.console.storage.query(q)
312 else:
313 self.warning('max_age_cmd is invalid [%s]'%self._max_age_cmd_in_days)
314
315
316 def string2days(self, text):
317 """ convert max age string to days. (max age can be written as : 2d for 'two days', etc) """
318 try:
319 if text[-1:].lower() == 'd':
320 days = int(text[:-1])
321 elif text[-1:].lower() == 'w':
322 days = int(text[:-1]) * 7
323 elif text[-1:].lower() == 'm':
324 days = int(text[:-1]) * 30
325 elif text[-1:].lower() == 'y':
326 days = int(text[:-1]) * 365
327 else:
328 days = int(text)
329 except ValueError, e:
330 self.error("Could not convert '%s' to a valid number of days. (%s)" % (text, e))
331 days = 0
332 return days
333
334
335
336class AbstractData(object):
337
338 def __init__(self, plugin):
339 #default name of the table for this data object
340 self._table = None
341 self.plugin = plugin
342
343 def _insertquery(self):
344 raise NotImplementedError
345
346 def save(self):
347 """should call self._save2db with correct parameters"""
348 raise NotImplementedError
349
350 def _save2db(self, data):
351 q = self._insertquery()
352 try:
353 cursor = self.plugin.console.storage.query(q, data)
354 if cursor.rowcount > 0:
355 self.plugin.debug("rowcount: %s, id:%s" % (cursor.rowcount, cursor.lastrowid))
356 else:
357 self.plugin.warning("inserting into %s failed" % self._table)
358 except Exception, e:
359 if e[0] == 1146:
360 self.plugin.error("Could not save to database : %s" % e[1])
361 self.plugin.info("Refer to this plugin readme file for instruction on how to create the required tables")
362 else:
363 raise e
364
365
366class CmdData(AbstractData):
367
368 def __init__(self, plugin, event):
369 AbstractData.__init__(self, plugin)
370 #default name of the table for this data object
371 self._table = 'cmdlog'
372
373 self.admin_id = event.client.id
374 self.admin_name = event.client.name
375
376 self.command = event.data[0]
377 self.data = event.data[1]
378 self.result = event.data[2]
379 self.event = event
380
381 def _insertquery(self):
382 return """INSERT INTO {table_name}
383 (cmd_time, admin_id, admin_name, command, data, result)
384 VALUES (%(time)s, %(admin_id)s, %(admin_name)s, %(command)s, %(data)s, %(result)s) """.format(table_name=self._table)
385
386 def save(self):
387 self.plugin.debug("%s, %s, %s, %s, %s" % (self.admin_id, self.admin_name, self.command, self.data, self.result))
388 data = {'time':self.plugin.console.time(),
389 'admin_id': self.admin_id,
390 'admin_name': self.admin_name,
391 'command': self.command.command,
392 'data': self.data,
393 'result': self.result
394 }
395 if self.plugin._save2db:
396 self._save2db(data)
397
398
399class ChatData(AbstractData):
400
401 #fields of the table
402 msg_type = 'ALL' # ALL, TEAM or PM
403 client_id = None
404 client_name = None
405 client_team = None
406 msg = None
407
408 def __init__(self, plugin, event):
409 AbstractData.__init__(self, plugin)
410 #default name of the table for this data object
411 self._table = 'chatlog'
412
413 self.client_id = event.client.id
414 self.client_name = event.client.name
415 self.client_team = event.client.team
416 self.msg = event.data
417 self.target_id = None
418 self.target_name = self.plugin.config.get('purge', 'max_age')
419 self.target_team = None
420
421 def _insertquery(self):
422 return """INSERT INTO {table_name}
423 (msg_time, msg_type, client_id, client_name, client_team, msg, target_id, target_name, target_team)
424 VALUES (%(time)s, %(type)s, %(client_id)s, %(client_name)s, %(client_team)s, %(msg)s, %(target_id)s,
425 %(target_name)s, %(target_team)s )""".format(table_name=self._table)
426
427 def save(self):
428 self.plugin.debug("%s, %s, %s, %s"% (self.msg_type, self.client_id, self.client_name, self.msg))
429 data = {'time':self.plugin.console.time(),
430 'type': self.msg_type,
431 'client_id': self.client_id,
432 'client_name': self.client_name,
433 'client_team': self.client_team,
434 'msg': self.msg,
435 'target_id': self.target_id,
436 'target_name': self.target_name,
437 'target_team': self.target_team,
438 }
439
440 if self.plugin._save2file:
441 self._save2file(data)
442 if self.plugin._save2db:
443 self._save2db(data)
444
445 def _save2file(self, data):
446 self.plugin.debug("writing to file")
447 self.plugin._filelogger.info("@%(client_id)s [%(client_name)s] to %(type)s:\t%(msg)s" % data)
448
449
450class TeamChatData(ChatData):
451 msg_type = 'TEAM'
452
453
454class PrivateChatData(ChatData):
455 msg_type = 'PM'
456
457 def __init__(self, plugin, event):
458 ChatData.__init__(self, plugin, event)
459 self.target_id = event.target.id
460 self.target_name = event.target.name
461 self.target_team = event.target.team
462
463
464class ClientRadioData(TeamChatData):
465 def __init__(self, plugin, event):
466 TeamChatData.__init__(self, plugin, event)
467 self.msg = 'RADIO %s %s (%s) %s' % (event.data['msg_group'], event.data['msg_id'], event.data['location'],event.data['text'])
468
469
470class ClientCallVoteData(ChatData):
471 def __init__(self, plugin, event):
472 ChatData.__init__(self, plugin, event)
473 self.msg = 'CALL_VOTE %s' % event.data
474
475class ClientVoteData(ChatData):
476 def __init__(self, plugin, event):
477 ChatData.__init__(self, plugin, event)
478 self.msg = 'VOTE %s' % event.data
479
480
481if __name__ == '__main__':
482 import MySQLdb
483 from b3.fake import FakeClient, fakeConsole, joe, simon
484 from b3.storage import DatabaseStorage
485
486 db = MySQLdb.connect(host='localhost', user='b3test', passwd='test')
487 db.query("DROP DATABASE IF EXISTS b3_test")
488 db.query("CREATE DATABASE b3_test CHARACTER SET utf8;")
489
490 fakeConsole.storage = DatabaseStorage("mysql://b3test:test@localhost/b3_test", fakeConsole)
491 fakeConsole.storage.executeSql("@b3/sql/b3.sql")
492 fakeConsole.storage.query("""CREATE TABLE `chatlog` (
493 `id` int(11) unsigned NOT NULL auto_increment,
494 `msg_time` int(10) unsigned NOT NULL,
495 `msg_type` enum('ALL','TEAM','PM') NOT NULL,
496 `client_id` int(11) unsigned NOT NULL,
497 `client_name` varchar(32) NOT NULL,
498 `client_team` tinyint(1) NOT NULL,
499 `msg` varchar(528) NOT NULL,
500 `target_id` int(11) unsigned default NULL,
501 `target_name` varchar(32) default NULL,
502 `target_team` tinyint(1) default NULL,
503 PRIMARY KEY (`id`),
504 KEY `client` (`client_id`)
505 ) ENGINE=MyISAM DEFAULT CHARSET=utf8;""")
506 fakeConsole.storage.query("""CREATE TABLE `cmdlog` (
507 `id` int(11) unsigned NOT NULL auto_increment,
508 `cmd_time` int(10) unsigned NOT NULL,
509 `admin_id` int(11) unsigned NOT NULL,
510 `admin_name` varchar(32) NOT NULL,
511 `command` varchar(100) NULL,
512 `data` varchar(528) default NULL,
513 `result` varchar(528) default NULL,
514 PRIMARY KEY (`id`),
515 KEY `client` (`admin_id`)
516 ) ENGINE=MyISAM DEFAULT CHARSET=utf8;""")
517
518 def sendsPM(self, msg, target):
519 print "\n%s PM to %s : \"%s\"" % (self.name, msg, target)
520 self.console.queueEvent(b3.events.Event(b3.events.EVT_CLIENT_PRIVATE_SAY, msg, self, target))
521 FakeClient.sendsPM = sendsPM
522
523 conf1 = b3.config.XmlConfigParser()
524 conf1.loadFromString("""
525 <configuration plugin="chatlogger">
526
527 <settings name="general">
528 <!-- do you want to save chat log to database ? -->
529 <set name="save_to_database">Yes</set>
530
531 <!-- do you want to save chat log to a file ? -->
532 <set name="save_to_file">no</set>
533 </settings>
534
535 <settings name="file">
536 <!-- location of the chat log file -->
537 <set name="logfile">@conf/chat.log</set>
538 <!-- file rotation rate. Can be either :
539 H : every hour
540 D : every day
541 W0 : every monday
542 W1 : every tuesday
543 W6 : every sunday
544 -->
545 <set name="rotation_rate">D</set>
546 </settings>
547
548 <!-- optionally you can choose a different name for the table used
549 to store the log. Default is 'chatlog'. To do so, uncomment the
550 following part: -->
551 <!--<settings name="database">
552 <set name="db_table">chatlog2</set>
553 </settings>-->
554
555 <settings name="purge">
556 <!-- how long (in days) do you want the history to be kept for.
557 0 : keep chat log history for ever (default value)
558 You can use the following syntax as well
559 3d : purge all chat older than 3 days
560 2w : two weeks
561 6m : six month
562 1y : one year
563 -->
564 <set name="max_age">0</set>
565
566 <!-- The purge action takes place once a day at the time define below.
567 Default time is midnight -->
568 <set name="hour">0</set>
569 <!-- hour between 0 and 23 -->
570 <set name="min">0</set>
571 <!-- min between 0 and 59 -->
572 </settings>
573 </configuration>
574 """)
575 p = ChatloggerPlugin(fakeConsole, conf1)
576 p.onLoadConfig()
577 p.onStartup()
578
579 joe.connects(1)
580 simon.connects(3)
581
582 joe.says("sql injec;tion ' test")
583 joe.sendsPM("sql; injection ' test", simon)
584 joe.says("!help sql injection ' test;")
585
586 joe.name = "j'oe"
587 simon.name = "s;m'n"
588 joe.says("sql injection test2")
589 joe.sendsPM("sql injection test2", simon)
590 joe.says("!help sql injection test2")
591
592 joe.name = "Joe"
593 simon.name = "Simon"
594
595 while True:
596 joe.says("hello")
597 simon.says2team("team test")
598 joe.sendsPM("PM test", simon)
599 simon.says("!help test command")
600
601 time.sleep(20)