[Edu-sig] writing an chat-server

Ka-Ping Yee ping@lfw.org
Sun, 9 Apr 2000 23:40:11 -0700 (PDT)


On Sun, 9 Apr 2000, A[r]TA wrote:
> I want to write a simple chat-server on my pc so my friends can login and
> chat. :)
> But I've some problems.
> 1) How do I get multiple connections?
> 2) How do I handle the multiple requests and sends of data?
> A complete solution isn't needed, but if someone could help me a bit,
> I would be glad. :)

This sounds like a fun little project, so i'm going to attempt
it now, on the spot.

I'm assuming you mean that you may have many people joining
in on a single conversation.  In this case, you can't simply
decide whose turn it is to speak and "recv()" on only that one
connection, since that will stop everything until that person
says something.  You have to watch all the connections at once,
including the "listening socket" ('s' in your example) to see
if someone new is joining in.

The answer to both of your questions, roughly, is select().
The select() routine will watch a number of file handles all
together, and let you know when any of them has incoming data.

We begin as before:

    import socket, string, select
    HOST, PORT = '', 5007

    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    s.bind((HOST, PORT))
    s.listen(1)

To keep the conversation sane, let's associate a name with
each incoming connection.  (This dictionary maps socket objects
to names, so it will also conveniently keep track of currently
active sockets in its keys.)

    socknames = {}

A useful thing to be able to do is to copy a message to all
participants but one, so let's take care of that in a function.

    def broadcast(message, sender):
        for sock in socknames.keys():
            if sock is not sender:
                sock.send(message)

Now, the main loop of the program will watch for new connections.
The select() call accepts three lists of file *numbers* (not
Python file objects, unfortunately; you have to call fileno()
on a file object to get its number).  The lists indicate which
file handles you want to watch for readability, writability,
and errors, respectively.  All we care about is reading at the
moment.  The fourth argument to select() is a timeout, in seconds;
since we have nothing else to do if no one has anything to say, we
can use None, which means "wait forever until something happens".
select() returns three lists, each a subset of the corresponding
list passed in, indicating what files are readable, writable, or
have errors, respectively.

    while 1:
        nums = [s.fileno()]
        for sock in socknames.keys():
            nums.append(sock.fileno())
        rd, wr, ex = select.select(nums, [], [], None)

At this point, something has happened on one of our sockets,
and 'rd' contains a list of the file numbers to check.  Let's
look through the list to see what we got.

        for n in rd:
            if n == s.fileno():

This means we have a new connection, so let's put it into our
dictionary.  The first thing we will do is ask the new participant
to enter a name for themselves.  The initial value in the dictionary
will be 'None' until we get a name from them.

                sock, addr = s.accept()
                socknames[sock] = None
                sock.send("Hello!  Please enter your name.\n")

            else:

This means someone said something on an existing connection.
First let's figure out which connection it was.  In the loop
below, we can be certain that 'n' must be the 'fileno()' of
one of the sockets in our dictionary, so 'sock' will be bound
to that socket when we break out of the loop.

                for sock in socknames.keys():
                    if sock.fileno() == n: break
                name = socknames[sock]

Now let's read in what was said.  For simplicity's sake, we're
making the assumption here that everybody is entering text a line
at a time, and that it is safe to expect a newline.  (Handling
character-at-a-time mode wouldn't be too hard -- we would just
have to keep a little buffer associated with each connection.)
We use the 'makefile()' method here, which makes a file-like
object out of the socket, so that we can use the convenient
'readline()' method to take care of this for us.

                text = sock.makefile().readline()
                if not text:

An empty string returned from 'readline()' means the connection
has been closed.  We should let everyone know, and then clean up.

                    broadcast("%s has left.\n" % name, sock)
                    sock.close()
                    del socknames[sock]

                elif name is None:

If something has been said but we haven't recorded a name yet,
then this is a new participant's response to our initial request
for a name.  So let's record this, and bring them into the fold.

                    name = string.strip(text)
                    socknames[sock] = name
                    sock.send("Thanks!  Welcome to the conversation.\n")
                    broadcast("%s has arrived.\n" % name, sock)

                else:

The final case is that we already have a name, and something
has been said -- this is just part of the conversation, and
we should echo it to everyone else.

                    broadcast("%s: %s" % (name, text), sock)

And we're done.  That's it!

                            *      *      *

Here is the assembled script.  Give it a go:


    import socket, string, select
    HOST, PORT = '', 5007

    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    s.bind((HOST, PORT))
    s.listen(1)

    socknames = {}

    def broadcast(message, sender):
        for sock in socknames.keys():
            if sock is not sender:
                sock.send(message)

    while 1:
        nums = [s.fileno()]
        for sock in socknames.keys():
            nums.append(sock.fileno())
        rd, wr, ex = select.select(nums, [], [], None)

        for n in rd:
            if n == s.fileno():
                sock, addr = s.accept()
                socknames[sock] = None
                sock.send("Hello!  Please enter your name.\n")

            else:
                for sock in socknames.keys():
                    if sock.fileno() == n: break
                name = socknames[sock]

                text = sock.makefile().readline()

                if not text:
                    broadcast("%s has left.\n" % name, sock)
                    sock.close()
                    del socknames[sock]

                elif name is None:
                    name = string.strip(text)
                    socknames[sock] = name
                    sock.send("Thanks!  Welcome to the conversation.\n")
                    broadcast("%s has arrived.\n" % name, sock)

                else:
                    broadcast("%s: %s" % (name, text), sock)


                            *      *      *

Exercises for the reader:

    1.  Accept command-line arguments to set the HOST and PORT
        of the chat server.

    2.  It's kind of nice to know who's in the room when you enter.
        Have the chat server print a message to new participants
        when they join to inform them of who's already present.

    3.  Enable the participants to do other things besides just
        talking, by accepting simple commands.  If all commands
        begin with '/', then commands can be distinguished from
        chatter just by looking at the first character.  Some
        examples of useful commands, in order of increasing
        difficulty, might be:

            "/quit" - leave the room
            "/look" - tell me who's in the room
            "/name" - change my name
            "/emote" - show an action about me (for example,
                "/emote hops on one foot." would display
                "Ping hops on one foot." to all the others)

    4.  Turn the chat server into a full-fledged multi-user game.
        Get millions of people addicted.  Sell advertising space,
        go public, and make a fortune.


-- ?!ng

"All models are wrong; some models are useful."
    -- George Box