diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ed8ebf5 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +__pycache__ \ No newline at end of file diff --git a/README b/README deleted file mode 100644 index 9013b20..0000000 --- a/README +++ /dev/null @@ -1,107 +0,0 @@ -ircbotframe.py is an IRC bot framework written in python. - -Eventually usage will be to import the ircBot class from ircbotframe.py - ------------------ -1. Example usage: ------------------ - -First, create the bot object with the constructor: - - bot = ircBot(, , , ) - -Next, bind functions to the various IRC message types. These functions will be called when the IRC bot receives messages with those message types. The function to be called should be of the form: - - funcname(sender, header, message) - -These can then be bound using: - - bot.bind(, ) - -Once functions have been bound, connect the irc bot to the server using: - - bot.connect() - -Then join a / some channel(s) using: - - bot.joinchan() - -where begins with the '#' character. Finally, make the bot start listening to messages with: - - bot.run() - ---------------- -2. Bot methods: ---------------- - -ban (banMask, channel, reason): - - Bans and kicks users satistfying the ban mask from with a given (optional). Only works if the bot has operator privileges in . - -bind (msgtype, callback) - - This command binds a particular message type (passed as a string) to a given function. If the bot hears a message with this message type, the corresponding function will be called. The function MUST be of the form: - - funcname(sender, headers, message) - - where is the nick of the entity that sent the message, is a list of any additional message headers before the message content and is the actual message content. Bind can be used to listen for both server and user message types (bear in mind that server message types are usually numeric strings). Bind only allows a single function to be bound to each message type. - -connect () - - Makes the bot connect to the specified irc server. - -debug (state) - - Sets the bot's debug state to which is a boolean value. - -disconnect (qMessage) - - Makes the bot disconnect from the irc server with the quit message (for no quit message, put an empty string) - -identify (nick, callbackApproved, approvedParameters, callbackDenied, deniedParameters) - - where and are of the form: - - funcname(parameter 1, parameter 2, ...) - - Used for checking whether or not a user with the nickname is who they say they are by checking their WHOIS info from the server. If they are verified, the function will be called with the parameters (which will fill , , etc. in order). If they cannot be verified or are not registered, then is called with . Parameter lists and are tuples with the tuple items matching the and function parameters in order from the second parameter. For example: - - identify(nick, approved, (string, integer1), denied, (integer2)) - - If the nick is verified the following function will be called: - - approved(string, integer1) - - Otherwise the other function will be called: - - denied(integer2) - -joinchan (channel) - - Makes the bot join a given channel. MUST start with a '#' character. - -kick (nick, channel, reason) - - If the bot has operator privileges in the channel , the user with will be kicked from it with the given reason . - -reconnect () - - Disconnects from the server then reconnects. It disconnects with the quit message "Reconnecting". - -run () - - Tells the bot to start listening to the messages it receives and to act upon the input using functions connected to the bindings using the command bind(). The bot starts listening on a new thread and so is not a blocking call. Any bot functions you wish to call must be called by functions connected to bindings (using the command bind()). - -say (recipient, message) - - The bot says the given message to the recipient . The recipient can be a channel (and should start with a '#' character if this is the case). - -send (string) - - Sends a raw IRC message given by to the server. Can have dire consequences if your message is not formatted correctly. It is advised that you consult the IRC specification in RFC 1459. - -stop () - - Stops the IRC bot from running. You should disconnect from the server first. The bot's thread will terminate naturally. Should you wish to use the bot's thread join() function, stop() should be called first. - - diff --git a/README.md b/README.md new file mode 100644 index 0000000..fc506d7 --- /dev/null +++ b/README.md @@ -0,0 +1,93 @@ +This is an **IRC bot framework written in Python 3**. + +To use it, import the `ircBot` class from [`ircbotframe.py`](ircbotframe.py). + +Example usage +============= + +First, create the bot object with the constructor: + + bot = ircBot(, , , [, password=][, ssl=True][, ip_ver=<4 or 6>]) + +Next, bind functions to the various IRC message types. These functions will be called when the IRC bot receives messages with those message types. The function to be called should be of the form: + + funcname(sender, header, message) + +These can then be bound using: + + bot.bind(, ) + +Once functions have been bound, connect the irc bot to the server using: + + bot.connect() + +Then join a / some channel(s) using: + + bot.joinchan() + +where `channel name` begins with the `#` character. Finally, make the bot start listening to messages with: + + bot.run() + +Bot methods +=========== + + ban(banMask, channel, reason) + +Bans and kicks users satistfying the ban mask `banMask` from `channel` with a given `reason` (optional). Only works if the bot has operator privileges in `channel`. + + bind(msgtype, callback) + +This command binds a particular message type `msgtype` (passed as a string) to a given function. If the bot hears a message with this message type, the corresponding function will be called. The `callback` function MUST be of the form `funcname(sender, headers, message)` where `sender` is the nick of the entity that sent the message, `headers` is a list of any additional message headers before the message content and `message` is the actual message content. Bind can be used to listen for both server and user message types (bear in mind that server message types are usually numeric strings). Bind only allows a single function to be bound to each message type. + + connect() + +Makes the bot connect to the specified irc server. This function is blocking until the server sends the end of the motd or the connect_timeout is reached. The return value indicates which case you have (`True` means successful). + + debug(state) + +Sets the bot's debug state to `state` which is a boolean value. + + disconnect(qMessage) + +Makes the bot disconnect from the irc server with the quit message `qMessage` (for no quit message, put an empty string) + + identify(nick, callbackApproved, approvedParameters, callbackDenied, deniedParameters) + +where `callbackApproved` and `callbackDenied` are of the form `funcname(parameter 1, parameter 2, ...)`. Used for checking whether or not a user with the nickname `nick` is who they say they are by checking their `WHOIS` info from the server. If they are verified, the function `callbackApproved` will be called with the parameters `approvedParameters` (which will fill `parameter 1`, `parameter 2`, etc. in order). If they cannot be verified or are not registered, then `callbackDenied` is called with `deniedParameters`. Parameter lists `approvedParameters` and `deniedParameters` are tuples with the tuple items matching the `callbackApproved` and `callbackDenied` function parameters in order from the second parameter. For example, `identify(nick, approved, (string, integer1), denied, (integer2))`. If the nick is verified the following function will be called: `approved(string, integer1)`. Otherwise the other function will be called: `denied(integer2)` + + joinchan(channel) + +Makes the bot join a given channel. `channel` *must* start with a `#` character. + + kick(nick, channel, reason) + +If the bot has operator privileges in the channel `channel`, the user with `nick` will be kicked from it with the given reason . + + reconnect() + +Disconnects from the server then reconnects. It disconnects with the quit message "Reconnecting". + + run() + +Tells the bot to start listening to the messages it receives and to act upon the input using functions connected to the bindings using the command `bind()`. The bot starts listening on a new thread and so is not a blocking call. Any bot functions you wish to call must be called by functions connected to bindings (using the command `bind()`). + + say(recipient, message) + +The bot says the given message `message` to the recipient `recipient`. The recipient can be a channel (and should start with a `#` character if this is the case). + + send(string) + +Sends a raw IRC message given by `string` to the server. Can have dire consequences if your message is not formatted correctly. It is advised that you consult the IRC specification in RFC 1459. + + stop() + +Stops the IRC bot from running. You should disconnect from the server first. The bot's thread will terminate naturally. Should you wish to use the bot's thread `join()` function, `stop()` should be called first. + + topic(channel, message) + +The bot sets the topic in `channel` to `message`, if it has sufficient privileges. + + unban(banMask, channel) + +Unbans previously banned users satistfying the ban mask `banMask` from `channel`. Only works if the bot has operator privileges in `channel`. diff --git a/examplebot.py b/examplebot.py index 1246f40..61e378a 100644 --- a/examplebot.py +++ b/examplebot.py @@ -45,10 +45,10 @@ def privmsg(sender, headers, message): if sender == owner: bot.identify(sender, kickSuccess, (message[6:firstSpace], message[firstSpace+1:secondSpace], message[secondSpace+1:]), authFailure, (headers[0], sender)) else: - print "PRIVMSG: \"" + message + "\"" + print("PRIVMSG: \"" + message + "\"") def actionmsg(sender, headers, message): - print "An ACTION message was sent by " + sender + " with the headers " + str(headers) + ". It says: \"" + sender + " " + message + "\"" + print("An ACTION message was sent by " + sender + " with the headers " + str(headers) + ". It says: \"" + sender + " " + message + "\"") def endMOTD(sender, headers, message): bot.joinchan(chanName) @@ -76,10 +76,10 @@ def endMOTD(sender, headers, message): bot.start() inputStr = "" while inputStr != "stop": - inputStr = raw_input() + inputStr = input() bot.stop() bot.join() else: - print "Usage: python examplebot.py " + print("Usage: python examplebot.py ") diff --git a/ircbotframe.py b/ircbotframe.py index 9c259ea..46be5ae 100644 --- a/ircbotframe.py +++ b/ircbotframe.py @@ -1,46 +1,44 @@ +import ipaddress +import re import socket +import ssl import threading -import re import time +import sched +import queue + class ircOutputBuffer: - # Delays consecutive messages by at least 1 second. - # This prevents the bot spamming the IRC server. + # This class provides buffered and unbuffered sending to a socket def __init__(self, irc): - self.waiting = False self.irc = irc - self.queue = [] - self.error = False - def __pop(self): - if len(self.queue) == 0: - self.waiting = False - else: - self.sendImmediately(self.queue[0]) - self.queue = self.queue[1:] - self.__startPopTimer() - def __startPopTimer(self): - self.timer = threading.Timer(1, self.__pop) - self.timer.start() + self.queue = queue.Queue() + def sendBuffered(self, string): # Sends the given string after the rest of the messages in the buffer. - # There is a 1 second gap between each message. - if self.waiting: - self.queue.append(string) - else: - self.waiting = True - self.sendImmediately(string) - self.__startPopTimer() + self.queue.put_nowait(string) + return True + + def sendFromQueue(self): + # Send the oldest message in the buffer if there is one + try: + string = self.queue.get_nowait() + result = self.sendImmediately(string) + self.queue.task_done() + return result + except queue.Empty: + return True + def sendImmediately(self, string): # Sends the given string without buffering. - if not self.error: - try: - self.irc.send(bytes(string) + b"\r\n") - except socket.error, msg: - self.error = True - print "Output error", msg - print "Was sending \"" + string + "\"" - def isInError(self): - return self.error + try: + self.irc.send((string + "\r\n").encode("utf-8")) + return True + except socket.error as msg: + print("Output error", msg) + print("Was sending \"" + string + "\"") + return False + class ircInputBuffer: # Keeps a record of the last line fragment received by the socket which is usually not a complete line. @@ -49,46 +47,93 @@ def __init__(self, irc): self.buffer = "" self.irc = irc self.lines = [] + def __recv(self): # Receives new data from the socket and splits it into lines. - # Last (incomplete) line is kept for buffer purposes. try: - data = self.buffer + self.irc.recv(4096) - except socket.error, msg: - raise socket.error, msg - self.lines += data.split(b"\r\n") - self.buffer = self.lines[len(self.lines) - 1] - self.lines = self.lines[:len(self.lines) - 1] + data = self.buffer + self.irc.recv(4096).decode("utf-8") + except UnicodeDecodeError: + data = '' + self.lines += data.split("\r\n") + # Last (incomplete) line is kept for buffer purposes. + self.buffer = self.lines[-1] + self.lines = self.lines[:-1] + def getLine(self): - # Returns the next line of IRC received by the socket. - # Converts the received string to standard string format before returning. + # Returns the next line of IRC received by the socket or None. + # This should already be in the standard string format. + # If no lines are buffered, this blocks until a line is received + # or we reach the socket timeout. When the timeout is + # reached, the function returns None. + while len(self.lines) == 0: try: self.__recv() - except socket.error, msg: - raise socket.error, msg - time.sleep(1); + except socket.timeout: + return None + line = self.lines[0] self.lines = self.lines[1:] - return str(line) + return line + class ircBot(threading.Thread): - def __init__(self, network, port, name, description): + def __init__(self, network, port, name, description, password=None, ssl=False, ip_ver=None): threading.Thread.__init__(self) self.keepGoing = True self.name = name self.desc = description + self.password = password self.network = network self.port = port + self.ssl = ssl self.identifyNickCommands = [] self.identifyLock = False - self.binds = [] + self.binds = {} self.debug = False + self.default_log_length = 200 + self.log_own_messages = True + self.channel_data = {} + self.irc = None + self.outBuf = None + self.inBuf = None + self.connected = False + self.connect_timeout = 30 + self.reconnect_interval = 30 + self.ping_timeout = 10 + self.ping_interval = 60 + + self.bind("PONG", self.__handlePong) + self.__unansweredPing = False + self.__sched = sched.scheduler() + + if ip_ver == 4: + self.socket_family = socket.AF_INET + elif ip_ver == 6: + self.socket_family = socket.AF_INET6 + elif ip_ver is None: + try: + address = ipaddress.ip_address(network) + except: + for family, _, _, _, _ in socket.getaddrinfo(network, port, proto=socket.IPPROTO_TCP): + if family == socket.AF_INET6: + self.socket_family = socket.AF_INET6 + break + else: + self.socket_family = socket.AF_INET + else: + self.socket_family = { + 4: socket.AF_INET, + 6: socket.AF_INET6 + }[address.version] + else: + raise ValueError('Invalid IP version: {!r}'.format(ip_ver)) + # PRIVATE FUNCTIONS def __identAccept(self, nick): """ Executes all the callbacks that have been approved for this nick """ - i = 0 + i = 0 while i < len(self.identifyNickCommands): (nickName, accept, acceptParams, reject, rejectParams) = self.identifyNickCommands[i] if nick == nickName: @@ -96,6 +141,7 @@ def __identAccept(self, nick): self.identifyNickCommands.pop(i) else: i += 1 + def __identReject(self, nick): # Calls the given "denied" callback for all functions called by that nick. i = 0 @@ -106,11 +152,13 @@ def __identReject(self, nick): self.identifyNickCommands.pop(i) else: i += 1 + def __callBind(self, msgtype, sender, headers, message): # Calls the function associated with the given msgtype. - for (messageType, callback) in self.binds: - if (messageType == msgtype): - callback(sender, headers, message) + callback = self.binds.get(msgtype) + if callback: + callback(sender, headers, message) + def __processLine(self, line): # If a message comes from another user, it will have an @ symbol if "@" in line: @@ -131,7 +179,7 @@ def __processLine(self, line): # Split everything up to the lastColon (ie. the headers) headers = line[1:lastColon-1].strip().split(" ") message = line[lastColon:] - + sender = headers[0] if len(headers) < 2: self.__debugPrint("Unhelpful number of messages in message: \"" + line + "\"") @@ -141,95 +189,230 @@ def __processLine(self, line): if cut != -1: sender = sender[:cut] msgtype = headers[1] - if msgtype == "PRIVMSG" and message.startswith("ACTION ") and message.endswith(""): - msgtype = "ACTION" - message = message[8:-1] - self.__callBind(msgtype, sender, headers[2:], message) + if msgtype == 'PRIVMSG': + if message.startswith('\001ACTION ') and message.endswith('\001'): + msgtype = 'ACTION' + message = message[8:-1] + if headers[2].startswith('#'): + self.log(headers[2], msgtype, sender, headers[2:], message) # log PRIVMSG and ACTION only for now else: - self.__debugPrint("[" + headers[1] + "] " + message) - if (headers[1] == "307" or headers[1] == "330") and len(headers) >= 4: + msgtype = headers[1] + self.__debugPrint('[' + msgtype + '] ' + message) + if msgtype == '376': + self.connected = True + if msgtype in ['307', '330'] and len(headers) >= 4: self.__identAccept(headers[3]) - if headers[1] == "318" and len(headers) >= 4: + if msgtype == '318' and len(headers) >= 4: self.__identReject(headers[3]) #identifies the next user in the nick commands list if len(self.identifyNickCommands) == 0: self.identifyLock = False else: self.outBuf.sendBuffered("WHOIS " + self.identifyNickCommands[0][0]) - self.__callBind(headers[1], sender, headers[2:], message) - + self.__callBind(msgtype, sender, headers[2:], message) + def __debugPrint(self, s): if self.debug: - print s + print(s) + + def __periodicSend(self): + if not self.irc: + return + + if not self.outBuf.sendFromQueue(): + self.close() + return + + # Delays consecutive messages by at least 1 second. + # This prevents the bot spamming the IRC server. + self.__sched.enter(1, priority=10, action=self.__periodicSend) + + def __periodicRecv(self): + if not self.irc: + return + + try: + line = self.inBuf.getLine() + except socket.error as msg: + self.__debugPrint("Input error", msg) + self.close() + return + + if line is not None: + if line.startswith("PING"): + if not self.outBuf.sendImmediately("PONG " + line.split()[1]): + self.close() + return + else: + self.__processLine(line) + + # next recv should be directly but with verly low priority + self.__sched.enter(0.01, priority=1, action=self.__periodicRecv) + + def __periodicPing(self): + self.ping() + self.__sched.enter(self.ping_interval, 1, self.__periodicPing) + + def __handlePong(self, sender, headers, message): + self.__unansweredPing = False + + def __handlePingTimeout(self): + if self.__unansweredPing: + self.__debugPrint("Ping timeout reached. Killing the connection.") + self.close() + + def ping(self): + if self.__unansweredPing: + return + + self.outBuf.sendImmediately('PING %s' % self.network) + self.__unansweredPing = True + self.__sched.enter(self.ping_timeout, 1, self.__handlePingTimeout) + + def log(self, channel, msgtype, sender, headers, message): + if channel in self.channel_data: + self.channel_data[channel]['log'].append((msgtype, sender, headers, message)) + if len(self.channel_data[channel]['log']) > self.channel_data[channel]['log_length']: + self.channel_data[channel]['log'] = self.channel_data[channel]['log'][-self.channel_data[channel]['log_length']:] # trim log to log length if necessary + # PUBLIC FUNCTIONS def ban(self, banMask, channel, reason): + # only bans, no kick. self.__debugPrint("Banning " + banMask + "...") - self.outBuf.sendBuffered("MODE +b " + channel + " " + banMask) - self.kick(nick, channel, reason) - + self.send("MODE +b " + channel + " " + banMask) + # TODO get nick + #self.kick(nick, channel, reason) + def bind(self, msgtype, callback): - # Check if the msgtype already exists - for i in xrange(0, len(self.binds)): - # Remove msgtype if it has already been "binded" to - if self.binds[i][0] == msgtype: - self.binds.remove(i) - self.binds.append((msgtype, callback)) - + self.binds[msgtype] = callback + + def __handleConnectingTimeout(self): + if not self.connected: + self.close() + def connect(self): self.__debugPrint("Connecting...") - self.irc = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - self.irc.connect((self.network, self.port)) + self.irc = socket.socket(self.socket_family, socket.SOCK_STREAM) + self.irc.settimeout(self.connect_timeout) + + if self.ssl: + self.irc = ssl.wrap_socket(self.irc) + + try: + self.irc.connect((self.network, self.port)) + except socket.error as msg: + self.__debugPrint("Connection failed: %s" % msg) + self.close() + return False + + self.irc.settimeout(1.0) + self.inBuf = ircInputBuffer(self.irc) self.outBuf = ircOutputBuffer(self.irc) + + if self.password is not None: + self.outBuf.sendBuffered("PASS " + self.password) + self.outBuf.sendBuffered("NICK " + self.name) - self.outBuf.sendBuffered("USER " + self.name + " " + self.name + " " + self.name + " :" + self.desc) + self.outBuf.sendBuffered("USER " + self.name + " 0 * :" + self.desc) + + self.connected = False + + self.__periodicSend() + self.__periodicRecv() + self.__sched.enter(self.connect_timeout, priority=20, action=self.__handleConnectingTimeout) + + while True: + if self.connected: + self.__debugPrint("Connection was successful!") + return True + + if self.irc is None: + return False + + self.__sched.run(blocking=False) + def debugging(self, state): self.debug = state + + def close(self): + self.outBuf = None + self.inBuf = None + self.irc.close() + self.irc = None + self.connected = False + def disconnect(self, qMessage): self.__debugPrint("Disconnecting...") - self.outBuf.sendBuffered("QUIT :" + qMessage) - self.irc.close() + # TODO make the following block until the message is sent + self.send("QUIT :" + qMessage) + self.close() + def identify(self, nick, approvedFunc, approvedParams, deniedFunc, deniedParams): self.__debugPrint("Verifying " + nick + "...") self.identifyNickCommands += [(nick, approvedFunc, approvedParams, deniedFunc, deniedParams)] + # TODO this doesn't seem right if not self.identifyLock: - self.outBuf.sendBuffered("WHOIS " + nick) + self.send("WHOIS " + nick) self.identifyLock = True + def joinchan(self, channel): self.__debugPrint("Joining " + channel + "...") - self.outBuf.sendBuffered("JOIN " + channel) + self.channel_data[channel] = { + 'log': [], + 'log_length': self.default_log_length + } + self.send("JOIN " + channel) + def kick(self, nick, channel, reason): self.__debugPrint("Kicking " + nick + "...") - self.outBuf.sendBuffered("KICK " + channel + " " + nick + " :" + reason) - def reconnect(self): - self.disconnect("Reconnecting") + self.send("KICK " + channel + " " + nick + " :" + reason) + + def reconnect(self, gracefully=True): + if gracefully: + self.disconnect("Reconnecting") + else: + self.close() + self.__debugPrint("Pausing before reconnecting...") - time.sleep(5) + time.sleep(self.reconnect_interval) self.connect() + def run(self): self.__debugPrint("Bot is now running.") self.connect() + + self.__periodicPing() + while self.keepGoing: - line = "" - while len(line) == 0: - try: - line = self.inBuf.getLine() - except socket.error, msg: - print "Input error", msg - self.reconnect() - if line.startswith("PING"): - self.outBuf.sendImmediately("PONG " + line.split()[1]) - else: - self.__processLine(line) - if self.outBuf.isInError(): - self.reconnect() + if self.irc is None: + self.__debugPrint("Pausing before reconnecting...") + time.sleep(self.reconnect_interval) + self.connect() + continue + + self.__sched.run(blocking=False) + + self.disconnect() + def say(self, recipient, message): - self.outBuf.sendBuffered("PRIVMSG " + recipient + " :" + message) + if self.log_own_messages: + self.log(recipient, 'PRIVMSG', self.name, [recipient], message) + self.send("PRIVMSG " + recipient + " :" + message) + def send(self, string): + if not self.connected: + self.__debugPrint("WARNING: you are trying to send without being connected - \"", string, "\"") + return + self.outBuf.sendBuffered(string) + def stop(self): self.keepGoing = False - def unban(self, banMask, channel): - self.__debugPrint("Unbanning " + banMask + "...") - self.outBuf.sendBuffered("MODE -b " + channel + " " + banMask) + def topic(self, channel, message): + self.send('TOPIC ' + channel + ' :' + message) + + def unban(self, banMask, channel): + self.__debugPrint('Unbanning ' + banMask + '...') + self.send('MODE -b ' + channel + ' ' + banMask)