bot.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561
  1. #!/usr/bin/env python
  2. #
  3. # v0.7.1
  4. # works with python 2.6.x and 2.7.x
  5. #
  6. import sys
  7. import socket
  8. import string
  9. import os
  10. import datetime
  11. import time
  12. import select
  13. import traceback
  14. import threading
  15. import botbrain
  16. from logger import Logger
  17. import db
  18. import confman
  19. from event import Event
  20. import db
  21. import util
  22. DEBUG = False
  23. class Bot(threading.Thread):
  24. """
  25. bot instance. one bot gets instanciated per network, as an entirely distinct, sandboxed thread.
  26. handles the core IRC protocol stuff, and passing lines to defined events, which dispatch to their subscribed modules.
  27. """
  28. def __init__(self, conf=None, network=None, d=None):
  29. threading.Thread.__init__(self)
  30. self.DEBUG = d
  31. self.brain = None
  32. self.network = network
  33. self.OFFLINE = False
  34. self.CONNECTED = False
  35. self.JOINED = False
  36. self.conf = conf
  37. self.db = db.DB()
  38. self.pid = os.getpid()
  39. self.logger = Logger()
  40. self.NICK = self.conf.getNick(self.network)
  41. self.logger.write(Logger.INFO, "\n", self.NICK)
  42. self.logger.write(Logger.INFO, " initializing bot, pid " + str(os.getpid()), self.NICK)
  43. # arbitrary key/value store for modules
  44. # they should be 'namespaced' like bot.mem_store.module_name
  45. self.mem_store = dict()
  46. self.CHANNELINIT = conf.getChannels(self.network)
  47. # this will be the socket
  48. self.s = None # each bot thread holds its own socket open to the network
  49. self.brain = botbrain.BotBrain(self.send, self)
  50. self.events_list = list()
  51. # define events here and add them to the events_list
  52. all_lines = Event("1__all_lines__")
  53. all_lines.define(".*")
  54. self.events_list.append(all_lines)
  55. implying = Event("__implying__")
  56. implying.define(">")
  57. #command = Event("__command__")
  58. # this is an example of passing in a regular expression to the event definition
  59. #command.define("fo.bar")
  60. lastfm = Event("__.lastfm__")
  61. lastfm.define(".lastfm")
  62. dance = Event("__.dance__")
  63. dance.define("\.dance")
  64. #unloads = Event("__module__")
  65. #unloads.define("^\.module")
  66. pimp = Event("__pimp__")
  67. pimp.define("\.pimp")
  68. bofh = Event("__.bofh__")
  69. bofh.define("\.bofh")
  70. #youtube = Event("__youtubes__")
  71. #youtube.define("youtube.com[\S]+")
  72. weather = Event("__.weather__")
  73. weather.define("\.weather")
  74. steam = Event("__.steam__")
  75. steam.define("\.steam")
  76. part = Event("__.part__")
  77. part.define("part")
  78. tell = Event("__privmsg__")
  79. tell.define("PRIVMSG")
  80. links = Event("__urls__")
  81. links.define("https?://*")
  82. # example
  83. # test = Event("__test__")
  84. # test.define(msg_definition="^\.test")
  85. # add your defined events here
  86. # tell your friends
  87. self.events_list.append(lastfm)
  88. self.events_list.append(dance)
  89. self.events_list.append(pimp)
  90. #self.events_list.append(youtube)
  91. self.events_list.append(bofh)
  92. self.events_list.append(weather)
  93. self.events_list.append(steam)
  94. self.events_list.append(part)
  95. self.events_list.append(tell)
  96. self.events_list.append(links)
  97. self.time_event = Event("__time_test__")
  98. self.time_event.define(time_event=True)
  99. self.events_list.append(self.time_event)
  100. #self.events_list.append(unloads)
  101. # example
  102. # self.events_list.append(test)
  103. self.load_modules()
  104. self.logger.write(Logger.INFO, "bot initialized.", self.NICK)
  105. # conditionally subscribe to events list or add event to listing
  106. def register_event(self, event, module):
  107. """
  108. Allows for dynamic, asynchronous event creation. To be used by modules, mostly, to define their own events in their initialization.
  109. Prevents multiple of the same _type of event being registered.
  110. Args:
  111. event: an event object to be registered with the bot
  112. module: calling module; ensures the calling module can be subscribed to the event if it is not already.
  113. Returns:
  114. nothing.
  115. """
  116. for e in self.events_list:
  117. if e.definition == event.definition and e._type == event._type:
  118. # if our event is already in the listing, don't add it again, just have our module subscribe
  119. e.subscribe(module)
  120. return
  121. self.events_list.append(event)
  122. return
  123. # utility function for loading modules; can be called by modules themselves
  124. def load_modules(self, specific=None):
  125. """
  126. Run through the ${bot_dir}/modules directory, dynamically instantiating each module as it goes.
  127. Args:
  128. specific: string name of module. if it is specified, the function attempts to load the named module.
  129. Returns:
  130. 1 if successful, 0 on failure. In keeping with the perverse reversal of UNIX programs and boolean values.
  131. """
  132. nonspecific = False
  133. found = False
  134. self.loaded_modules = list()
  135. modules_dir_list = list()
  136. tmp_list = list()
  137. modules_path = 'modules'
  138. autoload_path = 'modules/autoloads'
  139. # this is magic.
  140. import inspect
  141. import os, imp, json
  142. dir_list = os.listdir(modules_path)
  143. mods = {}
  144. autoloads = {}
  145. #load autoloads if it exists
  146. if os.path.isfile(autoload_path):
  147. self.logger.write(Logger.INFO, "Found autoloads file", self.NICK)
  148. try:
  149. autoloads = json.load(open(autoload_path))
  150. #logging
  151. for k in autoloads.keys():
  152. self.logger.write(Logger.INFO, "Autoloads found for network " + k, self.NICK)
  153. except IOError:
  154. self.logger.write(Logger.ERROR, "Could not load autoloads file.",self.NICK)
  155. # create dictionary of things in the modules directory to load
  156. for fname in dir_list:
  157. name, ext = os.path.splitext(fname)
  158. if specific is None:
  159. nonspecific = True
  160. # ignore compiled python and __init__ files.
  161. #choose to either load all .py files or, available, just ones specified in autoloads
  162. if self.network not in autoloads.keys(): # if autoload does not specify for this network
  163. if ext == '.py' and not name == '__init__':
  164. f, filename, descr = imp.find_module(name, [modules_path])
  165. mods[name] = imp.load_module(name, f, filename, descr)
  166. self.logger.write(Logger.INFO, " loaded " + name + " for network " + self.network, self.NICK)
  167. else: # follow autoload's direction
  168. if ext == '.py' and not name == '__init__':
  169. if name == 'module':
  170. f, filename, descr = imp.find_module(name, [modules_path])
  171. mods[name] = imp.load_module(name, f, filename, descr)
  172. self.logger.write(Logger.INFO, " loaded " + name + " for network " + self.network, self.NICK)
  173. elif ('include' in autoloads[self.network] and name in autoloads[self.network]['include']) or ('exclude' in autoloads[self.network] and name not in autoloads[self.network]['exclude']):
  174. f, filename, descr = imp.find_module(name, [modules_path])
  175. mods[name] = imp.load_module(name, f, filename, descr)
  176. self.logger.write(Logger.INFO, " loaded " + name + " for network " + self.network, self.NICK)
  177. else:
  178. if name == specific: # we're reloading only one module
  179. if ext != '.pyc': # ignore compiled
  180. f, filename, descr = imp.find_module(name, [modules_path])
  181. mods[name] = imp.load_module(name, f, filename, descr)
  182. found = True
  183. for k,v in mods.iteritems():
  184. for name in dir(v):
  185. obj = getattr(mods[k], name) # get the object from the namespace of 'mods'
  186. try:
  187. if inspect.isclass(obj): # it's a class definition, initialize it
  188. a = obj(self.events_list, self.send, self, self.say) # now we're passing in a reference to the calling bot
  189. if a not in self.loaded_modules: # don't add in multiple copies
  190. self.loaded_modules.append(a)
  191. except TypeError:
  192. pass
  193. if nonspecific is True or found is True:
  194. return 0
  195. else: return 1
  196. # end magic.
  197. def send(self, message):
  198. """
  199. Simply sends the specified message to the socket. Which should be our connected server.
  200. We parse our own lines, as well, in case we want an event triggered by something we say.
  201. If debug is True, we also print out a pretty thing to console.
  202. Args:
  203. message: string, sent directly and without manipulation (besides UTF-8ing it) to the server.
  204. """
  205. if self.OFFLINE:
  206. self.debug_print(util.bcolors.YELLOW + " >> " + util.bcolors.ENDC + self.getName() + ": " + message.encode('utf-8', 'ignore'))
  207. else:
  208. if self.DEBUG is True:
  209. self.logger.write(Logger.INFO, str(datetime.datetime.now()) + ": " + self.getName() + ": " + message.encode('utf-8', 'ignore'), self.NICK)
  210. self.debug_print(util.bcolors.OKGREEN + ">> " + util.bcolors.ENDC + ": " + self.getName() + ": " + message.encode('utf-8', 'ignore'))
  211. self.s.send(message.encode('utf-8', 'ignore'))
  212. target = message.split()[1]
  213. if target.startswith("#"):
  214. self.processline(':' + self.conf.getNick(self.network) + '!~' + self.conf.getNick(self.network) + '@fakehost.here ' + message.rstrip())
  215. def pong(self, response):
  216. """
  217. Keepalive heartbeat for IRC protocol. Until someone changes the IRC spec, don't modify this.
  218. """
  219. self.send('PONG ' + response + '\n')
  220. def processline(self, line):
  221. """
  222. Grab newline-delineated lines sent to us, and determine what to do with them.
  223. This function handles our initial low-level IRC stuff, as well; if we haven't joined, it waits for the MOTD message (or message indicating there isn't one) and then issues our own JOIN calls.
  224. Also immediately passes off PING messages to PONG.
  225. Args:
  226. line: string.
  227. """
  228. if self.DEBUG:
  229. import datetime
  230. self.debug_print(util.bcolors.OKBLUE + "<< " + util.bcolors.ENDC + ": " + self.getName() + ": " + line)
  231. message_number = line.split()[1]
  232. try:
  233. for e in self.events_list:
  234. if e.matches(line):
  235. e.notifySubscribers(line)
  236. # don't bother going any further if it's a PING/PONG request
  237. if line.startswith("PING"):
  238. ping_response_line = line.split(":", 1)
  239. self.pong(ping_response_line[1])
  240. # pings we respond to directly. everything else...
  241. else:
  242. # patch contributed by github.com/thekanbo
  243. if self.JOINED is False and (message_number == "376" or message_number == "422"):
  244. # wait until we receive end of MOTD before joining, or until the server tells us the MOTD doesn't exis
  245. self.chan_list = self.conf.getChannels(self.network)
  246. for c in self.chan_list:
  247. self.send('JOIN '+c+' \n')
  248. self.JOINED = True
  249. line_array = line.split()
  250. user_and_mask = line_array[0][1:]
  251. usr = user_and_mask.split("!")[0]
  252. channel = line_array[2]
  253. try:
  254. message = line.split(":",2)[2]
  255. self.brain.respond(usr, channel, message)
  256. except IndexError:
  257. try:
  258. message = line.split(":",2)[1]
  259. self.brain.respond(usr, channel, message)
  260. except IndexError:
  261. print "index out of range.", line
  262. except Exception:
  263. print "Unexpected error:", sys.exc_info()[0]
  264. traceback.print_exc(file=sys.stdout)
  265. def worker(self, mock=False):
  266. """
  267. Open the socket, make the first incision^H^H connection and get us on the server.
  268. Handles keeping the connection alive; if we disconnect from the server, attempts to reconnect.
  269. Args:
  270. mock: boolean. If mock is true, don't loop forever -- mock is for testing.
  271. """
  272. self.HOST = self.network
  273. self.NICK = self.conf.getNick(self.network)
  274. # we have to cast it to an int, otherwise the connection fails silently and the entire process dies
  275. self.PORT = int(self.conf.getPort(self.network))
  276. self.IDENT = 'mypy'
  277. self.REALNAME = 's1ash'
  278. self.OWNER = self.conf.getOwner(self.network)
  279. # connect to server
  280. self.s = socket.socket()
  281. while self.CONNECTED == False:
  282. try:
  283. # low level socket TCP/IP connection
  284. self.s.connect((self.HOST, self.PORT)) # force them into one argument
  285. self.CONNECTED = True
  286. self.logger.write(Logger.INFO, "Connected to " + self.network, self.NICK)
  287. if self.DEBUG:
  288. self.debug_print(util.bcolors.YELLOW + ">> " + util.bcolors.ENDC + "connected to " + self.network)
  289. except:
  290. self.debug_print(util.bcolors.FAIL + ">> " + util.bcolors.ENDC + "Could not connect! Retrying... ")
  291. time.sleep(1)
  292. self.worker()
  293. # core IRC protocol stuff
  294. self.s.send('NICK '+self.NICK+'\n')
  295. if self.DEBUG:
  296. self.debug_print(util.bcolors.YELLOW + ">> " + util.bcolors.ENDC + self.network + ': NICK ' + self.NICK + '\\n')
  297. self.s.send('USER '+self.IDENT+ ' 8 ' + ' bla : '+self.REALNAME+'\n') # yeah, don't delete this line
  298. if self.DEBUG:
  299. self.debug_print(util.bcolors.YELLOW + ">> " + util.bcolors.ENDC + self.network + ": USER " +self.IDENT+ ' 8 ' + ' bla : '+self.REALNAME+'\\n')
  300. time.sleep(3) # allow services to catch up
  301. self.s.send('PRIVMSG nickserv identify '+self.conf.getIRCPass(self.network)+'\n') # we're registered!
  302. if self.DEBUG:
  303. self.debug_print(util.bcolors.YELLOW + ">> " + util.bcolors.ENDC + self.network + ': PRIVMSG nickserv identify '+self.conf.getIRCPass(self.network)+'\\n')
  304. self.s.setblocking(1)
  305. read = ""
  306. timeout = 0
  307. # if we're only running a test of connecting, and don't want to loop forever
  308. if mock:
  309. return
  310. # infinite loop to keep parsing lines
  311. while True:
  312. try:
  313. timeout += 1
  314. # if we haven't received anything for 120 seconds
  315. time_since = self.conf.getTimeout(self.network)
  316. if timeout > time_since:
  317. if self.DEBUG:
  318. print "Disconnected! Retrying... "
  319. self.logger.write(Logger.CRITICAL, "Disconnected!", self.NICK)
  320. # so that we rejoin all our channels upon reconnecting to the server
  321. self.JOINED = False
  322. self.CONNECTED = False
  323. self.worker()
  324. if self.JOINED:
  325. self.time_event.time_notify_subscribers()
  326. time.sleep(1)
  327. # if self.CONNECTED == False:
  328. # self.connect()
  329. ready = select.select([self.s],[],[], 1)
  330. if ready[0]:
  331. try:
  332. read = read + self.s.recv(1024)
  333. except:
  334. if self.DEBUG:
  335. print "Disconnected! Retrying... "
  336. self.logger.write(Logger.CRITICAL, "Disconnected!", self.NICK)
  337. self.JOINED = False
  338. self.CONNECTED = False
  339. self.worker()
  340. lines = read.split('\n')
  341. # Important: all lines from irc are terminated with '\n'. lines.pop() will get you any "to be continued"
  342. # line that couldn't fit in the socket buffer. It is stored and tacked on to the start of the next recv.
  343. read = lines.pop()
  344. if len(lines) > 0:
  345. timeout = 0
  346. for line in lines:
  347. line = line.rstrip()
  348. self.processline(line)
  349. except KeyboardInterrupt:
  350. print "keyboard interrupt caught; exiting ..."
  351. raise
  352. # end worker
  353. def debug_print(self, line):
  354. """
  355. Prepends incoming lines with the current timestamp and the thread's name, then spits it to stdout.
  356. Args:
  357. line: text.
  358. """
  359. print str(datetime.datetime.now()) + ": " + self.getName() + ": " + line.strip('\n')
  360. def commands(*command_list):
  361. # stolen shamelessly from willie. damn, this is a good idea.
  362. """Decorator. Sets a command list for a callable.
  363. This decorator can be used to add multiple commands to one callable in a
  364. single line. The resulting match object will have the command as the first
  365. group, rest of the line, excluding leading whitespace, as the second group.
  366. Parameters 1 through 4, seperated by whitespace, will be groups 3-6.
  367. Args:
  368. command: A string, which can be a regular expression.
  369. Returns:
  370. A function with a new command appended to the commands
  371. attribute. If there is no commands attribute, it is added.
  372. Example:
  373. @command("hello"):
  374. If the command prefix is "\.", this would trigger on lines starting
  375. with ".hello".
  376. @commands('j', 'join')
  377. If the command prefix is "\.", this would trigger on lines starting
  378. with either ".j" or ".join".
  379. """
  380. def add_attribute(function):
  381. if not hasattr(function, "commands"):
  382. function.commands = []
  383. function.commands.extend(command_list)
  384. return function
  385. return add_attribute
  386. def depends(self, module_name):
  387. for m in self.loaded_modules:
  388. if m.__class__.__name__ == module_name:
  389. return m
  390. return None
  391. def run(self):
  392. """
  393. For implementing the parent threading.Thread class. Allows the thread the be initialized with our code.
  394. """
  395. self.worker()
  396. def say(self, channel, thing):
  397. """
  398. Speak, damn you!
  399. """
  400. self.brain.say(channel, thing)
  401. # end class Bot
  402. ## MAIN ## ACTUAL EXECUTION STARTS HERE
  403. if __name__ == "__main__":
  404. import bot
  405. DEBUG = False
  406. for i in sys.argv:
  407. if i == "-d":
  408. DEBUG = True
  409. # duuude this is so old.
  410. if os.name == "posix":
  411. botslist = list()
  412. if not DEBUG:
  413. pid = os.fork()
  414. if pid == 0: # child
  415. print "starting bot in the background, pid " + util.bcolors.GREEN + str(os.getpid()) + util.bcolors.ENDC
  416. if len(sys.argv) > 1:
  417. CONF = sys.argv[1]
  418. else: CONF = "~/.pybotrc"
  419. cm = confman.ConfManager(CONF)
  420. net_list = cm.getNetworks()
  421. i = 0
  422. if cm.getNumNets() > 1:
  423. for c in cm.getNetworks():
  424. b = bot.Bot(cm, net_list[i], DEBUG)
  425. b.start()
  426. i += 1
  427. else:
  428. b = bot.Bot(cm, net_list[0], DEBUG)
  429. b.start()
  430. elif pid > 0:
  431. sys.exit(0)
  432. else: # don't background
  433. print "starting bot in the background, pid " + util.bcolors.GREEN + str(os.getpid()) + util.bcolors.ENDC
  434. if len(sys.argv) > 1 and sys.argv[1] != "-d": # the conf file must be first argument
  435. CONF = sys.argv[1]
  436. try:
  437. f = open(CONF)
  438. except IOError:
  439. print "Could not open conf file " + sys.argv[1]
  440. sys.exit(1)
  441. else: CONF = "~/.pybotrc"
  442. cm = confman.ConfManager(CONF)
  443. net_list = cm.getNetworks()
  444. i = 0
  445. if cm.getNumNets() > 1:
  446. for c in cm.getNetworks():
  447. try:
  448. b = bot.Bot(cm, net_list[i], DEBUG)
  449. b.daemon = True
  450. b.start()
  451. botslist.append(b)
  452. i += 1
  453. while True: time.sleep(5)
  454. except (KeyboardInterrupt, SystemExit):
  455. print "keyboard interrupt caught; exiting..."
  456. sys.exit(0)
  457. else:
  458. try:
  459. b = bot.Bot(cm, net_list[0], DEBUG)
  460. b.daemon = True
  461. b.start()
  462. botslist.append(b)
  463. while True: time.sleep(5)
  464. except (KeyboardInterrupt, SystemExit):
  465. print "keyboard interrupt caught; exiting..."
  466. sys.exit(0)