Bot   F
last analyzed

Complexity

Total Complexity 87

Size/Duplication

Total Lines 508
Duplicated Lines 0 %

Importance

Changes 7
Bugs 1 Features 1
Metric Value
dl 0
loc 508
rs 1.5789
c 7
b 1
f 1
wmc 87

12 Methods

Rating   Name   Duplication   Size   Complexity  
A load_snippets() 0 15 4
A send() 0 21 4
A pong() 0 5 1
F load_modules() 0 88 25
B set_snippets() 0 12 7
F worker() 0 124 20
A debug_print() 0 14 2
A register_event() 0 20 4
F processline() 0 64 16
B __init__() 0 112 2
A say() 0 5 1
A run() 0 5 1

How to fix   Complexity   

Complex Class

Complex classes like Bot often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

1
#!/usr/bin/env python
2
#
3
# see version.py for version
4
# works with python 2.6.x and 2.7.x
5
#
6
7
8
import sys
9
import socket
10
import string
11
import os
12
import datetime
13
import time
14
import select
15
import traceback
16
import threading
17
import inspect
18
import argparse
19
20
import botbrain
21
from logger import Logger
22
import confman
23
from event import Event
24
import util
25
26
DEBUG = False
27
RETRY_COUNTER = 0
28
29
class Bot(threading.Thread):
30
  """
31
    bot instance. one bot gets instantiated per network, as an entirely distinct, sandboxed thread.
32
    handles the core IRC protocol stuff, and passing lines to defined events, which dispatch to their subscribed modules.
33
  """
34
  def __init__(self, conf=None, network=None, d=None):
35
    threading.Thread.__init__(self)
36
37
    self.HOST = None
38
    self.PORT = None
39
    self.REALNAME = None
40
    self.IDENT = None
41
    self.DEBUG = d
42
    self.brain = None
43
    self.network = network
44
    self.OFFLINE = False
45
    self.CONNECTED = False
46
    self.JOINED = False
47
    self.conf = conf
48
    self.pid = os.getpid()
49
    self.logger = Logger()
50
#   to be a dict of dicts
51
    self.command_function_map = dict()
52
    
53
    if self.conf.getDBType() == "sqlite":
54
      import lite
55
      self.db = lite.SqliteDB(self)
56
    else: 
57
      import db
58
      self.db = db.DB(self)
59
60
61
    self.NICK = self.conf.getNick(self.network)
62
63
    self.logger.write(Logger.INFO, "\n", self.NICK)
64
    self.logger.write(Logger.INFO, " initializing bot, pid " + str(os.getpid()), self.NICK)
65
66
    # arbitrary key/value store for modules
67
    # they should be 'namespaced' like bot.mem_store.module_name
68
    self.mem_store = dict()
69
70
    self.CHANNELINIT = conf.getChannels(self.network)
71
# this will be the socket
72
    self.s = None # each bot thread holds its own socket open to the network
73
74
    self.brain = botbrain.BotBrain(self.send, self) 
75
76
    self.events_list = list()
77
78
    # define events here and add them to the events_list
79
80
    all_lines = Event("1__all_lines__")
81
    all_lines.define(".*")
82
    self.events_list.append(all_lines)
83
84
    implying = Event("__implying__")
85
    implying.define(">")
86
87
    #command = Event("__command__")
88
   # this is an example of passing in a regular expression to the event definition
89
    #command.define("fo.bar")
90
91
    lastfm = Event("__.lastfm__")
92
    lastfm.define(".lastfm")
93
94
    dance = Event("__.dance__")
95
    dance.define("\.dance")
96
97
    #unloads = Event("__module__")
98
    #unloads.define("^\.module")
99
100
    pimp = Event("__pimp__")
101
    pimp.define("\.pimp")
102
103
    bofh = Event("__.bofh__")
104
    bofh.define("\.bofh")
105
106
    #youtube = Event("__youtubes__")
107
    #youtube.define("youtube.com[\S]+")
108
109
    weather = Event("__.weather__")
110
    weather.define("\.weather")
111
112
    steam = Event("__.steam__")
113
    steam.define("\.steam")
114
115
    part = Event("__.part__")
116
    part.define("part")
117
118
    tell = Event("__privmsg__")
119
    tell.define("PRIVMSG")
120
121
    links = Event("__urls__")
122
    links.define("https?://*")
123
124
  # example
125
  #  test = Event("__test__")
126
  #  test.define(msg_definition="^\.test")
127
128
    # add your defined events here
129
    # tell your friends
130
    self.events_list.append(lastfm)
131
    self.events_list.append(dance)
132
    self.events_list.append(pimp)
133
    #self.events_list.append(youtube)
134
    self.events_list.append(bofh)
135
    self.events_list.append(weather)
136
    self.events_list.append(steam)
137
    self.events_list.append(part)
138
    self.events_list.append(tell)
139
    self.events_list.append(links)
140
    #self.events_list.append(unloads)
141
  # example
142
  #  self.events_list.append(test)
143
144
    self.load_modules()
145
    self.logger.write(Logger.INFO, "bot initialized.", self.NICK)
146
147
  # conditionally subscribe to events list or add event to listing
148
  def register_event(self, event, module):
149
    """
150
      Allows for dynamic, asynchronous event creation. To be used by modules, mostly, to define their own events in their initialization.
151
      Prevents multiple of the same _type of event being registered.
152
153
      Args: 
154
      event: an event object to be registered with the bot 
155
      module: calling module; ensures the calling module can be subscribed to the event if it is not already.
156
157
      Returns: 
158
      nothing.
159
    """
160
    for e in self.events_list:
161
      if e.definition == event.definition and e._type == event._type:
162
        # if our event is already in the listing, don't add it again, just have our module subscribe
163
        e.subscribe(module)
164
        return
165
166
    self.events_list.append(event)
167
    return
168
169
  def load_snippets(self):
170
    import imp
171
    snippets_path = self.modules_path + '/snippets'
172
    self.snippets_list = set()
173
# load up snippets first
174
    for filename in os.listdir(snippets_path):
175
      name, ext = os.path.splitext(filename)
176
      try:
177
        if ext == ".py":
178
# snippet is a module
179
          snippet = imp.load_source(name, snippets_path + '/' + filename)
180
          self.snippets_list.add(snippet)
181
      except Exception, e:
182
        print e
183
        print name, filename
184
185
  def set_snippets(self):
186
    """ 
187
    check each snippet for a function with a list of commands in it
188
    create a big ol list of dictionaries, commands mapping to the functions to call if the command is encountered
189
    """
190
    for obj in self.snippets_list:
191
      for k,v in inspect.getmembers(obj, inspect.isfunction):
192
        if inspect.isfunction(v) and hasattr(v, 'commands'):
193
          for c in v.commands:
194
            if not c in self.command_function_map:
195
              self.command_function_map[c] = dict()
196
            self.command_function_map[c] = v
197
198
199
  # utility function for loading modules; can be called by modules themselves
200
  def load_modules(self, specific=None):
201
    """
202
      Run through the ${bot_dir}/modules directory, dynamically instantiating each module as it goes.
203
204
      Args:
205
      specific: string name of module. if it is specified, the function attempts to load the named module.
206
207
      Returns:
208
      1 if successful, 0 on failure. In keeping with the perverse reversal of UNIX programs and boolean values.
209
    """
210
    nonspecific = False
211
    found = False
212
213
    self.loaded_modules = list()
214
215
    modules_dir_list = list()
216
    tmp_list = list()
217
    
218
    self.modules_path = 'modules'
219
    modules_path = 'modules'
220
    self.autoload_path = 'modules/autoloads'
221
    autoload_path = 'modules/autoloads'
222
223
    # this is magic.
224
225
    import os, imp, json
226
227
228
    self.load_snippets()
229
    self.set_snippets()
230
231
    dir_list = os.listdir(modules_path)
232
    mods = {}
233
    autoloads = {}
234
    # load autoloads if it exists
235
    if os.path.isfile(autoload_path): 
236
      self.logger.write(Logger.INFO, "Found autoloads file", self.NICK)
237
      try:
238
        autoloads = json.load(open(autoload_path))
239
        # logging
240
        for k in autoloads.keys():
241
          self.logger.write(Logger.INFO, "Autoloads found for network " + k, self.NICK)
242
      except IOError:
243
        self.logger.write(Logger.ERROR, "Could not load autoloads file.",self.NICK)
244
    # create dictionary of things in the modules directory to load
245
    for fname in dir_list:
246
      name, ext = os.path.splitext(fname)
247
      if specific is None:
248
        nonspecific = True
249
        # ignore compiled python and __init__ files. 
250
        # choose to either load all .py files or, available, just ones specified in autoloads
251
        if self.network not in autoloads.keys(): # if autoload does not specify for this network
252
          if ext == '.py' and not name == '__init__': 
253
            f, filename, descr = imp.find_module(name, [modules_path])
254
            mods[name] = imp.load_module(name, f, filename, descr)
255
            self.logger.write(Logger.INFO, " loaded " + name + " for network " + self.network, self.NICK)
256
        else: # follow autoload's direction
257
          if ext == '.py' and not name == '__init__':
258
            if name == 'module':
259
              f, filename, descr = imp.find_module(name, [modules_path])
260
              mods[name] = imp.load_module(name, f, filename, descr)
261
              self.logger.write(Logger.INFO, " loaded " + name + " for network " + self.network, self.NICK)
262
            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']):
263
              f, filename, descr = imp.find_module(name, [modules_path])
264
              mods[name] = imp.load_module(name, f, filename, descr)
265
              self.logger.write(Logger.INFO, " loaded " + name + " for network " + self.network, self.NICK)
266
      else:
267
        if name == specific: # we're reloading only one module
268
          if ext != '.pyc': # ignore compiled 
269
            f, filename, descr = imp.find_module(name, [modules_path])
270
            mods[name] = imp.load_module(name, f, filename, descr)
271
            found = True
272
273
    for k,v in mods.iteritems(): 
274
      for name in dir(v):
275
        obj = getattr(mods[k], name) # get the object from the namespace of 'mods'
276
        try:
277
          if inspect.isclass(obj): # it's a class definition, initialize it
278
            a = obj(self.events_list, self.send, self, self.say) # now we're passing in a reference to the calling bot
279
            if a not in self.loaded_modules: # don't add in multiple copies
280
              self.loaded_modules.append(a)
281
        except TypeError:
282
          pass
283
284
    if nonspecific is True or found is True:
285
      return 0
286
    else:
287
      return 1
288
    # end magic.
289
    
290
  def send(self, message):
291
    """
292
    Simply sends the specified message to the socket. Which should be our connected server.
293
    We parse our own lines, as well, in case we want an event triggered by something we say.
294
    If debug is True, we also print out a pretty thing to console.
295
296
    Args:
297
    message: string, sent directly and without manipulation (besides UTF-8ing it) to the server.
298
    """
299
    if self.OFFLINE:
300
      self.debug_print(util.bcolors.YELLOW + " >> " + util.bcolors.ENDC + self.getName() + ": " + message.encode('utf-8', 'ignore'))
301
    else:
302
      if self.DEBUG is True:
303
        self.logger.write(Logger.INFO, "DEBUGGING OUTPUT", self.NICK)
304
        self.logger.write(Logger.INFO, self.getName() + " " + message.encode('utf-8', 'ignore'), self.NICK)
305
        self.debug_print(util.bcolors.OKGREEN + ">> " + util.bcolors.ENDC + ": " + " " + message.encode('utf-8', 'ignore'))
306
307
      self.s.send(message.encode('utf-8', 'ignore'))
308
      target = message.split()[1]
309
      if target.startswith("#"):
310
        self.processline(':' + self.conf.getNick(self.network) + '!~' + self.conf.getNick(self.network) + '@fakehost.here ' + message.rstrip()) 
311
312
  def pong(self, response):
313
    """
314
    Keepalive heartbeat for IRC protocol. Until someone changes the IRC spec, don't modify this.
315
    """
316
    self.send('PONG ' + response + '\n')
317
318
  def processline(self, line):
319
    """
320
    Grab newline-delineated lines sent to us, and determine what to do with them. 
321
    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.
322
323
    Also immediately passes off PING messages to PONG.
324
325
    Args:
326
    line: string. 
327
328
    """
329
    if self.DEBUG:
330
      import datetime
331
      if os.name == "posix": # because windows doesn't like the color codes.
332
        self.debug_print(util.bcolors.OKBLUE + "<< " + util.bcolors.ENDC + line)
333
      else:
334
        self.debug_print("<< " + ": " + line)
335
336
    message_number = line.split()[1]
337
338
    try:
339
      first_word = line.split(":", 2)[2].split()[0]
340
      channel = line.split()[2]
341
    except IndexError:
342
      pass
343
    else:
344
      if first_word in self.command_function_map:
345
        self.command_function_map[first_word](self, line, channel)
346
347
    try:
348
      for e in self.events_list:
349
        if e.matches(line):
350
          e.notifySubscribers(line)
351
      # don't bother going any further if it's a PING/PONG request
352
      if line.startswith("PING"):
353
        ping_response_line = line.split(":", 1)
354
        self.pong(ping_response_line[1])
355
      # pings we respond to directly. everything else...
356
      else:
357
# patch contributed by github.com/thekanbo
358
        if self.JOINED is False and (message_number == "376" or message_number == "422"): 
359
          # wait until we receive end of MOTD before joining, or until the server tells us the MOTD doesn't exist
360
          self.chan_list = self.conf.getChannels(self.network) 
361
          for c in self.chan_list:
362
            self.send('JOIN '+c+' \n')
363
          self.JOINED = True
364
        
365
        line_array = line.split()
366
        user_and_mask = line_array[0][1:]
367
        usr = user_and_mask.split("!")[0]
368
        channel = line_array[2]
369
        try: 
370
          message = line.split(":",2)[2]
371
          self.brain.respond(usr, channel, message)
372
        except IndexError:
373
          try:
374
            message = line.split(":",2)[1]
375
            self.brain.respond(usr, channel, message)
376
          except IndexError:
377
            print "index out of range.", line
378
        
379
    except Exception:
380
      print "Unexpected error:", sys.exc_info()[0]
381
      traceback.print_exc(file=sys.stdout)
382
383
      
384
  def worker(self, mock=False):
385
    """
386
    Open the socket, make the first incision^H^H connection and get us on the server. 
387
    Handles keeping the connection alive; if we disconnect from the server, attempts to reconnect.
388
389
    Args:
390
    mock: boolean. If mock is true, don't loop forever -- mock is for testing.
391
    """
392
    self.HOST = self.network
393
    self.NICK = self.conf.getNick(self.network)
394
395
    # we have to cast it to an int, otherwise the connection fails silently and the entire process dies
396
    self.PORT = int(self.conf.getPort(self.network))
397
    self.IDENT = 'mypy'
398
    self.REALNAME = 's1ash'
399
    self.OWNER = self.conf.getOwner(self.network) 
400
    
401
    # connect to server
402
    self.s = socket.socket()
403
    while not self.CONNECTED:
404
      try:
405
# low level socket TCP/IP connection
406
        self.s.connect((self.HOST, self.PORT)) # force them into one argument
407
        self.CONNECTED = True
408
        self.logger.write(Logger.INFO, "Connected to " + self.network, self.NICK)
409
        if self.DEBUG:
410
          self.debug_print(util.bcolors.YELLOW + ">> " + util.bcolors.ENDC + "connected to " + self.network)
411
      except:
412
        if self.DEBUG:
413
          self.debug_print(util.bcolors.FAIL + ">> " + util.bcolors.ENDC + "Could not connect to " + self.HOST + " at " + str(self.PORT) + "! Retrying... ")
414
        self.logger.write(Logger.CRITICAL, "Could not connect to " + self.HOST + " at " + str(self.PORT) + "! Retrying...")
415
        time.sleep(1)
416
417
        self.worker()
418
419
      time.sleep(1)
420
    # core IRC protocol stuff
421
      self.s.send('NICK '+self.NICK+'\n')
422
423
      if self.DEBUG:
424
        self.debug_print(util.bcolors.YELLOW + ">> " + util.bcolors.ENDC + self.network + ': NICK ' + self.NICK + '\\n')
425
426
      self.s.send('USER '+self.IDENT+ ' 8 ' + ' bla : '+self.REALNAME+'\n') # yeah, don't delete this line
427
428
      if self.DEBUG:
429
        self.debug_print(util.bcolors.YELLOW + ">> " + util.bcolors.ENDC + self.network + ": USER " +self.IDENT+ ' 8 ' + ' bla : '+self.REALNAME+'\\n')
430
431
      time.sleep(3) # allow services to catch up
432
433
      self.s.send('PRIVMSG nickserv identify '+self.conf.getIRCPass(self.network)+'\n')  # we're registered!
434
435
      if self.DEBUG:
436
        self.debug_print(util.bcolors.YELLOW + ">> " + util.bcolors.ENDC + self.network + ': PRIVMSG nickserv identify '+self.conf.getIRCPass(self.network)+'\\n')
437
438
    self.s.setblocking(1)
439
    
440
    read = ""
441
    
442
    timeout = 0
443
444
#   does not require a definition -- it will be invoked specifically when the bot notices it has been disconnected
445
    disconnect_event = Event("__.disconnection__")
446
#   if we're only running a test of connecting, and don't want to loop forever
447
    if mock:
448
      return
449
    # infinite loop to keep parsing lines
450
    while True:
451
      try:
452
        timeout += 1
453
        # if we haven't received anything for 120 seconds
454
        time_since = self.conf.getTimeout(self.network)
455
        if timeout > time_since:
456
          if self.DEBUG:
457
            self.debug_print("Disconnected! Retrying... ")
458
          disconnect_event.notifySubscribers("null")
459
          self.logger.write(Logger.CRITICAL, "Disconnected!", self.NICK)
460
# so that we rejoin all our channels upon reconnecting to the server
461
          self.JOINED = False
462
          self.CONNECTED = False
463
464
          global RETRY_COUNTER
465
          if RETRY_COUNTER > 10:
466
            self.debug_print("Failed to reconnect after 10 tries. Giving up...")
467
            sys.exit(1)
468
469
          RETRY_COUNTER+=1
470
          self.worker()
471
472
        time.sleep(1)
473
        ready = select.select([self.s], [], [], 1)
474
        if ready[0]:
475
          try:
476
            read = read + self.s.recv(1024).decode('utf8', 'ignore')
477
          except UnicodeDecodeError, e:
478
            self.debug_print("Unicode decode error; " + e.__str__())
479
            self.debug_print("Offending recv: " + self.s.recv)
480
            pass
481
          except Exception, e:
482
            print e
483
            if self.DEBUG:
484
              self.debug_print("Disconnected! Retrying... ")
485
            disconnect_event.notifySubscribers("null")
486
            self.logger.write(Logger.CRITICAL, "Disconnected!", self.NICK)
487
            self.JOINED = False
488
            self.CONNECTED = False
489
            self.worker()
490
491
492
          lines = read.split('\n')
493
494
          
495
          # Important: all lines from irc are terminated with '\n'. lines.pop() will get you any "to be continued"
496
          # line that couldn't fit in the socket buffer. It is stored and tacked on to the start of the next recv.
497
          read = lines.pop() 
498
499
          if len(lines) > 0:
500
            timeout = 0
501
502
          for line in lines:
503
            line = line.rstrip()
504
            self.processline(line)
505
      except KeyboardInterrupt:
506
        print "keyboard interrupt caught; exiting ..."
507
        raise
508
  # end worker
509
510
  def debug_print(self, line, error=False):
511
    """
512
    Prepends incoming lines with the current timestamp and the thread's name, then spits it to stdout.
513
    Warning: this is entirely asynchronous between threads. If you connect to multiple networks, they will interrupt each other between lines.
514
515
    Args:
516
    line: text.
517
    error: boolean, defaults to False. if True, prints out with red >> in the debug line
518
    """
519
520
    if not error:
521
      print str(datetime.datetime.now()) + ": " + self.getName() + ": " + line.strip('\n').rstrip().lstrip()
522
    else:
523
      print str(datetime.datetime.now()) + ": " + self.getName() + ": " + util.bcolors.RED + ">> " + util.bcolors.ENDC + line.strip('\n').rstrip().lstrip()
524
525
    
526
  def run(self):
527
    """
528
    For implementing the parent threading.Thread class. Allows the thread the be initialized with our code.
529
    """
530
    self.worker()
531
532
  def say(self, channel, thing):
533
    """
534
    Speak, damn you!
535
    """
536
    self.brain.say(channel, thing)
537
# end class Bot
538
              
539
## MAIN ## ACTUAL EXECUTION STARTS HERE
540
541
if __name__ == "__main__":
542
  DEBUG = False
543
  import bot
544
545
  parser = argparse.ArgumentParser(description="a python irc bot that does stuff")
546
  parser.add_argument('config', nargs='?')
547
  parser.add_argument('-d', help='debug (foreground) mode', action='store_true')
548
549
  args = parser.parse_args()
550
  if args.d:
551
    DEBUG = True
552
  if args.config:
553
    config = args.config
554
  else:
555
    config = "~/.pybotrc"
556
557
  botslist = list()
558
  if not DEBUG and hasattr(os, 'fork'):
559
    pid = os.fork()
560
    if pid == 0: # child
561
      if os.name == "posix":
562
        print "starting bot in the background, pid " + util.bcolors.GREEN + str(os.getpid()) + util.bcolors.ENDC
563
      else:
564
        print "starting bot in the background, pid " + str(os.getpid())
565
566
      cm = confman.ConfManager(config)
567
      net_list = cm.getNetworks()
568
      for c in cm.getNetworks():
569
        b = bot.Bot(cm, c, DEBUG)
570
        b.start()
571
572
    elif pid > 0:
573
      sys.exit(0)
574
  else: # don't background; either we're in debug (foreground) mode, or on windows TODO
575
    if os.name == 'nt':
576
      print 'in debug mode; backgrounding currently unsupported on windows.'
577
    DEBUG = True
578
    print "starting bot, pid " + util.bcolors.GREEN + str(os.getpid()) + util.bcolors.ENDC
579
    try:
580
      f = open(os.path.expanduser(config))
581
    except IOError:
582
      print "Could not open conf file " + config 
583
      sys.exit(1)
584
585
    cm = confman.ConfManager(config)
586
    net_list = cm.getNetworks()
587
    for c in cm.getNetworks():
588
      b = bot.Bot(cm, c, DEBUG)
589
      b.daemon = True
590
      b.start()
591
      botslist.append(b)
592
    try:
593
      while True:
594
        time.sleep(5)
595
    except (KeyboardInterrupt, SystemExit):
596
      l = Logger()
597
      l.write(Logger.INFO, "killed by ctrl+c or term signal")
598
      for b in botslist:
599
        b.s.send("QUIT :because I got killed\n")
600
      print
601
      print "keyboard interrupt caught; exiting"
602
      sys.exit(1)
603
      
604
605