Supybot-plugins/WebStats/plugin.py

526 lines
20 KiB
Python
Raw Normal View History

2010-11-19 11:06:38 -08:00
###
# Copyright (c) 2010, Valentin Lorentz
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
# * Redistributions of source code must retain the above copyright notice,
# this list of conditions, and the following disclaimer.
# * Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions, and the following disclaimer in the
# documentation and/or other materials provided with the distribution.
# * Neither the name of the author of this software nor the name of
# contributors to this software may be used to endorse or promote products
# derived from this software without specific prior written consent.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
###
2010-11-20 08:51:23 -08:00
import os
2010-11-19 11:06:38 -08:00
import sys
import time
import datetime
2010-11-19 11:06:38 -08:00
import threading
import BaseHTTPServer
2010-11-20 08:51:23 -08:00
import supybot.conf as conf
2010-11-19 11:06:38 -08:00
import supybot.world as world
import supybot.log as log
2010-11-19 11:06:38 -08:00
import supybot.utils as utils
from supybot.commands import *
2010-11-22 08:28:55 -08:00
import supybot.irclib as irclib
2010-11-19 11:06:38 -08:00
import supybot.plugins as plugins
2010-11-22 08:28:55 -08:00
import supybot.ircmsgs as ircmsgs
2010-11-19 11:06:38 -08:00
import supybot.ircutils as ircutils
import supybot.callbacks as callbacks
2010-11-20 08:51:23 -08:00
try:
import sqlite3
except ImportError:
from pysqlite2 import dbapi2 as sqlite3 # for python2.4
try:
from supybot.i18n import PluginInternationalization
from supybot.i18n import internationalizeDocstring
_ = PluginInternationalization('WebStats')
except:
_ = lambda x:x
internationalizeDocstring = lambda x:x
2010-11-19 11:06:38 -08:00
2010-11-20 08:51:23 -08:00
DEBUG = True
testing = world.testing
2010-11-19 11:06:38 -08:00
def getTemplate(name):
2010-11-20 08:51:23 -08:00
if sys.modules.has_key('WebStats.templates.skeleton'):
reload(sys.modules['WebStats.templates.skeleton'])
2010-11-19 11:06:38 -08:00
if sys.modules.has_key('WebStats.templates.%s' % name):
reload(sys.modules['WebStats.templates.%s' % name])
module = __import__('WebStats.templates.%s' % name)
2010-11-20 08:51:23 -08:00
return getattr(getattr(module, 'templates'), name)
2010-11-19 11:06:38 -08:00
2010-11-20 08:51:23 -08:00
class FooException(Exception):
pass
2010-11-19 11:06:38 -08:00
class HTTPHandler(BaseHTTPServer.BaseHTTPRequestHandler):
def log_request(self, code=None, size=None):
# By default, it logs the request to stderr
pass
2010-11-20 08:51:23 -08:00
def do_GET(self):
output = ''
try:
if self.path == '/design.css':
response = 200
content_type = 'text/css'
output = getTemplate('design').get(not testing)
elif self.path == '/':
response = 200
content_type = 'text/html'
output = getTemplate('index').get(not testing,
self.server.db.getChannels())
elif self.path == '/about/':
response = 200
content_type = 'text/html'
self.end_headers()
output = getTemplate('about').get(not testing)
elif self.path == '/%s/' % _('channels'):
response = 404
content_type = 'text/html'
output = """<p style="font-size: 20em">BAM!</p>
<p>You played with the URL, you losed.</p>"""
2010-11-20 08:51:23 -08:00
elif self.path.startswith('/%s/' % _('channels')):
response = 200
content_type = 'text/html'
splittedPath = self.path.split('/')
chanName = splittedPath[2]
print splittedPath
if len(splittedPath) == 3:
output = getTemplate('chan_index').get(not testing,
chanName,
self.server.db)
else:
subdir = splittedPath[3]
output = getTemplate('chan_index').get(not testing,
chanName,
self.server.db,
subdir)
2010-11-20 08:51:23 -08:00
else:
response = 404
content_type = 'text/html'
output = getTemplate('error404').get(not testing)
except FooException as e:
2010-11-20 08:51:23 -08:00
response = 500
content_type = 'text/html'
if output == '':
output = '<h1>Internal server error</h1>'
if DEBUG:
output += '<p>The server raised this exception: %s</p>' % \
repr(e)
finally:
self.send_response(response)
self.send_header('Content-type', content_type)
self.end_headers()
self.wfile.write(output)
class WebStatsDB:
def __init__(self):
filename = conf.supybot.directories.data.dirize('WebStats.db')
alreadyExists = os.path.exists(filename)
if alreadyExists and testing:
2010-11-20 08:51:23 -08:00
os.remove(filename)
alreadyExists = False
self._conn = sqlite3.connect(filename, check_same_thread = False)
self._conn.text_factory = str
2010-11-20 08:51:23 -08:00
if not alreadyExists:
self.makeDb()
def makeDb(self):
"""Create the tables in the database"""
2010-11-20 08:51:23 -08:00
cursor = self._conn.cursor()
cursor.execute("""CREATE TABLE messages (
chan VARCHAR(128),
nick VARCHAR(128),
time TIMESTAMP,
content TEXT
)""")
cursor.execute("""CREATE TABLE moves (
chan VARCHAR(128),
nick VARCHAR(128),
time TIMESTAMP,
2010-11-22 10:07:47 -08:00
type VARCHAR(16),
2010-11-20 08:51:23 -08:00
content TEXT
)""")
cacheTableCreator = """CREATE TABLE %s_cache (
chan VARCHAR(128),
%s
year INT,
month TINYINT,
day TINYINT,
dayofweek TINYINT,
hour TINYINT,
2010-11-20 08:51:23 -08:00
lines INTEGER,
words INTEGER,
chars INTEGER,
joins INTEGER,
parts INTEGER,
2010-11-22 10:07:47 -08:00
quits INTEGER,
nicks INTEGER,
kickers INTEGER,
kickeds INTEGER
)"""
cursor.execute(cacheTableCreator % ('chans', ''))
2010-11-22 08:42:34 -08:00
cursor.execute(cacheTableCreator % ('nicks', 'nick VARCHAR(128),'))
2010-11-20 08:51:23 -08:00
self._conn.commit()
cursor.close()
def getChannels(self):
"""Get a list of channels in the database"""
2010-11-20 08:51:23 -08:00
cursor = self._conn.cursor()
cursor.execute("""SELECT DISTINCT(chan) FROM chans_cache""")
2010-11-20 08:51:23 -08:00
results = []
for row in cursor:
results.append(row[0])
cursor.close()
return results
def recordMessage(self, chan, nick, message):
"""Called by doPrivmsg or onNotice.
Stores the message in the database"""
2010-11-20 08:51:23 -08:00
cursor = self._conn.cursor()
cursor.execute("""INSERT INTO messages VALUES (?,?,?,?)""",
(chan, nick, time.time(), message))
self._conn.commit()
cursor.close()
if DEBUG:
self.refreshCache()
def recordMove(self, chan, nick, type_, message=''):
"""Called by doJoin, doPart, or doQuit.
Stores the 'move' in the database"""
2010-11-20 08:51:23 -08:00
cursor = self._conn.cursor()
cursor.execute("""INSERT INTO moves VALUES (?,?,?,?,?)""",
(chan, nick, time.time(), type_, message))
self._conn.commit()
cursor.close()
if DEBUG:
self.refreshCache()
def refreshCache(self):
"""Clears the cache tables, and populate them"""
self._truncateCache()
2010-11-20 08:51:23 -08:00
tmp_chans_cache = {}
tmp_nicks_cache = {}
cursor = self._conn.cursor()
cursor.execute("""SELECT * FROM messages""")
for row in cursor:
chan, nick, timestamp, content = row
chanindex, nickindex = self._getIndexes(chan, nick, timestamp)
self._incrementTmpCache(tmp_chans_cache, chanindex, content)
self._incrementTmpCache(tmp_nicks_cache, nickindex, content)
2010-11-20 08:51:23 -08:00
cursor.close()
cursor = self._conn.cursor()
cursor.execute("""SELECT * FROM moves""")
for row in cursor:
chan, nick, timestamp, type_, content = row
chanindex, nickindex = self._getIndexes(chan, nick, timestamp)
self._addKeyInTmpCacheIfDoesNotExist(tmp_chans_cache, chanindex)
self._addKeyInTmpCacheIfDoesNotExist(tmp_nicks_cache, nickindex)
2010-11-22 10:07:47 -08:00
id = {'join':3,'part':4,'quit':5,'nick':6,'kicker':7,'kicked':8}
id = id[type_]
tmp_chans_cache[chanindex][id] += 1
tmp_nicks_cache[nickindex][id] += 1
2010-11-20 08:51:23 -08:00
cursor.close()
self._writeTmpCacheToCache(tmp_chans_cache, 'chan')
self._writeTmpCacheToCache(tmp_nicks_cache, 'nick')
self._conn.commit()
def _addKeyInTmpCacheIfDoesNotExist(self, tmpCache, key):
"""Takes a temporary cache list and key.
If the key is not in the list, add it in the list with value list
filled with zeros."""
if not tmpCache.has_key(key):
2010-11-22 10:07:47 -08:00
tmpCache.update({key: [0, 0, 0, 0, 0, 0, 0, 0, 0]})
def _truncateCache(self):
"""Clears the cache tables"""
2010-11-20 08:51:23 -08:00
cursor = self._conn.cursor()
cursor.execute("""DELETE FROM chans_cache""")
cursor.execute("""DELETE FROM nicks_cache""")
2010-11-20 08:51:23 -08:00
cursor.close()
def _incrementTmpCache(self, tmpCache, index, content):
"""Takes a temporary cache list, the index it'll increment, and the
message content.
Updates the temporary cache to count the content."""
self._addKeyInTmpCacheIfDoesNotExist(tmpCache, index)
tmpCache[index][0] += 1
tmpCache[index][1] += len(content.split(' '))
tmpCache[index][2] += len(content)
def _getIndexes(self, chan, nick, timestamp):
"""Takes a chan name, a nick, and a timestamp, and returns two index,
to crawl the temporary chans and nicks caches."""
dt = datetime.datetime.today()
2010-11-21 01:36:12 -08:00
dt = dt.fromtimestamp(timestamp)
chanindex=(chan, dt.year, dt.month, dt.day, dt.weekday(), dt.hour)
nickindex=(chan, nick, dt.year, dt.month, dt.day, dt.weekday(), dt.hour)
return chanindex, nickindex
def _writeTmpCacheToCache(self, tmpCache, type_):
"""Takes a temporary cache list, its type, and write it in the cache
database."""
cursor = self._conn.cursor()
for index in tmpCache:
data = tmpCache[index]
values = index + tuple(data)
cursor.execute("""INSERT INTO %ss_cache
VALUES(%s)""" % (type_, ('?,'*len(values))[0:-1]), values)
cursor.close()
2010-11-20 08:51:23 -08:00
def getChanGlobalData(self, chanName):
"""Returns a tuple, containing the channel stats, on all the recording
period."""
2010-11-20 08:51:23 -08:00
cursor = self._conn.cursor()
cursor.execute("""SELECT SUM(lines), SUM(words), SUM(chars),
2010-11-22 10:07:47 -08:00
SUM(joins), SUM(parts), SUM(quits),
SUM(nicks), SUM(kickers), SUM(kickeds)
FROM chans_cache WHERE chan=?""", (chanName,))
2010-11-20 08:51:23 -08:00
row = cursor.fetchone()
2010-11-22 10:07:47 -08:00
if None in row:
oldrow = row
row = None
for item in oldrow:
if row is None:
row = (0,)
else:
row += (0,)
return row
2010-11-20 08:51:23 -08:00
2010-11-21 01:36:12 -08:00
def getChanRecordingTimeBoundaries(self, chanName):
"""Returns two tuples, containing the min and max values of each
year/month/day/dayofweek/hour field.
Note that this data comes from the cache, so they might be a bit
outdated if DEBUG is False."""
cursor = self._conn.cursor()
cursor.execute("""SELECT MIN(year), MIN(month), MIN(day),
MIN(dayofweek), MIN(hour)
FROM chans_cache WHERE chan=?""", (chanName,))
min_ = cursor.fetchone()
cursor = self._conn.cursor()
cursor.execute("""SELECT MAX(year), MAX(month), MAX(day),
MAX(dayofweek), MAX(hour)
FROM chans_cache WHERE chan=?""", (chanName,))
max_ = cursor.fetchone()
if None in min_:
min_ = tuple([int('0') for x in max_])
if None in max_:
max_ = tuple([int('0') for x in max_])
2010-11-21 01:36:12 -08:00
return min_, max_
def getChanXXlyData(self, chanName, type_):
"""Same as getChanGlobalData, but for the given
year/month/day/dayofweek/hour.
For example, getChanXXlyData('#test', 'hour') returns a list of 24
getChanGlobalData-like tuples."""
2010-11-22 10:07:47 -08:00
sampleQuery = """SELECT lines, words, chars, joins, parts, quits, nicks, kickers, kickeds
FROM chans_cache WHERE chan=? and %s=?"""
min_, max_ = self.getChanRecordingTimeBoundaries(chanName)
typeToIndex = {"year":0, "month":1, "day":2, "dayofweek":3, "hour":4}
if type_ not in typeToIndex:
raise ValueError("Invalid type")
min_ = min_[typeToIndex[type_]]
max_ = max_[typeToIndex[type_]]
results = {}
for index in range(min_, max_+1):
query = sampleQuery % (type_)
cursor = self._conn.cursor()
cursor.execute(query, (chanName, index))
try:
row = cursor.fetchone()
2010-11-22 07:33:02 -08:00
if row is None:
raise Exception()
results.update({index: row})
except:
self._addKeyInTmpCacheIfDoesNotExist(results, index)
cursor.close()
return results
def getChanNickGlobalData(self, chanName, nick):
"""Same as getChanGlobalData, but only for one nick."""
cursor = self._conn.cursor()
cursor.execute("""SELECT lines, words, chars, joins, parts, quits,
nicks, kickers, kickeds
FROM nicks_cache WHERE chan=? and nick=?""",
(chanName, nick))
row = cursor.fetchone()
if None in row:
oldrow = row
row = None
for item in oldrow:
if row is None:
row = (0,)
else:
row += (0,)
return row
class WebStatsHTTPServer(BaseHTTPServer.HTTPServer):
"""A simple class that set a smaller timeout to the socket"""
timeout = 0.3
2010-11-19 11:06:38 -08:00
class Server:
"""The WebStats HTTP server handler."""
2010-11-19 11:06:38 -08:00
def __init__(self, plugin):
self.serve = True
self._plugin = plugin
def run(self):
serverAddress = (self._plugin.registryValue('server.host'),
self._plugin.registryValue('server.port'))
2010-11-20 08:51:23 -08:00
done = False
while not done:
time.sleep(1)
try:
httpd = WebStatsHTTPServer(serverAddress, HTTPHandler)
2010-11-20 08:51:23 -08:00
done = True
except:
pass
log.info('WebStats web server launched')
2010-11-20 08:51:23 -08:00
httpd.db = self._plugin.db
2010-11-19 11:06:38 -08:00
while self.serve:
httpd.handle_request()
httpd.server_close()
time.sleep(1) # Let the socket be really closed
class WebStats(callbacks.Plugin):
def __init__(self, irc):
self.__parent = super(WebStats, self)
callbacks.Plugin.__init__(self, irc)
2010-11-22 08:28:55 -08:00
self.lastmsg = {}
self.ircstates = {}
2010-11-20 08:51:23 -08:00
self.db = WebStatsDB()
2010-11-19 11:06:38 -08:00
self._server = Server(self)
if not world.testing:
threading.Thread(target=self._server.run,
name="WebStats HTTP Server").start()
def die(self):
self._server.serve = False
self.__parent.die()
2010-11-20 08:51:23 -08:00
def doPrivmsg(self, irc, msg):
channel = msg.args[0]
if channel == 'AUTH':
return
2010-11-20 08:51:23 -08:00
if not self.registryValue('channel.enable', channel):
return
content = msg.args[1]
nick = msg.prefix.split('!')[0]
self.db.recordMessage(channel, nick, content)
doNotice = doPrivmsg
2010-11-19 11:06:38 -08:00
def doJoin(self, irc, msg):
channel = msg.args[0]
if not self.registryValue('channel.enable', channel):
return
nick = msg.prefix.split('!')[0]
self.db.recordMove(channel, nick, 'join')
def doPart(self, irc, msg):
channel = msg.args[0]
if not self.registryValue('channel.enable', channel):
return
if len(msg.args) > 1:
message = msg.args[1]
else:
message = ''
nick = msg.prefix.split('!')[0]
self.db.recordMove(channel, nick, 'part', message)
def doQuit(self, irc, msg):
nick = msg.prefix.split('!')[0]
if len(msg.args) > 1:
message = msg.args[1]
else:
message = ''
2010-11-22 08:28:55 -08:00
for channel in self.ircstates[irc].channels:
2010-11-22 09:23:09 -08:00
if self.registryValue('channel.enable', channel) and \
msg.nick in self.ircstates[irc].channels[channel].users:
2010-11-22 08:28:55 -08:00
self.db.recordMove(channel, nick, 'quit', message)
2010-11-22 10:07:47 -08:00
def doNick(self, irc, msg):
nick = msg.prefix.split('!')[0]
if len(msg.args) > 1:
message = msg.args[1]
else:
message = ''
for channel in self.ircstates[irc].channels:
if self.registryValue('channel.enable', channel) and \
msg.nick in self.ircstates[irc].channels[channel].users:
self.db.recordMove(channel, nick, 'nick', message)
def doKick(self, irc, msg):
nick = msg.prefix.split('!')[0]
if len(msg.args) > 1:
message = msg.args[1]
else:
message = ''
for channel in self.ircstates[irc].channels:
if self.registryValue('channel.enable', channel) and \
msg.nick in self.ircstates[irc].channels[channel].users:
self.db.recordMove(channel, nick, 'kicker', message)
self.db.recordMove(channel, msg.args[2], 'kicked', message)
2010-11-22 08:28:55 -08:00
# The two fellowing functions comes from the Relay plugin, provided
# with Supybot
def __call__(self, irc, msg):
try:
irc = self._getRealIrc(irc)
if irc not in self.ircstates:
self._addIrc(irc)
self.ircstates[irc].addMsg(irc, self.lastmsg[irc])
finally:
self.lastmsg[irc] = msg
self.__parent.__call__(irc, msg)
def _addIrc(self, irc):
# Let's just be extra-special-careful here.
if irc not in self.ircstates:
self.ircstates[irc] = irclib.IrcState()
if irc not in self.lastmsg:
self.lastmsg[irc] = ircmsgs.ping('this is just a fake message')
if irc.afterConnect:
# We've probably been reloaded. Let's send some messages to get
# our IrcState objects up to current.
for channel in irc.state.channels:
irc.queueMsg(ircmsgs.who(channel))
irc.queueMsg(ircmsgs.names(channel))
def _getRealIrc(self, irc):
if isinstance(irc, irclib.Irc):
return irc
else:
return irc.getRealIrc()
2010-11-19 11:06:38 -08:00
Class = WebStats
# vim:set shiftwidth=4 softtabstop=4 expandtab textwidth=79: