bot.py 19 KB

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