diff --git a/.gitignore b/.gitignore index 0d20b64..26dcfc9 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ *.pyc +irc.db +seen_prs + diff --git a/Db.py b/Db.py index 7e30ca0..f20b307 100644 --- a/Db.py +++ b/Db.py @@ -23,11 +23,16 @@ class LogMessage(BaseModel): message_type = CharField() message = CharField() +class Quote(BaseModel): + datetime = DateTimeField() + author = CharField() + message = CharField() + def create_tables(): database.connect() try: - database.create_tables([LogMessage, Day, Channel]) + database.create_tables([Quote, LogMessage, Day, Channel]) except OperationalError: pass # Database already exists @@ -55,6 +60,12 @@ def add_log_message(channel, nickname, message_type, message = None): message_type = message_type, message = message) +def add_quote(author, message): + Quote.create( + author = author, + message = message, + datetime = datetime.datetime.now().strftime("%H:%m:%S")) + def show_all_messages(): for message in LogMessage.select(): - print "<%s> %s" % (message.nickname, message.message) + print("<%s> %s" % (message.nickname, message.message)) diff --git a/README.md b/README.md index c37ef86..e50c3dc 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,32 @@ -LogBot 0.4.2 -============ +# smooth-operator -Written by Chris Oliver +smooth-operator is a IRC bot which logs everything in a channel and offers more convinient things like notifications about new commits on GitHub, etc. For a roadmap please check the issues on GitHub. -Many thanks to Filip Slagter for his contributions. +Originally this was written by Chris Oliver with contributions from Filip Slagter. Now it has diverged quite a lot. -Requirements ------------- -LogBot shows logs using flask, and stores logs using peewee. Install these dependencies using ``pip``: +## Requirements + +smooth-operator shows logs using flask, and stores logs using peewee. Install these dependencies using ``pip``: pip install flask peewee -Usage ------ -LogBot requires Python 2. It is NOT compatible with Python 3. -Configuration is done inside logbot.py. +## Usage - python logbot.py +smooth-operator requires Python 2. It is NOT compatible with Python 3. Configuration is either done inside logbot.py, or using environment variables. The following environment variables are respected: -You can view logs on http://localhost:5000 +- ``IRC_SERVER``: IRC server +- ``IRC_PORT``: IRC server port +- ``IRC_SERVER_PASS``: Password for IRC server, if any +- ``IRC_CHANNELS``: IRC channels to join, separated by ``,`` +- ``IRC_NICK``: Nickname +- ``IRC_NICK_PASS``: Password to use when authenticating to nickserv, if any + +The bot can be launched using: + + python2 logbot.py + +You can view the logs on http://localhost:5000 + +## License + +This project is licensed under the GPLv2. diff --git a/commands.py b/commands.py new file mode 100644 index 0000000..718439e --- /dev/null +++ b/commands.py @@ -0,0 +1,44 @@ +from Db import * +from irclib import nm_to_n +import random + +class Commands: + def __init__(self): + self.commands = { + "quote": self.cmd_quote, + "remember_quote": self.cmd_remember_quote + } + + def process(self, c, e): + msg = e.arguments()[0] + if msg.startswith("!"): + cmd = msg.split("!")[1].split(" ")[0] + if cmd in self.commands: + msg = " ".join(msg.split(" ")[1:]) + self.commands[cmd](c, msg, e.target(), nm_to_n(e.source())) + else: + print "Unknown command", cmd + + def cmd_quote(self, c, msg, target, source): + replies = [ + "%s once said \"%s\"", + "I head from %s that \"%s\"", + "A wise man (haha, just kidding, it was actually %s) once said \"%s\"" + ] + reply = lambda msg: c.privmsg(target, msg) + random_query = Quote.select().order_by(fn.Random()) + try: + one_quote = random_query.get() + reply(random.choice(replies) % (one_quote.author, one_quote.message)) + except: # No quotes + reply("I don't know ay quotes :(") + + def cmd_remember_quote(self, c, msg, target, source): + reply = lambda msg: c.privmsg(target, msg) + + if (len(msg) > 1): + add_quote(msg.split(" ")[0], ' '.join(msg.split(" ")[1:])) + reply("I'll try to remember that!") + else: + reply("I didn't get that :(") + diff --git a/ircbot.py b/ircbot.py index 6f29a65..9cf6c1f 100644 --- a/ircbot.py +++ b/ircbot.py @@ -25,7 +25,14 @@ write simpler bots. """ import sys -from UserDict import UserDict + +# UserDict is moved to collections in Python3 +# In order to support Python 2.7 this has to be imported +# in the following way +try: + from UserDict import UserDict +except ImportError: + from collections import UserDict from irclib import SimpleIRCClient from irclib import nm_to_n, irc_lower, all_events @@ -160,7 +167,7 @@ class SingleServerIRCBot(SimpleIRCClient): """[Internal]""" before = nm_to_n(e.source()) after = e.target() - for ch in self.channels.values(): + for ch in list(self.channels.values()): if ch.has_user(before): ch.change_nick(before, after) @@ -177,7 +184,7 @@ class SingleServerIRCBot(SimpleIRCClient): def _on_quit(self, c, e): """[Internal]""" nick = nm_to_n(e.source()) - for ch in self.channels.values(): + for ch in list(self.channels.values()): if ch.has_user(nick): ch.remove_user(nick) @@ -283,8 +290,6 @@ class IRCDict: del self.canon_keys[ck] def __iter__(self): return iter(self.data) - def __contains__(self, key): - return self.has_key(key) def clear(self): self.data.clear() self.canon_keys.clear() @@ -294,15 +299,15 @@ class IRCDict: import copy return copy.copy(self) def keys(self): - return self.data.keys() + return list(self.data.keys()) def items(self): - return self.data.items() + return list(self.data.items()) def values(self): - return self.data.values() + return list(self.data.values()) def has_key(self, key): return irc_lower(key) in self.canon_keys def update(self, dict): - for k, v in dict.items(): + for k, v in list(dict.items()): self.data[k] = v def get(self, key, failobj=None): return self.data.get(key, failobj) @@ -322,16 +327,16 @@ class Channel: def users(self): """Returns an unsorted list of the channel's users.""" - return self.userdict.keys() + return list(self.userdict.keys()) def opers(self): """Returns an unsorted list of the channel's operators.""" - return self.operdict.keys() + return list(self.operdict.keys()) def voiced(self): """Returns an unsorted list of the persons that have voice mode set in the channel.""" - return self.voiceddict.keys() + return list(self.voiceddict.keys()) def has_user(self, nick): """Check whether the channel has a user.""" diff --git a/irclib.py b/irclib.py index 5f7141c..3499298 100644 --- a/irclib.py +++ b/irclib.py @@ -207,8 +207,8 @@ class IRC: incoming data, if there are any. If that seems boring, look at the process_forever method. """ - sockets = map(lambda x: x._get_socket(), self.connections) - sockets = filter(lambda x: x != None, sockets) + sockets = [x._get_socket() for x in self.connections] + sockets = [x for x in sockets if x != None] if sockets: (i, o, e) = select.select(sockets, [], [], timeout) self.process_data(i) @@ -342,7 +342,7 @@ class Connection: self.irclibobj = irclibobj def _get_socket(): - raise IRCError, "Not overridden" + raise IRCError("Not overridden") ############################## ### Convenience wrappers. @@ -433,10 +433,10 @@ class ServerConnection(Connection): self.socket.connect((self.server, self.port)) if ssl: self.ssl = socket.ssl(self.socket) - except socket.error, x: + except socket.error as x: self.socket.close() self.socket = None - raise ServerConnectionError, "Couldn't connect to socket: %s" % x + raise ServerConnectionError("Couldn't connect to socket: %s" % x) self.connected = 1 if self.irclibobj.fn_to_add_socket: self.irclibobj.fn_to_add_socket(self.socket) @@ -491,7 +491,7 @@ class ServerConnection(Connection): new_data = self.ssl.read(2**14) else: new_data = self.socket.recv(2**14) - except socket.error, x: + except socket.error as x: # The server hung up. self.disconnect("Connection reset by peer") return @@ -500,14 +500,14 @@ class ServerConnection(Connection): self.disconnect("Connection reset by peer") return - lines = _linesep_regexp.split(self.previous_buffer + new_data) + lines = _linesep_regexp.split(self.previous_buffer + new_data.decode()) # Save the last, unfinished line. self.previous_buffer = lines.pop() for line in lines: if DEBUG: - print "FROM SERVER:", line + print("FROM SERVER:", line) if not line: continue @@ -561,7 +561,7 @@ class ServerConnection(Connection): command = "privnotice" for m in messages: - if type(m) is types.TupleType: + if type(m) is tuple: if command in ["privmsg", "pubmsg"]: command = "ctcp" else: @@ -569,15 +569,15 @@ class ServerConnection(Connection): m = list(m) if DEBUG: - print "command: %s, source: %s, target: %s, arguments: %s" % ( - command, prefix, target, m) + print("command: %s, source: %s, target: %s, arguments: %s" % ( + command, prefix, target, m)) self._handle_event(Event(command, prefix, target, m)) if command == "ctcp" and m[0] == "ACTION": self._handle_event(Event("action", prefix, target, m[1:])) else: if DEBUG: - print "command: %s, source: %s, target: %s, arguments: %s" % ( - command, prefix, target, [m]) + print("command: %s, source: %s, target: %s, arguments: %s" % ( + command, prefix, target, [m])) self._handle_event(Event(command, prefix, target, [m])) else: target = None @@ -595,8 +595,8 @@ class ServerConnection(Connection): command = "umode" if DEBUG: - print "command: %s, source: %s, target: %s, arguments: %s" % ( - command, prefix, target, arguments) + print("command: %s, source: %s, target: %s, arguments: %s" % ( + command, prefix, target, arguments)) self._handle_event(Event(command, prefix, target, arguments)) def _handle_event(self, event): @@ -660,7 +660,7 @@ class ServerConnection(Connection): try: self.socket.close() - except socket.error, x: + except socket.error as x: pass self.socket = None self._handle_event(Event("disconnect", self.server, "", [message])) @@ -743,7 +743,7 @@ class ServerConnection(Connection): def part(self, channels, message=""): """Send a PART command.""" - if type(channels) == types.StringType: + if type(channels) == bytes: self.send_raw("PART " + channels + (message and (" " + message))) else: self.send_raw("PART " + ",".join(channels) + (message and (" " + message))) @@ -782,15 +782,16 @@ class ServerConnection(Connection): The string will be padded with appropriate CR LF. """ if self.socket is None: - raise ServerNotConnectedError, "Not connected." + raise ServerNotConnectedError("Not connected.") try: + string += "\r\n" if self.ssl: - self.ssl.write(string + "\r\n") + self.ssl.write(string.encode()) else: - self.socket.send(string + "\r\n") + self.socket.send(string.encode()) if DEBUG: - print "TO SERVER:", string - except socket.error, x: + print("TO SERVER:", string) + except socket.error as x: # Ouch! self.disconnect("Connection reset by peer.") @@ -888,8 +889,8 @@ class DCCConnection(Connection): self.passive = 0 try: self.socket.connect((self.peeraddress, self.peerport)) - except socket.error, x: - raise DCCConnectionError, "Couldn't connect to socket: %s" % x + except socket.error as x: + raise DCCConnectionError("Couldn't connect to socket: %s" % x) self.connected = 1 if self.irclibobj.fn_to_add_socket: self.irclibobj.fn_to_add_socket(self.socket) @@ -913,8 +914,8 @@ class DCCConnection(Connection): self.socket.bind((socket.gethostbyname(socket.gethostname()), 0)) self.localaddress, self.localport = self.socket.getsockname() self.socket.listen(10) - except socket.error, x: - raise DCCConnectionError, "Couldn't bind socket: %s" % x + except socket.error as x: + raise DCCConnectionError("Couldn't bind socket: %s" % x) return self def disconnect(self, message=""): @@ -930,7 +931,7 @@ class DCCConnection(Connection): self.connected = 0 try: self.socket.close() - except socket.error, x: + except socket.error as x: pass self.socket = None self.irclibobj._handle_event( @@ -947,8 +948,8 @@ class DCCConnection(Connection): self.socket = conn self.connected = 1 if DEBUG: - print "DCC connection from %s:%d" % ( - self.peeraddress, self.peerport) + print("DCC connection from %s:%d" % ( + self.peeraddress, self.peerport)) self.irclibobj._handle_event( self, Event("dcc_connect", self.peeraddress, None, None)) @@ -956,7 +957,7 @@ class DCCConnection(Connection): try: new_data = self.socket.recv(2**14) - except socket.error, x: + except socket.error as x: # The server hung up. self.disconnect("Connection reset by peer") return @@ -985,11 +986,11 @@ class DCCConnection(Connection): target = None for chunk in chunks: if DEBUG: - print "FROM PEER:", chunk + print("FROM PEER:", chunk) arguments = [chunk] if DEBUG: - print "command: %s, source: %s, target: %s, arguments: %s" % ( - command, prefix, target, arguments) + print("command: %s, source: %s, target: %s, arguments: %s" % ( + command, prefix, target, arguments)) self.irclibobj._handle_event( self, Event(command, prefix, target, arguments)) @@ -1009,8 +1010,8 @@ class DCCConnection(Connection): if self.dcctype == "chat": self.socket.send("\n") if DEBUG: - print "TO PEER: %s\n" % string - except socket.error, x: + print("TO PEER: %s\n" % string) + except socket.error as x: # Ouch! self.disconnect("Connection reset by peer.") @@ -1181,10 +1182,6 @@ def mask_matches(nick, mask): r = re.compile(mask, re.IGNORECASE) return r.match(nick) -_special = "-[]\\`^{}" -nick_characters = string.ascii_letters + string.digits + _special -_ircstring_translation = string.maketrans(string.ascii_uppercase + "[]\\^", - string.ascii_lowercase + "{}|~") def irc_lower(s): """Returns a lowercased string. @@ -1192,7 +1189,12 @@ def irc_lower(s): The definition of lowercased comes from the IRC specification (RFC 1459). """ - return s.translate(_ircstring_translation) + s = s.lower() + s = s.replace("[", "{") + s = s.replace("]", "}") + s = s.replace("\\", "|") + s = s.replace("^", "~") + return s def _ctcp_dequote(message): """[Internal] Dequote a message according to CTCP specifications. @@ -1259,16 +1261,16 @@ def ip_numstr_to_quad(num): """Convert an IP number as an integer given in ASCII representation (e.g. '3232235521') to an IP address string (e.g. '192.168.0.1').""" - n = long(num) - p = map(str, map(int, [n >> 24 & 0xFF, n >> 16 & 0xFF, - n >> 8 & 0xFF, n & 0xFF])) + n = int(num) + p = list(map(str, list(map(int, [n >> 24 & 0xFF, n >> 16 & 0xFF, + n >> 8 & 0xFF, n & 0xFF])))) return ".".join(p) def ip_quad_to_numstr(quad): """Convert an IP address string (e.g. '192.168.0.1') to an IP number as an integer given in ASCII representation (e.g. '3232235521').""" - p = map(long, quad.split(".")) + p = list(map(int, quad.split("."))) s = str((p[0] << 24) | (p[1] << 16) | (p[2] << 8) | p[3]) if s[-1] == "L": s = s[:-1] @@ -1557,4 +1559,4 @@ protocol_events = [ "pong", ] -all_events = generated_events + protocol_events + numeric_events.values() +all_events = generated_events + protocol_events + list(numeric_events.values()) diff --git a/logbot.py b/logbot.py index cf0177a..2901194 100755 --- a/logbot.py +++ b/logbot.py @@ -59,6 +59,7 @@ from pullrequest import PullRequest from Db import * from flask import * import threading +from commands import Commands pat1 = re.compile(r"(^|[\n ])(([\w]+?://[\w\#$%&~.\-;:=,?@\[\]+]*)(/[\w\#$%&~/.\-;:=,?@\[\]+]*)?)", re.IGNORECASE | re.DOTALL) @@ -79,12 +80,12 @@ def urlify2(value): DEBUG = False # IRC Server Configuration -SERVER = "irc.freenode.net" -PORT = 6667 -SERVER_PASS = None -CHANNELS=["#pelux"] -NICK = "pelux" -NICK_PASS = "" +SERVER = os.getenv("IRC_SERVER", "irc.freenode.net") +PORT = os.getenv("IRC_PORT", 6667) +SERVER_PASS = os.getenv("IRC_SERVER_PASS", None) +CHANNELS = os.getenv("IRC_CHANNELS", "#pelux").split(",") +NICK = os.getenv("IRC_NICK", "pelux") +NICK_PASS = os.getenv("IRC_NICK_PASS", "") # The local folder to save logs LOG_FOLDER = "/var/www/html/" @@ -115,13 +116,13 @@ def search(channel = None, nickname = None): try: channel = Channel.get(Channel.name == channel) messages = LogMessage.select() \ - .where(LogMessage.channel == channel and \ + .where(LogMessage.channel == channel, \ LogMessage.message.contains(query)) except: pass # No such channel elif nickname: messages = LogMessage.select() \ - .where(LogMessage.nickname == nickname and \ + .where(LogMessage.nickname == nickname, \ LogMessage.message.contains(query)) else: messages = LogMessage.select() \ @@ -135,13 +136,13 @@ def search(channel = None, nickname = None): @flaskapp.route("/channels//") @flaskapp.route("/channels///") -def channel(channel, day = None, query = None): +def channel(channel, day = None): channel = Channel.get(Channel.name == channel) if day: d = Day.get(Day.date == day) messages = LogMessage.select() \ - .where(LogMessage.day == d and \ + .where(LogMessage.day == d, \ LogMessage.channel == channel) return render_template("messages.html", @@ -164,18 +165,17 @@ def channels(): def append_line(filename, line): data = open(filename, "rb").readlines()[:-2] - data += [line, "\n", "\n", "\n"] + data += [line.encode(), "\n".encode(), "\n".encode(), "\n".encode()] write_lines(filename, data) def write_lines(filename, lines): - f = open(filename, "wb") - f.writelines(lines) - f.close() + with open(filename, "wb") as f: + f.writelines(lines) + def write_string(filename, string): - f = open(filename, "wb") - f.write(string) - f.close() + with open(filename, "wb") as f: + f.write(string.encode()) color_pattern = re.compile(r'(\[\d{1,2}m)') "Pattern that matches ANSI color codes and the text that follows" @@ -204,16 +204,17 @@ class Logbot(SingleServerIRCBot): self.chans = [x.lower() for x in channels] self.set_ftp() self.nick_pass = nick_pass + self.commands = Commands() - print "Logbot %s" % __version__ - print "Connecting to %s:%i..." % (server, port) - print "Press Ctrl-C to quit" + print("Logbot %s" % __version__) + print("Connecting to %s:%i..." % (server, port)) + print("Press Ctrl-C to quit") def quit(self): self.connection.disconnect("Quitting...") def color(self, user): - return "#%s" % md5(user).hexdigest()[:6] + return "#%s" % md5(user.encode()).hexdigest()[:6] def set_ftp(self, ftp=None): self.ftp = ftp @@ -223,6 +224,7 @@ class Logbot(SingleServerIRCBot): if event_name == "nick": message = params["new"] + target = params["chan"] elif event_name == "kick": message = "%s kicked %s from %s. Reason: %s" % (nm_to_n(params["kicker"]), params["user"], params["channel"], params["reason"]) @@ -257,7 +259,7 @@ class Logbot(SingleServerIRCBot): def on_all_raw_messages(self, c, e): """Display all IRC connections in terminal""" - if DEBUG: print e.arguments()[0] + if DEBUG: print(e.arguments()[0]) def on_welcome(self, c, e): """Join channels after successful connection""" @@ -320,6 +322,7 @@ class Logbot(SingleServerIRCBot): def on_pubmsg(self, c, e): # if e.arguments()[0].startswith(NICK): # c.privmsg(e.target(), self.format["help"]) + self.commands.process(c, e) self.write_event("pubmsg", e) def on_pubnotice(self, c, e): @@ -327,7 +330,7 @@ class Logbot(SingleServerIRCBot): def on_privmsg(self, c, e): # c.privmsg(nm_to_n(e.source()), self.format["help"]) - pas + pass def on_quit(self, c, e): nick = nm_to_n(e.source()) @@ -340,7 +343,7 @@ class Logbot(SingleServerIRCBot): self.write_event("topic", e) def connect_ftp(): - print "Using FTP %s..." % (FTP_SERVER) + print("Using FTP %s..." % (FTP_SERVER)) f = ftplib.FTP(FTP_SERVER, FTP_USER, FTP_PASS) f.cwd(FTP_FOLDER) return f @@ -350,6 +353,7 @@ def main(): create_tables() t = threading.Thread(target=flaskapp.run, kwargs={"host": "0.0.0.0"}) + t.daemon = True t.start() # Create the logs directory @@ -368,7 +372,6 @@ def main(): except KeyboardInterrupt: if FTP_SERVER: bot.ftp.quit() bot.quit() - t.join() if __name__ == "__main__": diff --git a/pullrequest.py b/pullrequest.py index 3d003a9..8fac4ab 100644 --- a/pullrequest.py +++ b/pullrequest.py @@ -69,7 +69,7 @@ class PullRequest: for repo in self.repos: r = requests.get(repo["uri"]) if r.status_code != 200: - print "Error fetching %s", repo["name"] + print("Error fetching %s", repo["name"]) break prs = r.json() @@ -83,4 +83,4 @@ class PullRequest: if __name__ == "__main__": p = PullRequest() for line in p.check_all(): - print line["message"] + print(line["message"])