#! /usr/bin/env python
# Hey, Emacs! This is -*-python-*-.
#
# Copyright (C) 2003  Joel Rosdahl
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307
# USA
#
# Joel Rosdahl <joel@rosdahl.net>

version = "0.2.0"

import getopt
import os
import select
import sets
import socket
import string
import sys
import re
import time

class Channel(object):
    def __init__(self, server, name):
        self.__server = server
        self.__name = name
        self.__members = sets.Set()
        self.__operators = sets.Set()
        self.__voiced = sets.Set()
        self.__topic = ""

    def getName(self):
        return self.__name
    name = property(getName)

    def getTopic(self):
        return self.__topic
    def setTopic(self, topic):
        self.__topic = topic
    topic = property(getTopic, setTopic)

    def getMembers(self):
        return self.__members
    members = property(getMembers)

    def addMember(self, client):
        self.__members.add(client)

    def removeClient(self, client):
        for x in [self.__members, self.__operators, self.__voiced]:
            x.discard(client)
        if len(self.__members) == 0:
            self.__server.removeChannel(self)

class Client(object):
    __linesep_regexp = re.compile("\r?\n")
    # The RFC limit for nicknames is 9 characters, but what the heck.
    __valid_nickname_regexp = re.compile(
        "^[][\`_^{|}A-Za-z][][\`_^{|}A-Za-z0-9]{0,50}$")
    __valid_channelname_regexp = re.compile(
        "^[&#+!][^\x00\x07\x0a\x0d ,:]{0,50}$")

    def __init__(self, server, socket):
        self.__server = server
        self.__socket = socket
        self.__readbuffer = ""
        self.__writebuffer = ""
        self.__timestamp = time.time()
        self.__sentPing = False
        self.__nickname = None
        self.__user = None
        self.__host, self.__port = socket.getpeername()
        self.__realname = None
        self.__channels = {} # irc_lower(Channel name) --> Channel
        if self.__server.password:
            self.__handleCommand = self.__passHandler
        else:
            self.__handleCommand = self.__registrationHandler

    def getSocket(self):
        return self.__socket
    socket = property(getSocket)

    def getTimestamp(self):
        return self.__timestamp
    timestamp = property(getTimestamp)

    def getChannels(self):
        return self.__channels
    channels = property(getChannels)

    def getNickname(self):
        return self.__nickname
    def setNickname(self, nickname):
        self.__nickname = nickname
    nickname = property(getNickname, setNickname)
    name = nickname

    def getUser(self):
        return self.__user
    user = property(getUser)

    def getHost(self):
        return self.__host
    host = property(getHost)

    def getPrefix(self):
        return "%s!%s@%s" % (self.nickname, self.user, self.host)
    prefix = property(getPrefix)

    def getRealname(self):
        return self.__realname
    realname = property(getRealname)

    def checkAliveness(self):
        now = time.time()
        if self.timestamp + 180 < now:
            self.disconnect("ping timeout")
            return
        if not self.__sentPing and self.timestamp + 90 < now:
            if self.__handleCommand == self.__commandHandler:
                # Registered.
                self.message("PING :%s" % self.__server.name)
                self.__sentPing = True
            else:
                # Not registered.
                self.disconnect("ping timeout")

    def writeQueueSize(self):
        return len(self.__writebuffer)

    def __parseReadBuffer(self):
        lines = self.__linesep_regexp.split(self.__readbuffer)
        self.__readbuffer = lines[-1]
        lines = lines[:-1]
        for line in lines:
            if not line:
                # Empty line. Ignore.
                continue
            x = line.split(" ", 1)
            command = x[0].upper()
            if len(x) == 1:
                arguments = []
            else:
                if len(x[1]) > 0 and x[1][0] == ":":
                    arguments = [x[1][1:]]
                else:
                    y = string.split(x[1], " :", 1)
                    arguments = string.split(y[0])
                    if len(y) == 2:
                        arguments.append(y[1])
            self.__handleCommand(command, arguments)

    def __passHandler(self, command, arguments):
        server = self.__server
        if command == "PASS":
            if len(arguments) == 0:
                self.message(
                    ":%s 461 * PASS :Not enough parameters" % server.name)
            else:
                if arguments[0].lower() == server.password:
                    self.__handleCommand = self.__registrationHandler
                else:
                    self.message(
                        ":%s 464 :Password incorrect" % server.name)
        elif command == "QUIT":
            self.disconnect("Client quit")
            return
        else:
            pass

    def __registrationHandler(self, command, arguments):
        server = self.__server
        if command == "NICK":
            if len(arguments) < 1:
                self.message(
                    ":%s 431 :No nickname given" % server.name)
                return

            nick = arguments[0]
            if server.getClient(nick):
                self.message(
                    ":%s 433 * %s :Nickname is already in use" % (
                        server.name,
                        nick))
            elif not self.__valid_nickname_regexp.match(nick):
                self.message(
                    ":%s 432 * %s :Erroneous Nickname" % (
                        server.name,
                        nick))
            else:
                self.__nickname = nick
                server.clientChangedNickname(self, None)
        elif command == "USER":
            if len(arguments) < 4:
                self.message(
                    ":%s 461 * USER :Not enough parameters" % server.name)
                return

            self.__user = arguments[0]
            self.__realname = arguments[3]
        elif command == "QUIT":
            self.disconnect("Client quit")
            return
        else:
            pass
        if self.nickname and self.user:
            self.message(":%s 001 %s :Yo, welcome to IRC" % (
                server.name,
                self.nickname))
            self.message(
                ":%s 002 %s :Your host is %s, running version miniircd-%s" % (
                    server.name,
                    self.nickname,
                    server.name,
                    version))
            self.message(
                ":%s 003 %s :This server was created sometime" % (
                    server.name,
                    self.nickname))
            self.message(
                ":%s 004 %s :%s miniircd-%s o o" % (
                    server.name,
                    self.nickname,
                    server.name,
                    version))
            self.sendMotd()
            self.__handleCommand = self.__commandHandler

    def __commandHandler(self, command, arguments):
        def joinHandler():
            if len(arguments) < 1:
                self.message(
                    ":%s 461 %s JOIN :Not enough parameters" % (
                        server.name,
                        self.nickname))
                return

            if arguments[0] == "0":
                for channelname, channel in self.__channels.items():
                    self.messageChannel(
                        channel,
                        ":%s PART %s" % (self.prefix, channelname),
                        True)
                    server.removeMemberFromChannel(self, channelname)
                self.__channels = {}
            else:
                for channelname in arguments[0].split(","):
                    if irc_lower(arguments[0]) in self.__channels:
                        pass
                    elif not valid_channel_re.match(channelname):
                        self.message(
                            ":%s 403 %s %s :No such channel" % (
                                server.name,
                                self.nickname,
                                channelname))
                    else:
                        server.addMemberToChannel(self, channelname)
                        channel = server.getChannel(channelname)
                        self.__channels[irc_lower(channelname)] = channel
                        self.messageChannel(
                            channel,
                            ":%s JOIN %s" % (self.prefix, channelname),
                            True)
                        if channel.topic:
                            self.message(
                                ":%s 332 %s %s :%s" % (
                                    server.name,
                                    self.nickname,
                                    channel.name,
                                    channel.topic))
                        else:
                            self.message(
                                ":%s 331 %s %s :No topic is set" % (
                                    server.name,
                                    self.nickname,
                                    channel.name))
                        self.message(
                            ":%s 353 %s = %s :%s" % (
                                server.name,
                                self.nickname,
                                channelname,
                                " ".join([x.nickname
                                          for x in channel.members])))
                        self.message(
                            ":%s 366 %s %s :End of NAMES list" % (
                                server.name,
                                self.nickname,
                                channelname))

        def listHandler():
            if len(arguments) < 1:
                channels = server.channels
            else:
                channels = []
                for channelname in arguments[0].split(","):
                    channel = server.getChannel(channelname)
                    if channel:
                        channels.append(channel)
            for channel in channels:
                self.message(
                    ":%s 322 %s %s %d :%s" % (
                        server.name,
                        self.nickname,
                        channel.name,
                        len(channel.members),
                        channel.topic))
            self.message(
                ":%s 323 %s :End of LIST" % (server.name, self.nickname))

        def modeHandler():
            if len(arguments) < 1:
                self.message(
                    ":%s 461 %s MODE :Not enough parameters" % (
                        server.name,
                        self.nickname))
                return

            targetname = arguments[0]
            channel = server.getChannel(targetname)
            if channel:
                if len(arguments) > 1:
                    modes = arguments[1]
                    self.message(":%s 472 %s %s :Unknown MODE flag" % (
                        server.name,
                        self.nickname,
                        modes))
                else:
                    self.message(":%s 324 %s %s +" % (
                        server.name,
                        self.nickname,
                        targetname))
            else:
                if targetname == self.nickname:
                    if len(arguments) == 1:
                        self.message(
                            ":%s 221 %s +" % (server.name, self.nickname))
                    else:
                        self.message(
                            ":%s 501 %s :Unknown MODE flag" % (
                                 server.name,
                                 self.nickname))
                else:
                    self.message(
                        ":%s 403 %s %s :That channel doesn't exist" % (
                            server.name,
                            self.nickname,
                            targetname))

        def motdHandler():
            self.sendMotd()

        def nickHandler():
            if len(arguments) < 1:
                self.message(
                    ":%s 431 :No nickname given" % server.name)
                return

            newnick = arguments[0]
            client = server.getClient(newnick)
            if newnick == self.nickname:
                pass
            elif client and client is not self:
                self.message(
                    ":%s 433 %s %s :Nickname is already in use" % (
                        server.name,
                        self.nickname,
                        newnick))
            elif not self.__valid_nickname_regexp.match(newnick):
                self.message(
                    ":%s 432 %s %s :Erroneous Nickname" % (
                        server.name,
                        self.nickname,
                        newnick))
            else:
                oldnickname = self.nickname
                self.nickname = newnick
                server.clientChangedNickname(self, oldnickname)
                self.messageRelated(
                    ":%s!%s@%s NICK %s" % (
                        oldnickname,
                        self.user,
                        self.host,
                        self.nickname),

                    True)

        def noticeAndPrivmsgHandler():
            if len(arguments) == 0:
                self.message(":%s 411 %s :No recipient given" % (
                    server.name,
                    self.nickname))
                return
            elif len(arguments) == 1:
                self.message(":%s 412 %s :No text to send" % (
                    server.name,
                    self.nickname))
                return

            targetname = arguments[0]
            message = arguments[1]
            client = server.getClient(targetname)
            if client:
                client.message(":%s %s %s :%s" % (
                    self.prefix,
                    command,
                    targetname,
                    message))
            else:
                channel = server.getChannel(targetname)
                if channel:
                    self.messageChannel(
                        channel,
                        ":%s %s %s :%s" % (
                            self.prefix,
                            command,
                            channel.name,
                            message))
                else:
                    self.message(
                        ":%s 401 %s %s :No such nick/channel" % (
                            server.name,
                            self.nickname,
                            targetname))

        def partHandler():
            if len(arguments) < 1:
                self.message(
                    ":%s 461 %s PART :Not enough parameters" % (
                        server.name,
                        self.nickname))
                return

            if len(arguments) > 1:
                partmsg = arguments[1]
            else:
                partmsg = self.nickname
            for channelname in arguments[0].split(","):
                if not valid_channel_re.match(channelname):
                    self.message(
                        ":%s 403 %s %s :No such channel" % (
                            server.name,
                            self.nickname,
                            channelname))
                elif not irc_lower(channelname) in self.__channels:
                    self.message(
                        ":%s 442 %s %s :You're not on that channel" % (
                            server.name,
                            self.nickname,
                            channelname))
                else:
                    channel = self.__channels[irc_lower(channelname)]
                    self.messageChannel(
                        channel,
                        ":%s PART %s :%s" % (
                            self.prefix,
                            channelname,
                            partmsg),
                        True)
                    del self.__channels[irc_lower(channelname)]
                    server.removeMemberFromChannel(self, channelname)

        def pingHandler():
            if len(arguments) < 1:
                self.message(
                    ":%s 409 %s :No origin specified" % (
                        server.name,
                        self.nickname))
                return

            self.message(
                ":%s PONG %s :%s" % (
                    server.name,
                    server.name,
                    arguments[0]))

        def pongHandler():
            pass

        def quitHandler():
            if len(arguments) < 1:
                quitmsg = self.nickname
            else:
                quitmsg = arguments[0]
            self.disconnect(quitmsg)

        def topicHandler():
            if len(arguments) < 1:
                self.message(
                    ":%s 461 %s TOPIC :Not enough parameters" % (
                        server.name,
                        self.nickname))
                return

            channelname = arguments[0]
            if channelname in self.__channels:
                channel = server.getChannel(channelname)
                if len(arguments) > 1:
                    newtopic = arguments[1]
                    channel.topic = newtopic
                    self.messageChannel(
                        channel,
                        ":%s TOPIC %s :%s" % (
                            self.prefix,
                            channelname,
                            newtopic),
                        True)
                else:
                    if channel.topic:
                        self.message(
                            ":%s 332 %s %s :%s" % (
                                server.name,
                                self.nickname,
                                channel.name,
                                channel.topic))
                    else:
                        self.message(
                            ":%s 331 %s %s :No topic is set" % (
                                server.name,
                                self.nickname,
                                channel.name))
            else:
                self.message(
                    ":%s 442 %s :You're not on that channel" % (
                        server.name,
                        channelname))

        def whoHandler():
            if len(arguments) < 1:
                return

            targetname = arguments[0]
            channel = server.getChannel(targetname)
            if channel:
                for member in channel.members:
                    self.message(
                        ":%s 352 %s %s %s %s %s %s H :0 %s" % (
                            server.name,
                            self.nickname,
                            targetname,
                            member.user,
                            member.host,
                            server.name,
                            member.nickname,
                            member.realname))
                self.message(
                    ":%s 315 %s %s :End of WHO list" % (
                        server.name,
                        self.nickname,
                        targetname))

        def whoisHandler():
            if len(arguments) < 1:
                return

            username = arguments[0]
            user = server.getClient(username)
            if user:
                self.message(
                     ":%s 311 %s %s %s %s * :%s" % (
                        server.name,
                        self.nickname,
                        user.nickname,
                        user.user,
                        user.host,
                        user.realname))
                self.message(
                    ":%s 312 %s %s %s :%s" % (
                        server.name,
                        self.nickname,
                        user.nickname,
                        server.name,
                        server.name))
                self.message(
                    ":%s 319 %s %s :%s" % (
                    server.name,
                    self.nickname,
                    user.nickname,
                    " ".join(user.channels)))
                self.message(
                    ":%s 318 %s %s :End of WHOIS list" % (
                        server.name,
                        self.nickname,
                        user.nickname))
            else:
                self.message(
                    ":%s 401 %s %s :No such nick" % (
                        server.name,
                        self.nickname,
                        username))

        handlerTable = {
            "JOIN": joinHandler,
            "LIST": listHandler,
            "MODE": modeHandler,
            "MOTD": motdHandler,
            "NICK": nickHandler,
            "NOTICE": noticeAndPrivmsgHandler,
            "PART": partHandler,
            "PING": pingHandler,
            "PONG": pongHandler,
            "PRIVMSG": noticeAndPrivmsgHandler,
            "QUIT": quitHandler,
            "TOPIC": topicHandler,
            "WHO": whoHandler,
            "WHOIS": whoisHandler,
        }
        server = self.__server
        valid_channel_re = self.__valid_channelname_regexp
        try:
            handlerTable[command]()
        except KeyError:
            self.message(":%s 421 %s %s :Unknown command" % (
                server.name,
                self.nickname,
                command))

    def socketReadableNotification(self):
        try:
            data = self.socket.recv(2**10)
            quitmsg = "EOT"
        except socket.error, x:
            data = ""
            quitmsg = x
        if len(data) == 0:
            self.disconnect(quitmsg)
        else:
            self.__readbuffer += data
            self.__parseReadBuffer()
            self.__timestamp = time.time()
            self.__sentPing = False

    def socketWritableNotification(self):
        try:
            sent = self.socket.send(self.__writebuffer)
            self.__writebuffer = self.__writebuffer[sent:]
        except socket.error, x:
            self.disconnect(x)

    def disconnect(self, quitmsg):
        self.message("ERROR :%s" % quitmsg)
        self.__server.printInfo(
            "Disconnected connection from %s:%s (%s)." % (
                self.__host, self.__port, quitmsg))
        self.socket.close()
        self.__server.removeClient(self, quitmsg)

    def message(self, msg):
        self.__writebuffer += msg + "\r\n"

    def messageChannel(self, channel, line, includeSelf=False):
        for client in channel.members:
            if client != self or includeSelf:
                client.message(line)

    def messageRelated(self, msg, includeSelf=False):
        clients = sets.Set()
        if includeSelf:
            clients.add(self)
        for channel in self.channels.values():
            clients |= channel.members
        if not includeSelf:
            clients.discard(self)
        for client in clients:
            client.message(msg)

    def sendMotd(self):
        server = self.__server
        motdlines = server.getMotdLines()
        if motdlines:
            self.message(
                ":%s 375 %s :- %s Message of the day -" % (
                    server.name,
                    self.nickname,
                    server.name))
            for line in motdlines:
                self.message(
                    ":%s 372 %s :- %s" % (
                        server.name,
                        self.nickname,
                        line.rstrip()))
            self.message(
                ":%s 376 %s :End of /MOTD command" % (
                server.name,
                self.nickname))

class Server(object):
    def __init__(self, ports, password, motdfile, verbose):
        self.__ports = ports
        self.__password = password
        self.__verbose = verbose
        self.__channels = {} # irc_lower(Channel name) --> Channel instance.
        self.__clients = {}  # Socket --> Client instance.
        self.__nicknames = {} # irc_lower(Nickname) --> Client instance.
        self.__name = socket.getfqdn()[:63] # Server name limit from the RFC.
        self.__motdfile = motdfile

    def daemonize(self):
        try:
            pid = os.fork()
            if pid > 0:
                sys.exit(0)
        except OSError:
            sys.exit(1)
        os.setsid()
        try:
            pid = os.fork()
            if pid > 0:
                self.printInfo("PID: %d" % pid)
                sys.exit(0)
        except OSError:
            sys.exit(1)
        os.chdir("/")
        os.umask(0)
        devNull = file("/dev/null", "r+")
        os.dup2(devNull.fileno(), sys.stdout.fileno())
        os.dup2(devNull.fileno(), sys.stderr.fileno())
        os.dup2(devNull.fileno(), sys.stdin.fileno())

    def getName(self):
        return self.__name
    name = property(getName)

    def getPassword(self):
        return self.__password
    password = property(getPassword)

    def getChannels(self):
        return self.__channels.values()
    channels = property(getChannels)

    def getClient(self, nickname):
        return self.__nicknames.get(irc_lower(nickname))

    def getChannel(self, channelname):
        return self.__channels.get(irc_lower(channelname))

    def getMotdLines(self):
        if self.__motdfile:
            try:
                f = file(self.__motdfile)
                return f.readlines()
            except IOError:
                return ["Could not read MOTD file %s." % self.__motdfile]
        else:
            return []

    def printInfo(self, msg):
        if self.__verbose:
            print msg

    def printError(self, msg):
        print >>sys.stderr, msg

    def clientChangedNickname(self, client, oldnickname):
        if oldnickname:
            del self.__nicknames[irc_lower(oldnickname)]
        self.__nicknames[irc_lower(client.nickname)] = client

    def addMemberToChannel(self, client, channelname):
        if self.__channels.has_key(irc_lower(channelname)):
            channel = self.__channels[irc_lower(channelname)]
        else:
            channel = Channel(self, channelname)
            self.__channels[irc_lower(channelname)] = channel
        channel.addMember(client)

    def removeMemberFromChannel(self, client, channelname):
        if self.__channels.has_key(irc_lower(channelname)):
            channel = self.__channels[irc_lower(channelname)]
            channel.removeClient(client)

    def removeClient(self, client, quitmsg):
        client.messageRelated(":%s QUIT :%s" % (client.prefix, quitmsg))
        for chan in client.channels.values():
            chan.removeClient(client)
        if client.nickname \
               and self.__nicknames.has_key(irc_lower(client.nickname)):
            del self.__nicknames[irc_lower(client.nickname)]
        del self.__clients[client.socket]

    def removeChannel(self, channel):
        del self.__channels[irc_lower(channel.name)]

    def start(self):
        serversockets = []
        for port in self.__ports:
            s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
            s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
            try:
                s.bind(("", port))
            except socket.error, x:
                self.printError("Could not bind port %s: %s." % (port, x))
                sys.exit(1)
            s.listen(5)
            serversockets.append(s)
            del s
            self.printInfo("Listening on port %d." % port)

        lastAlivenessCheck = time.time()
        while True:
            iwtd, owtd, ewtd = select.select(
                serversockets + [x.socket for x in self.__clients.values()],
                [x.socket for x in self.__clients.values()
                          if x.writeQueueSize() > 0],
                [],
                10)
            for x in iwtd:
                if x in self.__clients:
                    self.__clients[x].socketReadableNotification()
                else:
                    conn, addr = x.accept()
                    self.__clients[conn] = Client(self, conn)
                    self.printInfo("Accepted connection from %s:%s." % (
                        addr[0], addr[1]))
            for x in owtd:
                self.__clients[x].socketWritableNotification()
            now = time.time()
            if lastAlivenessCheck + 10 < now:
                for client in self.__clients.values():
                    client.checkAliveness()
                lastAlivenessCheck = now

_alpha = "abcdefghijklmnopqrstuvwxyz"
_special = "-[]\\`^{}"
nick_characters = _alpha + _alpha.upper() + string.digits + _special
_ircstring_translation = string.maketrans(
    string.upper(_alpha) + "[]\\^",
    _alpha + "{}|~")

def irc_lower(s):
    return string.translate(s, _ircstring_translation)

######################################################################

def displayUsage():
    print "Usage: miniircd [arguments]"
    print
    print "miniircd is a small and limited IRC server."
    print
    print "Arguments:"
    print
    print "    -d, --daemon         Fork and become a daemon."
    print "    -h, --help           Show this help text."
    print "        --motd X         Display file X as message of the day."
    print "    -p, --password X     Require connection password X. Default: no password."
    print "        --ports X        Listen to ports X (a list separated by comma or"
    print "                         whitespace). Default: 6667."
    print "    -v, --verbose        Be verbose (print some progress messages on stdout)."

def main(argv):
    try:
        optlist, arguments = getopt.getopt(
            argv[1:],
            "dhp:v",
            ["daemon",
             "help",
             "motd=",
             "password=",
             "ports=",
             "verbose"])
    except getopt.error, x:
        sys.stderr.write("Bad arguments: %s.\n" % x)
        sys.exit(17)
    motd = None
    password = None
    ports = [6667]
    verbose = False
    daemon = False
    for opt, val in optlist:
        if opt in ("-d", "--daemon"):
            daemon = True
        elif opt in ("-h", "--help"):
            displayUsage()
            sys.exit(0)
        elif opt == "--motd":
            motd = val
        elif opt in ("-p", "--password"):
            password = val.lower()
        elif opt == "--ports":
            ports = []
            for port in re.split("[,\s]+", val):
                try:
                    ports.append(int(port))
                except ValueError:
                    sys.stderr.write("Bad port: \"%s\".\n" % port)
                    sys.exit(1)
        elif opt in ("-v", "--verbose"):
            verbose = True
    server = Server(ports, password, motd, verbose)
    if daemon:
        server.daemonize()
    try:
        server.start()
    except KeyboardInterrupt:
        server.printError("Interrupted.")

main(sys.argv)
