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