I made this IRC client ages ago in Python, and thought I'd revisit it for Python 3.5 (I'd like to play with asyncio). Before I start, I'd like to have an overall design review about this.
Known issues, although feel free to comment on them:
- I do too much in one class
- There is a fair bit of repetition that could probably be pulled out
I don't think there are any obvious bugs, but I never had a rigorous test suite for this (read as: there was never a test suite, I wrote this before I discovered unit testing) so its very possible I've missed something.
I'm also not an expert in the IRC spec - this was mostly googling and trial and error. If I've misunderstood/misused/missed something please comment on that as well.
from __future__ import absolute_import, print_function, unicode_literals
from functools import partial
from multiprocessing import dummy
import datetime
import enum
import logging
import select
import socket
import time
import threading
now = datetime.datetime.now()
logging.basicConfig(
filename=''.join(["Logs/", str(datetime.datetime.now()), ".log"]),
level=logging.INFO
)
ErrorCodes = enum(
'ErrorCodes',
'UNKNOWN_HOST UNKNOWN_FAILURE UNKNOWN_CHANNEL UNKOWN_USER '
'MESSAGE_SENT PONG_SUCCESS SERVER_CONNECTED HOSTNAME_NOT_RESOLVED '
'SERVER_DISCONNECTED CHANNEL_JOINED CHANNEL_NAME_MALFORMED CHANNEL_LEFT '
)
class IrcMember(object):
"""Represents an individual who uses the IRC client.
Only stores non-sensitive information.
Attributes
----------
nickname : str
User nickname.
real_name : str
User's "real" name.
ident : str
User's id
servers: dict
Server name to socket mapping for connected servers.
server_channels: dict
Server name to a list of channel names.
server_data: dict
Server name to dict of user information if it differs from the
default values.
lock: threading.Lock
Socket lock.
replies: dict
Pending replies.
DEFAULT_PORT: int
The default port to use.
"""
DEFAULT_PORT = 6667
def __init__(self, nick, **kwargs):
"""Creates a new member.
Parameters
----------
nick : str
The user's nickname.
real : str, optional
The user's "real" name, defaults to the nickname.
ident : str, optional
The user's id, defaults to the nickname.
"""
self.nickname = nick
self.real_name = nick
self.ident = nick
for key, value in kwargs.iteritems():
self.__dict__[key] = value
self.servers = {}
self.server_channels = {}
self.server_data = {}
self.lock = threading.Lock()
self.replies = {}
def send_server_message(self, hostname, message):
"""Send a message to a server.
Parameters
----------
hostname : str
Name of the server to send to.
message : str
Message to send.
"""
if hostname not in self.servers:
logging.warning("No such server {}".format(hostname))
logging.warning("Failed to send message {}".format(message))
return ErrorCodes.UNKNOWN_HOST
sock = self.servers[hostname]
try:
sock.send("{} \r\n".format(message.rstrip()))
except socket.error as e:
logging.exception(e)
logging.warning("Failed to send message {}".format(message))
return ErrorCodes.UNKNOWN_FAILURE
else:
return ErrorCodes.MESSAGE_SENT
def send_channel_message(self, hostname, chan_name, message):
"""Sends a message to a channel.
Parameters
----------
hostname : str
Name of the server to send to
chan_name : str
Name of the channel
message : str
Message to send
"""
if hostname not in self.servers:
logging.warning("Not connected to server {}".format(hostname))
logging.warning("Failed to send message {}".format(message))
return ErrorCodes.UNKNOWN_HOST
elif chan_name not in self.server_channels[hostname]:
logging.warning("Not in channel {}".format(chan_name))
logging.warning("Failed to send message {}".format(message))
return ErrorCodes.UNKNOWN_CHANNEL
else:
return self.send_private_message(
hostname, chan_name, message, channel=True
)
def send_private_message(self, hostname, name, message, channel=False):
"""Sends a private message.
Parameters
----------
hostname: str
Name of the server to send to
name: str
Name of the user or channel to send to
message: str
Message to send
channel: bool, optional
Whether or not this is a channel message
"""
if hostname not in self.servers:
logging.warning("No such server {}".format(hostname))
logging.warning("Failed to send message {}".format(message))
return ErrorCodes.UNKNOWN_HOST
if not (channel or self.user_exists(name)):
return ErrorCodes.UNKNOWN_USER
message = "PRIVMSG {}: {}".format(username, message.rstrip())
return self.send_server_message(hostname, message)
def user_exists(self, username):
"""Validate a user exists.
Parameters
----------
username: str
Name of the user.
"""
## TODO: implement this
return True
def ping_pong(self, sock, data):
"""Pong a server.
Parameters
----------
sock : socket.socket
Socket to pong on.
data : str
Data to send in pong.
"""
try:
sock.send("PONG {}\r\n".format(data))
except socket.error as e:
logging.exception(e)
logging.warn("Couldn't pong the server")
return ErrorCodes.UNKNOWN_FAILURE
else:
return ErrorCodes.PONG_SUCCESS
def join_server(self, hostname, port=None, **kwargs):
"""Join a server.
Parameters
----------
hostname: str
Name of the server to join.
port: int, optional
Port to connect on - defaults to `IrcMember.DEFAULT_PORT`.
nickname: str, optional
Nickname to use on this server
real_name: str, optional
'Real' name ot use on this server
ident: str, optional
Identity to use on this server.
"""
if port is None:
port = IrcMember.DEFAULT_PORT
if hostname in self.servers:
logging.warn("Already connected to {}".format(hostname))
return ErrorCodes.SERVER_CONNECTED
nick = self.nickname
ident = self.ident
realname = self.real_name
## Checking if the data for this server is different from the defaults
if kwargs:
self.serv_to_data[hostname] = {}
for key, value in kwargs.items():
if key in ['nickname', 'real_name', 'ident']:
self.server_data[hostname][key] = value
locals()[key] = value
else:
logging.info(
"key-value pair {}: {} unusued".format(key, value)
)
if not self.server_data[hostname]:
del self.server_data[hostname]
try:
ip = socket.gethostbyname(hostname)
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect((ip, port))
self.servers[hostname] = sock
self.serv_to_chan[hostname] = []
sock.settimeout(2)
self.send_server_message(
hostname, "NICK {}\r\n".format(nick))
self.send_server_message(hostname,
"USER {} {} bla: {}\r\n".format(nick, ident, realname))
except socket.gaierror as e:
logging.exception(e)
return ErrorCodes.HOSTNAME_NOT_RESOLVED
except socket.error as e:
logging.exception(e)
if port != IrcMember.DEFAULT_PORT:
logging.warning(
"Consider using port %s (the defacto IRC port) not of %s"
% (IrcMember.DEFAULT_PORT, port)
)
return ErrorCodes.UNKNOWN_FAILURE
else:
logging.info("Connected to {} on {}".format(hostname, port))
return ErrorCodes.SERVER_CONNECTED
def leave_server(self, hostname):
"""Leave a server.
Parameters
----------
hostname: str
Server to disconnect from.
"""
if hostname not in self.servers:
logging.warning("Not connected to {}".format(hostname))
return ErrorCodes.SERVER_DISCONNECTED
try:
self.send_server_message(hostname, "QUIT\r\n")
self.servers[hostname].close()
except socket.error as e:
logging.exception(e)
logging.warning("Failed to leave server {}".format(hostname))
return 1
else:
for attr_name in ['servers', 'server_channels', 'server_data']:
try:
del getattr(self, attr_name)[hostname]
except KeyError:
# This host doesn't have any data about that
pass
logging.info("Left server {}".format(hostname))
return ErrorCodes.SERVER_DISCONNECTED
def join_channel(self, hostname, channel_name):
"""Join a channel.
Parameters
----------
hostname: str
Server the channel is on.
channel_name: str
Name of the channel.
"""
if channel_name in self.serv_to_chan[hostname]:
logging.warning(
"Already connected to {} on {}".format(hostname, channel_name))
return ErrorCodes.CHANNEL_JOINED
if chan_name.startswith("#"):
try:
self.send_server_message(
hostname, "JOIN {}\r\n".format(chan_name))
except socket.error as e:
logging.exception(e)
logging.warning("Failed to connect to {}".format(chan_name))
return ErrorCodes.UNKNOWN_FAILURE
else:
self.serv_to_chan[hostname].append(chan_name)
logging.info("Connected to {}".format(chan_name))
return ErrorCodes.CHANNEL_JOINED
else:
logging.warning("Channel names should look like #<channel_name>")
return ErrorCodes.CHANNEL_NAME_MALFORMED
def leave_channel(self, hostname, chan_name):
"""Leave a channel.
Parameters
----------
hostname: str
Server the channel is on.
channel_name: str
Name of the channel.
"""
if hostname not in self.servers:
logging.warning("No such server {}".format(hostname))
return ErrorCodes.UNKNOWN_HOST
elif chan_name not in self.serv_to_chan[hostname]:
logging.warning("No such channel {}".format(chan_name))
return ErrorCodes.CHANNEL_LEFT
else:
try:
self.send_server_message(
hostname, "PART {}\r\n".format(chan_name)
)
except socket.error as e:
logging.exception(e)
logging.warning("Failed to leave {}".format(chan_name))
return ErrorCodes.UNKNOWN_FAILURE
else:
self.serv_to_chan[hostname].remove(chan_name)
logging.info("Left channel {}".format(chan_name))
return ErrorCodes.CHANNEL_LEFT
def receive_all_messages(self, buff_size=4096):
"""Display all messages waiting to be received.
Parameters
----------
buff_size: int, optional
How large of a buffer to receive with.
"""
ready, _, _ = select.select(self.servers.values(), [], [], 5)
if ready:
for i in range(len(ready)):
for host, sock in self.servers.iteritems():
if sock == ready[i]:
ready[i] = host
try:
pool = dummy.Pool()
pool.map(partial(self.receive_message,
buff_size=buff_size),
(tuple(ready),))
with self.lock:
replies, self.replies = self.replies, {}
for server, reply in replies.iteritems():
print("{} :\n\n".format(server))
for message in reply:
print(" {}".format(message))
except socket.error as e:
logging.exception(e)
logging.warning("Failed to get messages")
return ErrorCodes.UNKNOWN_FAILURE
return ErrorCodes.MESSAGES_RECEIVED
def receive_message(self, hostname, buff_size=4096):
"""Receive a message from a single server.
Parameters
----------
hostname: tuple
Server to receive from.
buff_size: int, optional
How large of a buffer to receive with.
Notes
-----
Has already checked that there is a message waiting.
"""
hostname = hostname[0]
reply = []
sock = self.servers[hostname]
while True:
try:
readbuffer = sock.recv(buff_size)
if not readbuffer:
break
temp = readbuffer.split("\n")
readbuffer = temp.pop()
for line in temp:
line = line.rstrip().split()
if (line[0] == "PING"):
self.ping_pong(sock, line[1])
else:
line = " ".join(line)
reply.append(line)
except socket.error:
break
with self.lock:
try:
if reply not in self.replies[hostname]:
self.replies[hostname] += reply
except KeyError:
self.replies[hostname] = reply
def __del__(self):
for host, sock in self.servers.items():
self.leave_server(host)
if __name__ == "__main__":
NICK = raw_input("Please enter your nickname ")
HOST = raw_input("Please enter your desired server ")
CHAN = raw_input("Please enter your desired channel ")
me = IRC_member(NICK)
me.join_server(HOST, nickname='test', ident='test', real_name='test')
time.sleep(1)
me.receive_all_messages()
me.join_channel(HOST, CHAN)
time.sleep(1)
me.receive_all_messages()
i = 0
while i < 100:
start = time.time()
msg = raw_input("Would you like to say something? ")
if msg == 'n':
break
if msg.rstrip():
me.send_channel_message(HOST, CHAN, msg)
me.receive_all_messages()
end = time.time()
if (end-start) < 5:
time.sleep(int(5-(end-start)))
i += 1