Source

mekk.fics / sample / clock_statistician.py

Full commit
# -*- coding: utf-8 -*-

"""
clock_statistician_bot
=====================

mekk.fics example code - simple „run and provide some service” bot.

This is simple example: the bot monitors current started games and
provides a few simple statistics about game clock (most
frequently/rarely used clocks, in general or in rating range, for
given variant). 

Some information is displayed in finger, more detailed can be
queried by bot commands.

Running this bot
-----------------

Just start it:

    python clock_statistician.py

or, to get a lot of debugging:

    python clock_statistician.py --debug

Check the name bot got (will be printed on stdout), then login to FICS
and chat with the bot starting from “tell GuestXXXX help”.

Note that by default the bot runs as guest, so you must “set guest 1”
on your own FICS account) to be able to hear it's tells.

Comments about the code
-----------------------

Check notes in load_player_information.py and read its code before
reading the code below.

Note that here we use reconnecting factory, activate keepalive, and in
general configure the code for running service, not one-shot connection.
"""

import getopt, sys, logging, os
from twisted.internet import defer, reactor, task
from twisted.enterprise import adbapi

from mekk.fics import ReconnectingFicsFactory, FicsClient, FicsEventMethodsMixin
from mekk.fics import TellCommandsMixin, TellCommand, tell_errors
#from mekk.fics.support.tell_status import TellLoopPrevention

logger = logging.getLogger("clockstat")

#################################################################################
# Configuration
#################################################################################

from mekk.fics import FICS_HOST, FICS_PORT

FICS_USER='clockstatistician'
FICS_PASSWORD=''

FINGER_TEXT="""mekk.fics example code: clock_statistician.py.
See http://bitbucket.org/Mekk/mekk.fics/ for more information.

To check which commands I handle, "tell %s help"
"""

#################################################################################
# Database config
################################################################################

script_dir = os.path.dirname(os.path.abspath(__file__))

DB_DRIVER = 'sqlite3'
DB_URL = os.path.join(script_dir, 'clockstat.db')
# For sqlite, using 2 or more connections generate
# „database is locked” errors. So we stick with one.
DB_MIN_CONNECTIONS = 1
DB_MAX_CONNECTIONS = 1

# For PostgreSQL
# DB_DRIVER = 'psycopg2'
# DB_URL = 'dbname=seekdb user=ficsbot password=secret host=localhost port=5432'
# DB_MIN_CONNECTIONS = 3
# DB_MAX_CONNECTIONS = 5

# Twisted support for databases:
# http://twistedmatrix.com/documents/current/core/howto/rdbms.html
# Not used here but worth checking:
# http://findingscience.com/twistar/

#################################################################################
# ”Business logic” (processing not directly bound to FICS interface)
#################################################################################

class ClockStatistician(object):
    """
    Actual history container and statistics calculator.
    """
    def __init__(self, dbpool):
        self.dbpool = dbpool

    def __del__(self):
        self.dbpool.close()

    db_initialized = False

    @classmethod
    def setup_database(cls, conn):
        """
        This method is mapped below as cp_openfun in dbpool construction (called
        whenever new database connection is made), so we are guaranteed to have
        it run before other SQL is executed.
        """
        if cls.db_initialized:
            return
        conn.execute("""
            CREATE TABLE IF NOT EXISTS games (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                white_rating INTEGER,
                black_rating INTEGER,
                variant VARCHAR(60),
                is_rated CHAR(1) CHECK (is_rated IN ('Y', 'N')),
                clock_base INTEGER,
                clock_inc INTEGER,
                start_time DATETIME
            )""")
        cls.db_initialized = True

    def save_game_info(self, white_rating, black_rating, variant, is_rated,
                       clock, start_time):
        """
        Saves record to database
        """
        return self.dbpool.runQuery("""
            INSERT INTO games(
                white_rating, black_rating, variant, is_rated,
                clock_base, clock_inc, start_time)
            VALUES(
                ?, ?, ?, ?,
                ?, ?, ?
            )""",
            [white_rating, black_rating, variant.name, is_rated and 'Y' or 'N',
             clock.base_min, clock.inc_sec, start_time])

    def stat_best(self):
        # This is not very efficient way once database grows.
        # But this script is just a simple example.
        def stat(tx):
            r = tx.execute("""
                SELECT * FROM
                    (SELECT variant, clock_base, clock_inc, COUNT(*) AS clock_count
                    FROM games
                    GROUP BY variant, clock_base, clock_inc)
                ORDER BY clock_count DESC, variant, clock_base, clock_inc
                LIMIT 5""")
            return r.fetchall()
        return self.dbpool.runInteraction(stat)

    def stat_clock(self, clock_base, clock_inc):
        def stat(tx):
            r = tx.execute("""
                SELECT COUNT(*) AS clock_count
                FROM games
                WHERE variant IN ('standard', 'blitz', 'lightning')
                AND clock_base = ? AND clock_inc = ?
                """, [clock_base, clock_inc])
            return r.fetchall()[0][0]
        return self.dbpool.runInteraction(stat)

#################################################################################
# Commands (handling tells to the bot)
#################################################################################

class BestCommand(TellCommand):
    """
    Bot command: show most popular clocks
    """

    def __init__(self, clock_statistician):
        self.clock_statistician = clock_statistician
    @classmethod
    def named_parameters(cls):
        return {}
    @classmethod
    def positional_parameters_count(cls):       
        return 0, 0
    @defer.inlineCallbacks
    def run(self, fics_client, player, *args, **kwargs):
        info_records = yield self.clock_statistician.stat_best()
        defer.returnValue("Most popular games: %s" % ", ".join(
            "%s %s+%s (%d)" % (variant, clock_base, clock_inc, count)
            for variant, clock_base, clock_inc, count in info_records))
    def help(self, fics_client):
        return "Show most popular clocks"

class ClockCommand(TellCommand):
    """
    Clock command: show stats for given clock
    """

    def __init__(self, clock_statistician):
        self.clock_statistician = clock_statistician
    @classmethod
    def named_parameters(cls):
        return {}
    @classmethod
    def positional_parameters_count(cls):
        # We need two numeric params
        return 2,2
    @defer.inlineCallbacks
    def run(self, fics_client, player, *args, **kwargs):
        try:
            base, inc = int(args[0]), int(args[1])
        except ValueError:
            raise tell_errors.InvalidCommandParameters(self.name())
        stat = yield self.clock_statistician.stat_clock(base, inc)
        # Alternative reply method
        yield fics_client.tell_to(player, "I spotted %s games played with %s %s clock" % (
            stat, base, inc))
    def help(self, fics_client):
        return "Show stats for given clock type, for example \"tell %(who)s %(what)s 5 0\" or \"tell %(who)s %(what)s 45 45\"" % (
            dict(who=fics_client.fics_user_name, what=self.name()))

class HelpCommand(TellCommand):
    @classmethod
    def name_aliases(cls):
        return ["?"]
    @classmethod
    def named_parameters(cls):
        return {}
    @classmethod
    def positional_parameters_count(cls):
        return 0,1
    def run(self, fics_client, player, *args, **kwargs):
        if args:
            return fics_client.command_help(args[0])
        else:
            return "I support the following commands: %s.\nFor more help try: %s" % (
                ", ".join(fics_client.command_names()),
                ", ".join(
                    "\"tell %s help %s\"" % (fics_client.fics_user_name, command)
                    for command in fics_client.command_names()
                    if command != "help"))
    def help(self, fics_client):
        return "I print some help"

#################################################################################
# The bot core
#################################################################################

class MyBot(
    TellCommandsMixin,
    FicsEventMethodsMixin,
    FicsClient
):

    def __init__(self, clock_statistician):
        FicsClient.__init__(self, label="clock-stats-bot")

        self.clock_statistician = clock_statistician

        self.use_keep_alive = True
        self.variables_to_set_after_login = {
            'shout': 0,
            'cshout': 0,
            'tzone': 'EURCST',
            # Enable guest tells
            'guest': 1,
            # Listen to games notifications
            'gin' : 1,
            }
        self.interface_variables_to_set_after_login = [
            'GAMEINFO', # For rich info about game started
            ]

        self.register_command(BestCommand(self.clock_statistician))
        self.register_command(ClockCommand(self.clock_statistician))
        self.register_command(HelpCommand())

        # Not needed as TellCommandsMixin handles it automatically
        #self._tell_loop_prevent = TellLoopPrevention(max_errors_allowed=3)

    def on_login(self, my_username):
        print "I am logged as %s, use \"tell %s help\" to start conversation on FICS" % (
            my_username, my_username)
        # Spawn periodical finger update
        self._finger_refresh_task = task.LoopingCall(self._refresh_finger)
        self._finger_refresh_task.start(300, now=True)
        # Normal post-login processing
        return defer.DeferredList([
                self.set_finger(FINGER_TEXT % my_username),
                # Commands below are unnecessary as variables_to_set_after_login above
                # defines them. Still, this form may be useful if we dynamically enable/disable
                # things.
                #  self.enable_seeks(),
                #  self.enable_guest_tells(),
                #  self.enable_games_tracking(),
                #  self.enable_users_tracking(),
                # self.subscribe_channel(101),
                self.subscribe_channel(49), # TODO: tournament stats
                # self.subscribe_channel(90),
                ])

    def on_logout(self):
        if hasattr(self, '_finger_refresh_task'):
            self._finger_refresh_task.stop()
            del self._finger_refresh_task

    @defer.inlineCallbacks
    def _refresh_finger(self):
        best = yield self.clock_statistician.stat_best()
        for pos, data in enumerate(best[:3]):
            yield self.set_finger_line(8+pos, "%s %s %s - %s games" % data)

    @defer.inlineCallbacks
    def on_game_started(self, info):
        #current_time = datetime.datetime.utcnow()
        game_info = yield self.get_game_info(info.game_no)
        #print info
        #print game_info
        yield self.clock_statistician.save_game_info(
            white_rating=game_info.white_rating_value,
            black_rating=game_info.black_rating_value,
            variant=info.game_spec.game_type,
            is_rated=info.game_spec.is_rated,
            clock=game_info.game_spec.clock,
            start_time=game_info.start_time)

#################################################################################
# Script argument processing
#################################################################################

# TODO: --silent with no logging except errors

options, remainders = getopt.getopt(args = sys.argv[1:], shortopts=[], longopts=["debug"])

if "--debug" in [name for name,_ in options]:
    logging_level = logging.DEBUG
else:
    #logging_level = logging.WARN
    logging_level = logging.INFO

logging.basicConfig(level=logging_level)

#################################################################################
# Startup glue code
#################################################################################

# TODO: convert back to reconnecting

dbpool = adbapi.ConnectionPool(
    DB_DRIVER, DB_URL,
    check_same_thread=False, # avoids some sqlite harmless warnings
    cp_reconnect = True,
    cp_noisy = True,
    cp_openfun = ClockStatistician.setup_database,
    cp_min = DB_MIN_CONNECTIONS,
    cp_max = DB_MAX_CONNECTIONS,
)

clock_statistician = ClockStatistician(dbpool)
my_bot = MyBot(clock_statistician)
reactor.connectTCP(
    FICS_HOST, FICS_PORT,
    ReconnectingFicsFactory(
        client=my_bot,
        auth_username=FICS_USER, auth_password=FICS_PASSWORD)
)
#noinspection PyUnresolvedReferences
reactor.run()