====== PlayGround ====== H a i forkb0t/forkb0t.py extern> http://www.gentoo-pr0n.org/code/forkb0t/forkb0t.py #!/usr/bin/env python # -*- coding: utf-8 -*- ### # plugger.py # Part of forkb0t ### # Copyright (C) 2008-2010, Ken Rushia (krushia), forkb0t@kenrushia.com # Copyright (C) 2008, Kenneth Prugh (Ken69267), ken69267@gentoo.org # Inspired by http://www.oreilly.com/pub/h/1968#code # # 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 under version 2 of the license. # # 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. ### import sys import trace import traceback import subprocess import time import ConfigParser import collections import linecache import textwrap import os import itertools #Example plugin import if one was to put it up here # from plugins.gigablast import gigablast # TODO: # Make namedtuple usage cleaner (always use names rather than positional assignment, and try to remove redundantcy) # Add defaults and make all options optional # Sanity checks on data # Is there any reason for using namedtuple, besides converting to py types? class pluggar: def __init__(self, configfile): plugconf = ConfigParser.SafeConfigParser({'shblacklist': 'False', 'shgreylist': 'True', 'prefixnick': 'True', 'input': 'terms', 'debug': 'False'}) plugconf.read(configfile) self.plugwords = {} self.functable = {} self.plugalways = [] FuncOptionTuple = collections.namedtuple('FuncOptionTuple', 'shblacklist shgreylist prefixnick input debug') for i in plugconf.sections(): for n in plugconf.get(i,'keywords').split(): if n is not '*': self.plugwords[n] = i else: self.plugalways.append(i) self.functable[i] = FuncOptionTuple(shblacklist=plugconf.getboolean(i,'shblacklist'),shgreylist=plugconf.getboolean(i,'shgreylist'),prefixnick=plugconf.getboolean(i,'prefixnick'),input=plugconf.get(i,'input'),debug=plugconf.getboolean(i,'debug')) class pluggerThread: def __init__(self): self.online = [] # not correct... should be in network data self.pingychan = "#b0tcage" CAPAB_IDENTIFY_MSG = False self.identified = False self.out = '' def run(self, lmsg, net, name, pconf, zomgthefiles, db): self.out = '' self.pconf = pconf self.zomgthefiles = zomgthefiles self.Options = net self.name = name self.network = name #default to output same network self.msg = self.decode(lmsg) # Optimization globals... use instead of functions if possible self.msgtype, self.chan, self.nick, self.msgtext = self.findAll(self.msg) CAPAB_IDENTIFY_MSG = True # temporary, till we get data stuff if self.msgtype == '290': if "IDENTIFY-MSG" in self.msg: CAPAB_IDENTIFY_MSG = True self.identified = False # reset here every scan if CAPAB_IDENTIFY_MSG: if self.msgtype in ['PRIVMSG', 'NOTICE']: if self.msgtext[:1] in ["+", "-"]: if self.msgtext[:1] == "+": self.identified = True self.msgtext = self.msgtext[1:] # Somewhat out of place but critical... respond to ping sex from freenode if self.msgtype == 'PING': self.sendRaw("PONG %s\r\n" %self.msg.partition(':')[2]) # HAX - 2nd half of !names... read response fron nickserv # 353 RPL_NAMREPLY - defined in RFC1459 and RFC2812 # msgtext is same in both definitions, but header varies elif self.msgtype == '353': self.online = [] for name in self.msgtext.split(): if name.startswith('@') or name.startswith('+'): name = name[1:] if name not in self.online: self.online.append(name) # When someone leaves the channel, remove from online list elif self.msgtype in ['PART', 'QUIT']: if self.nick in self.online: self.online.remove(self.nick) # If someone talks, they must be online. Add them to online list if missing. #elif self.msgtype == 'NOTICE': # if self.msgtext.startswith("\001PING"): # try: # self.say(self.pingychan, "Total latency me -> " + self.chan + " -> me == " + str(int(time.time()*1000)-int(self.msgtext.split()[1].partition("\001")[0])) + " ms") # except: # pass elif self.msgtype == 'PRIVMSG': # If someone talks, they must be online. Add them to online list if missing. if self.nick not in self.online: self.online.append(self.nick) ######################################## # BEGIN PRIVMSG INTERNAL COMMAND CHECK # ######################################## # CTCP responses if self.msgtext.startswith("\001"): # chan == nick makes sure we do not send replies to channel # CTCP spammers. if "\001ACTION" not in self.msgtext and self.chan == self.nick: if not self.msgtext.endswith("\001"): self.spam("WARNING: CTCP from "+self.nick+"possibly malformed (doesn't end with \\001) - parsing anyway") if self.msgtext.startswith("\001CLIENTINFO"): self.sendRaw("NOTICE " + self.nick + " :\001CLIENTINFO ACTION CLIENTINFO PING TIME URL USERINFO VERSION\001\r\n") elif self.msgtext.startswith("\001PING"): self.sendRaw("NOTICE " + self.nick + " :" + self.msgtext + "\r\n") elif self.msgtext.startswith("\001TIME"): # Returned in RFC 2822 format per http://www.invlogic.com/irc/ctcp.html # well.. technically it calls for RFC 822, which differs by using 2-digit year # however, it seems most IRC clients use 4-digit, so we should be safe # ... # On the other hand, irssi and Konversation return a different format, # http://www.irchelp.org/irchelp/rfc/ctcpspec.html but sans timezone! self.sendRaw("NOTICE " + self.nick + " :\001TIME " +time.strftime("%a, %d %b %Y %H:%M:%S %z")+"\001\r\n") elif self.msgtext.startswith("\001URL"): self.sendRaw("NOTICE " + self.nick + " :\001URL http://www.gentoo-pr0n.org/forkb0t:forkb0t\001\r\n") elif self.msgtext.startswith("\001USERINFO"): self.sendRaw("NOTICE " + self.nick + " :\001USERINFO A friendly b0t based in the #gentoo-pr0n channel on Freenode. Owner is krushia on IRC, who can also be contacted at forkb0t@kenrushia.com\001\r\n") elif self.msgtext.startswith("\001VERSION"): # Note we go by http://www.invlogic.com/irc/ctcp.html # somewhat different than http://www.irchelp.org/irchelp/rfc/ctcpspec.html # also we don't quote self.sendRaw("NOTICE " + self.nick + " :\001VERSION forkb0t 0.1 - by krushia\001\r\n") elif self.msgtext.startswith("\001DCC CHAT"): a, b, c = self.msgtext.partition('CHAT')[2].strip().split() self.spam("GOT CTCP DCC CHAT from "+self.nick+". protocol: "+a+" ip: "+self.unDccIP(int(b))+" port: "+c) else: self.spam("Unknown CTCP command... "+self.msgtext.partition("\001")[2].partition("\001")[0]) # HAX - !rawforksay command... should be converted to plugin elif self.msgtext.startswith("!rawforksay"): try: if self.identified and self.nick == self.Options['master']: terms = self.msg.partition('!rawforksay ')[2] self.sendRaw(terms+'\r\n') except: self.spam("rawforksay failure", True) # HAX - !rawforksay command... should be converted to plugin elif self.msgtext.startswith("!netstat"): try: if self.identified and self.nick == self.Options['master']: self.network = self.msg.partition('!netstat ')[2] self.sendRaw('LINKS\r\n') return except: self.spam("netstat failure", True) # !forkhelp can't be a plugin because plugins can't access the plugin list elif self.msgtext.startswith("!forkhelp"): if self.msgtext.strip() != '!forkhelp': try: temp = self.msgtext.split()[1] if temp in self.pconf.plugwords: self.msgtext=str(temp)+' --help' else: self.say(self.chan, '''You're a dumbass''') except: pass else: try: self.say(self.chan, '''I know the following commands. To get help for a specific command, type "!command --help"''') plugwords = [] for i in self.pconf.plugwords: plugwords.append(i) plugwords.sort() self.say(self.chan, ' '.join(plugwords)) #for i in plugwords: # helptext = helptext + i + " " #helptext = helptext + """\nThere are some cute operators:\n"!command >>> user" produces "user: output"\n"!command >>>> channel" pipes output to channel\n"!command1 " excTb = traceback.format_tb(trbk, maxTBlevel) return (excName, excArgs, excTb) # Reloads plugin modules def replug(self, plugin=''): plugincount = 0 errors = 0 if plugin == '': repluglist = self.pconf.functable else: repluglist = [str(plugin)] for pluginText in repluglist: plugincount += 1 # To update line numbers linecache.checkcache("plugins/"+pluginText+"/"+pluginText+".py") if self.loadPlugin(pluginText, doreload=True) is None: errors += 1 linecache.checkcache() if errors == 0: return "Successfully replugged " + str(plugincount) + " plugin(s)" else: return "Replug attempt had " + str(errors) + " error(s). Details in " + self.Options['debugchannel'] def loadPlugin(self, plugin, function='', doreload=False): if function == '': function = plugin try: # Will return plugins.foo, where foo is a package # On filesystem, this is a directory ./plugins/foo opackage = __import__("plugins." + plugin, globals(), locals(), [plugin], -1) except: self.spam("Pluggar fails at finding package for plugin " + plugin, True) return None if doreload: try: reload(opackage) except: self.spam("Pluggar failed to reload package for plugin " + plugin, True) return None try: # Will return plugins.foo.foo, where foo is a module # On filesystem, this is a file ./plugins/foo/foo.py omodule = getattr(opackage, plugin) except: self.spam("Pluggar fails at finding module for plugin " + plugin, True) return None if doreload: try: reload(omodule) except: self.spam("Pluggar failed to reload module for plugin " + plugin, True) return None try: # Will return plugins.foo.foo.bar, where bar is a function # On filesystem, this is the function bar() in ./plugins/foo/foo.py ofunction = getattr(omodule, function) return ofunction except: self.spam("Pluggar fails at finding function named " + function + " in module for plugin " + plugin, True) return None # New complete message parser # Replaces findType(), findChannel(), and findNick() def findAll(self, msg): headerdict = {} if msg.startswith(':'): splitmsg = msg.split(None, 2) prefix = splitmsg[0][1:] if '!' in prefix: nickname, temp = prefix.split('!') user, host = temp.split('@') rnick = nickname # remove when forky gets smart elif '@' in prefix: nickname, host = prefix.split('@') rnick = nickname # remove when forky gets smart else: servername = prefix rnick = servername # remove when forky gets smart splitmsg.pop(0) else: splitmsg = msg.split(None, 1) rnick = 'dumb0t' # remove when forky gets smart command = splitmsg[0] rmsgtext = splitmsg[1] # remove when forky gets smart if ':' in splitmsg[1]: a, b, c = splitmsg[1].partition(':') if a == '': params = [c] else: params = a.split() if c != '': params.append(c) else: params = splitmsg[1].split() # A note on the naming of paramdict entries... # While writing this function, I had the RFC open and was making # sure that it was followed 100%. To this end, I decided to use the # exact naming from the RFC for parameters to commands. # ... it seemed like a good idea at first ... # However, it turns out that the RFC was written by those who aren't # gifted in the art of technical writing, as you can plainly see in # the code below. There is an annoying lack of consistency in format # of names. Most astounding are "text" for PRIVMSG and # "text to be sent" for NOTICE. Also confusion of "user" and "nickname" paramdict = {} # Not implemented: # OPER, SERVICE, SQUIT, NAMES, LIST, if command == 'PASS': # Not from server paramdict['password'] = params[0] elif command == 'NICK': paramdict['nickname'] = params[0] elif command == 'USER': # Not from server paramdict['user'] = params[0] paramdict['mode'] = params[1] paramdict['unused'] = params[2] paramdict['realname'] = params[3] elif command == 'MODE': pass #if params[0] == nickname: # Not from server # paramdict['nickname'] = params[0] # rest are a list of mode changes #if params[0] != nickname: # paramdict['channel'] = params[0] # rest is a bunch of stuff to uberparse elif command == 'QUIT': pass #paramdict['quit_message'] = params[0] # Note that quit message is optional # insert netsplit checks here elif command == 'JOIN': # Note we don't check lists, since RFC says servers should not return them paramdict['channel'] = params[0] elif command == 'PART': paramdict['channel'] = params[0] #paramdict['part_message'] = params[1] # Note that part message is optional elif command == 'TOPIC': paramdict['channel'] = params[0] paramdict['topic'] = params[1] elif command == 'INVITE': paramdict['nickname'] = params[0] paramdict['channel'] = params[1] elif command == 'KICK': paramdict['channel'] = params[0] paramdict['user'] = params[1] #paramdict['comment'] = params[2] # comment is optional elif command == 'PRIVMSG': paramdict['msgtarget'] = params[0] paramdict['text_to_send'] = params[1] rmsgtext = paramdict['text_to_send'] # remove when forky gets smart elif command == 'NOTICE': paramdict['msgtarget'] = params[0] paramdict['text'] = params[1] rmsgtext = paramdict['text'] # remove when forky gets smart elif command == 'PING': paramdict['server1'] = params[0] # we don't check for server2 elif command == 'ERROR': #should only get when connection is terminated paramdict['error_message'] = params[0] try: # remove when forky gets smart rchan = paramdict['channel'] # remove when forky gets smart except: # remove when forky gets smart try: # remove when forky gets smart rchan = paramdict['msgtarget'] # remove when forky gets smart if self.Options['nick'] in rchan: # remove when forky gets smart rchan = rnick # remove when forky gets smart except: # remove when forky gets smart rchan = self.Options['debugchannel'] # remove when forky gets smart return command, rchan, rnick, rmsgtext # Print debuggin information for fails. The name makes sense if you monitor #b0tcage # NOTE: Might not need all the str() - they are there from transition from %s def spam(self, text, trace=False, fulltrace=False): self.sendRaw("PRIVMSG " + self.Options['debugchannel'] + " :" + str(text) + "\r\n") if trace: try: e, a, t = self.formatExceptionInfo() self.sendRaw("PRIVMSG "+self.Options['debugchannel']+" : "+ str(e) +" --- "+str(a)+"\r\n") for line in t: # NOTE: line isn't really line, it is a bt point. Needs work. self.sendRaw("PRIVMSG " + self.Options['debugchannel'] + " : " + str(line).splitlines()[0] + "\r\n") if ( not fulltrace ) and ( "forkb0t" not in line ): # stop after the backtrace leaves our source time.sleep(3) # should remove... proper socket level flood control instead return except: self.sendRaw("PRIVMSG "+self.Options['debugchannel']+" : FAILED TO GET TRACEBACK\r\n") # Create a raw IRC PRIVMSG line that sends text to destination # TODO: Properly handle text with multiple lines def say(self, destination, text, prefix=""): for line in text.splitlines(): if not line: continue command = "PRIVMSG" lprefix = prefix ldestination = destination # ACTION method is depreciated # Make sure pandas don't use it in new plugins! if line.startswith("ACTION"): lprefix = "" lbody = "\001ACTION" + line.partition('ACTION')[2] + "\001" elif line.startswith("/me"): lprefix = "" lbody = "\001ACTION" + line.partition('/me')[2] + "\001" elif line.startswith("/notice"): command = "NOTICE" lprefix = "" ldestination = line.split()[1] lbody = line.partition(ldestination)[2].strip() elif line.startswith("/ctcp"): lprefix = "" ldestination = line.split()[1] lbody = "\001" + line.partition(ldestination)[2].strip() + "\001" elif line.startswith("/nctcp"): command = "NOTICE" lprefix = "" ldestination = line.split()[1] lbody = "\001" + line.partition(ldestination)[2].strip() + "\001" # note, /msg code should be a function? elif line.startswith("/msg"): prefix = "" lprefix = "" destination = line.split()[1] ldestination = destination lbody = line.partition(ldestination)[2].strip() elif line.startswith("/macro"): continue elif line.startswith("/core"): if 'replug' in line.partition('/core')[2]: if len(line.split()) >= 3: lbody = self.replug(line.split()[2]) else: lbody = self.replug('') else: self.spam('A plugin specified invalid /core call: ' + line.partition('/core')[2]) return elif line.startswith("/say"): lbody = line.partition('/say')[2].lstrip() else: lbody = line if "" != lprefix != ldestination and ldestination.startswith("#"): lprefix += ": " else: lprefix = "" # The True added at end cuz too lazy to add superuser bypass here if ldestination in self.online or ldestination in self.Options['channels'].split() or True: a = command + " " + ldestination + " :" + lprefix for z in textwrap.wrap(lbody,512-(len(a)+3+36)): tosend = a + z + "\r\n" self.sendRaw(tosend.encode('utf-8')) else: self.spam('A plugin tried to /msg invalid target: ' + ldestination) return def sendRaw(self, msg): self.out+=msg # TODO: I have no idea where this was headed... def report(self, msg): rtime = str(int(time.time()+1000.0)) self.spam('Autogenerating bug report...') import sys rfile = open('report_'+rtime+'.txt', 'a', 0) rfile.write('Report\n') rfile.write('\n\nLoaded Modules\n') for i in sys.modules: rfile.write('%s\n'%i) # Ugly shell exploit checker. Really needs to be abstracted better. def checkHaxors(self, lmsgtext, black, grey): if black: for i in ["$", "`", "|", ";", "&", "~", "<<", ">>", '\'\'', '\"', '\\']: if i in lmsgtext: return "This command doesn't allow " + i + " for safety reasons." if grey: for i in [">", "<", "&", "%", "*", "/"]: if i in lmsgtext: return "This command is configured for stringent safety checks. The character(s) " + i + " are not allowed." return False def doTopPlug(self, plugin, lmsg, lmsgtext, keyword): fromNick = self.nick fromChan = self.chan exportTo = self.chan if self.pconf.functable[plugin].prefixnick: writeTo = self.nick else: writeTo = "" superuser = False master = False if self.identified: if fromNick in self.Options['superusers'].split(): superuser = True if fromNick == self.Options['master']: master = True # 1 ONE-TIME PARSE PER MSG # Parse redirection operators if keyword is not "*": if '>>>>>' in lmsgtext: self.network = lmsg.partition('>>>>>')[2].strip() lmsg = lmsg.partition('>>>>>')[0].strip() lmsgtext = lmsgtext.partition('>>>>>')[0].strip() if '>>>>' in lmsgtext: # note that the validation here is redundant... see self.say() if lmsg.partition('>>>>')[2].strip() in self.online or lmsg.partition('>>>>')[2].strip() in self.Options['channels'].split() or superuser: exportTo = lmsg.partition('>>>>')[2].strip() lmsg = lmsg.partition('>>>>')[0].strip() lmsgtext = lmsgtext.partition('>>>>')[0].strip() writeTo = "" else: self.say(fromChan, '''I either don't know or am not allowed to redirect to ''' + lmsg.partition('>>>>')[2].strip()) self.spam('Failed an exportTo') return if '>>>' in lmsgtext: writeTo = lmsg.partition('>>>')[2].strip() lmsg = lmsg.partition('>>>')[0].strip() lmsgtext = lmsgtext.partition('>>>')[0].strip() # 2 INSERT PIPING AND COMMAND SUBSTITUTION HERE # 3 CODE RUNS FOR EVERY COMMAND (MAKE IT HAPPEN) # Set plugin environment variables # NOTE: This is code prevents us from threading msg parsing self.zomgthefiles.acquire() penv = ConfigParser.SafeConfigParser() penv.read('plugin.env') if not penv.has_section('msg'): penv.add_section('msg') penv.set('msg', 'network', self.name.encode('unicode_escape')) penv.set('msg', 'nick', fromNick.encode('unicode_escape')) penv.set('msg', 'channel', fromChan.encode('unicode_escape')) penv.set('msg', 'keyword', keyword.encode('unicode_escape')) penv.set('msg', 'plugin', plugin.encode('unicode_escape')) penv.set('msg', 'identified', str(self.identified).encode('unicode_escape')) # Note that following line was redundant with doPlugKeyword if keyword is not "*": # Changed from lstrip to partition, old code on next line # lmsgtext = lmsgtext.lstrip(keyword).strip() lmsgtext = lmsgtext.partition(keyword)[2].strip() # BUG: Usually get exceptions here when % in values try: penv.set('msg', 'terms', lmsgtext.encode('unicode_escape')) except: penv.set('msg', 'terms', ' '.encode('unicode_escape')) try: penv.set('msg', 'raw', lmsg.encode('unicode_escape')) except: penv.set('msg', 'raw', ' '.encode('unicode_escape')) penv.set('msg', 'superuser', str(superuser).encode('unicode_escape')) penv.set('msg', 'master', str(master).encode('unicode_escape')) with open('plugin.env', 'wb') as configfile: penv.write(configfile) # Run plugin output = self.doPlugKeyword(plugin, lmsg, lmsgtext, keyword) self.zomgthefiles.release() # 3.5 (?) PARSE OF OUTPUT FROM EACH COMMAND # 4 ONE-TIME PARSE OF FINAL OUTPUT # NOTE: Need to put a flood detector here if output is None: return # Apply personal preferences (noprefixnick) if writeTo == fromNick: parsedmyconf = ConfigParser.SafeConfigParser() parsedmyconf.read('parsed.my.conf') if parsedmyconf.has_section(fromNick): if parsedmyconf.has_option(fromNick, 'noprefixnick'): if parsedmyconf.getboolean(fromNick, 'noprefixnick'): writeTo = "" self.say(exportTo, output, writeTo) # This huge mutha is waht runs a plugin. It keeps getting bigger. WTF. def doPlugKeyword(self, plugin, raw, lmsgtext, keyword): grey = self.pconf.functable[plugin].shgreylist black = self.pconf.functable[plugin].shblacklist haxresult = self.checkHaxors(lmsgtext, black, grey) if haxresult: if keyword is not "*": return haxresult return try: if self.pconf.functable[plugin].input == "raw": return self.runPlugin(plugin, raw, keyword, self.pconf.functable[plugin].debug) else: return self.runPlugin(plugin, lmsgtext, keyword, self.pconf.functable[plugin].debug) except: # NOTE: This exception catches problems in runPlugin, NOT plugins themselves if keyword is not "*": self.say(self.chan, "I accidentally teh " + keyword + ". Details in " + self.Options['debugchannel']) self.spam("Failed doPlugKeyword for " + keyword + " (plugin " + plugin + ")", True) def runPlugin(self, plugin, text, keyword, debug): functext = plugin if keyword is not "*": # NOTE: Plugin help() is going to be removed in the future if text in ['-h', '-?', '--help']: functext = 'help' text = keyword func = self.loadPlugin(plugin, function=functext) if func is None: return if debug: command = "bash forkdev-debug-setup.sh " + plugin pipe = subprocess.Popen(command, stdout=subprocess.PIPE, shell=True).communicate()[0] tf = open(plugin+'.trace.txt', 'w', 0) sys.stdout = tf tracer = trace.Trace(ignoredirs=[sys.prefix, sys.exec_prefix], timing=True) try: if debug: funcout = tracer.runfunc(func, text) else: funcout = func(text) except: if keyword is not "*": self.say(self.chan, "I accidentally teh %s (plugin %s). Details in %s" %(keyword, plugin, self.Options['debugchannel'])) if debug: self.spam("Failed to run plugin %s (keyword %s) *EXTENDED DEBUGGING ENABLED*" %(plugin, keyword), True, True) else: self.spam("Failed to run plugin %s (keyword %s)" %(plugin, keyword), True) funcout = '' # to avoid second accident at next return if debug: sys.stdout = sys.__stdout__ command = "bash forkdev-debug-postrun.sh " + plugin pipe = subprocess.Popen(command, stdout=subprocess.PIPE, shell=True).communicate()[0] return self.decode(funcout) # Function borrowed from supybot ircutils.py # commit 6135a88741fcafa49bb2bd768cfc971cd7d58b5e def dccIP(self, ip): """Converts an IP string to the DCC integer form.""" i = 0 x = 256**3 for quad in ip.split('.'): i += int(quad)*x x /= 256 return i # Function borrowed from supybot ircutils.py # commit 6135a88741fcafa49bb2bd768cfc971cd7d58b5e def unDccIP(self, i): """Takes an integer DCC IP and return a normal string IP.""" L = [] while len(L) < 4: L.append(i % 256) i /= 256 L.reverse() return '.'.join(itertools.imap(str, L))