]> jfr.im git - erebus.git/blame - bot.py
have multiple DB connections for use in different threads
[erebus.git] / bot.py
CommitLineData
b25d4368 1#!/usr/bin/python
4477123d 2# vim: fileencoding=utf-8
b25d4368 3
931c88a4 4# Erebus IRC bot - Author: John Runyon
5# "Bot" and "BotConnection" classes (handling a specific "arm")
6
0784e720 7import socket, sys, time, threading, os, random
e64ac4a0 8from collections import deque
b25d4368 9
656dc5a4 10MAXLEN = 400 # arbitrary max length of a command generated by Bot.msg functions
11
a28e2ae9 12if sys.version_info.major < 3:
13 timerbase = threading._Timer
d6052ebf 14 stringbase = basestring
a28e2ae9 15else:
16 timerbase = threading.Timer
d6052ebf 17 stringbase = str
a28e2ae9 18class MyTimer(timerbase):
2ffa3996 19 def __init__(self, *args, **kwargs):
a28e2ae9 20 timerbase.__init__(self, *args, **kwargs)
2ffa3996 21 self.daemon = True
22
d6052ebf 23if sys.version_info.major < 3:
24 stringbase = basestring
25else:
26 stringbase = str
2ffa3996 27
b25d4368 28#bots = {'erebus': bot.Bot(nick='Erebus', user='erebus', bind='', server='irc.quakenet.org', port=6667, realname='Erebus')}
29class Bot(object):
0af282c6 30 def __init__(self, parent, nick, user, bind, authname, authpass, server, port, realname):
b25d4368 31 self.parent = parent
32 self.nick = nick
0784e720 33 self.permnick = nick
a12f7519 34 self.user = user
35 self.realname = realname
5477b368 36
0af282c6 37 self.authname = authname
38 self.authpass = authpass
39
2729abc8 40 curs = self.parent.query("SELECT chname FROM chans WHERE bot = %s AND active = 1", (self.nick,))
41 if curs:
4fa1118b 42 chansres = curs.fetchall()
43 curs.close()
44 self.chans = [self.parent.newchannel(self, row['chname']) for row in chansres]
6de27fd4 45 else:
46 self.chans = []
b25d4368 47
a12f7519 48 self.conn = BotConnection(self, bind, server, port)
e64ac4a0 49
2ffa3996 50 self.lastreceived = time.time() #time we last received a line from the server
d6c6516c 51 self.watchdog()
2ffa3996 52
e64ac4a0 53 self.msgqueue = deque()
2bb267e0 54 self.slowmsgqueue = deque()
e64ac4a0 55 self.makemsgtimer()
c6e6807f 56 self.msgtimer.start()
e64ac4a0 57
e40e5b39 58 def __del__(self):
2729abc8 59 try:
60 curs = self.parent.query("UPDATE bots SET connected = 0 WHERE nick = %s", (self.nick,))
61 curs.close()
62 except: pass
e40e5b39 63
2ffa3996 64 def watchdog(self):
dcc5bde3 65 if time.time() > int(self.parent.cfg.get('watchdog', 'maxtime', default=300))+self.lastreceived:
2ffa3996 66 self.parse("ERROR :Fake-error from watchdog timer.")
dcc5bde3 67 self.watchdogtimer = MyTimer(int(self.parent.cfg.get('watchdog', 'interval', default=30)), self.watchdog)
e64ac4a0 68
a8553c45 69 def log(self, *args, **kwargs):
70 self.parent.log(self.nick, *args, **kwargs)
71
b25d4368 72 def connect(self):
b2a896c8 73 if self.conn.connect():
49a455aa 74 self.parent.newfd(self, self.conn.socket.fileno())
75
b25d4368 76 def getdata(self):
2ffa3996 77 self.lastreceived = time.time()
d1ea2946 78 return self.conn.read()
a4eacae2 79
0784e720 80 def _checknick(self): # check if we're using the right nick, try changing
81 if self.nick != self.permnick and self.conn.registered():
82 self.conn.send("NICK %s" % (self.permnick))
83
b25d4368 84 def parse(self, line):
6b6f9624 85 if self.parent.cfg.getboolean('debug', 'io'):
86 self.log('I', line)
b25d4368 87 pieces = line.split()
a4eacae2 88
a38e8be0 89 # dispatch dict
90 zero = { #things to look for without source
0784e720 91 'NOTICE': self._gotconnected,
28d06664 92 'PING': self._gotping,
93 'ERROR': self._goterror,
94 }
a38e8be0 95 one = { #things to look for after source
c22ee2ba 96 'NOTICE': self._gotconnected,
28d06664 97 '001': self._got001,
0784e720 98 '376': self._gotRegistered,
99 '422': self._gotRegistered,
28d06664 100 'PRIVMSG': self._gotprivmsg,
84b7c247 101 '353': self._got353, #NAMES
102 '354': self._got354, #WHO
0784e720 103 '433': self._got433, #nick in use
28d06664 104 'JOIN': self._gotjoin,
105 'PART': self._gotpart,
6de27fd4 106 'KICK': self._gotkick,
28d06664 107 'QUIT': self._gotquit,
108 'NICK': self._gotnick,
109 'MODE': self._gotmode,
110 }
d1ea2946 111
e4a4c762 112 if self.parent.hasnumhook(pieces[1]):
113 hooks = self.parent.getnumhook(pieces[1])
114 for callback in hooks:
a38e8be0 115 try:
116 callback(self, line)
117 except Exception:
118 self.__debug_cbexception("numhook", line)
e4a4c762 119
28d06664 120 if pieces[0] in zero:
121 zero[pieces[0]](pieces)
122 elif pieces[1] in one:
123 one[pieces[1]](pieces)
124
0784e720 125 def _gotconnected(self, pieces):
28d06664 126 if not self.conn.registered():
127 self.conn.register()
128 def _gotping(self, pieces):
129 self.conn.send("PONG %s" % (pieces[1]))
0784e720 130 self._checknick()
f5f2b592 131 def _goterror(self, pieces):
666366fd 132 # TODO: better handling, just reconnect that single bot
2729abc8 133 try:
134 self.quit("Error detected: %s" % ' '.join(pieces))
666366fd 135 except: pass
136 try:
2729abc8 137 curs = self.parent.query("UPDATE bots SET connected = 0")
138 curs.close()
2ffa3996 139 except: pass
28d06664 140 sys.exit(2)
141 os._exit(2)
142 def _got001(self, pieces):
0784e720 143 pass # wait until the end of MOTD instead
144 def _gotRegistered(self, pieces):
28d06664 145 self.conn.registered(True)
e40e5b39 146
2729abc8 147 curs = self.parent.query("UPDATE bots SET connected = 1 WHERE nick = %s", (self.nick,))
148 if curs: curs.close()
e40e5b39 149
28d06664 150 self.conn.send("MODE %s +x" % (pieces[2]))
151 if self.authname is not None and self.authpass is not None:
152 self.conn.send("AUTH %s %s" % (self.authname, self.authpass))
153 for c in self.chans:
154 self.join(c.name)
155 def _gotprivmsg(self, pieces):
156 nick = pieces[0].split('!')[0][1:]
157 user = self.parent.user(nick)
158 target = pieces[2]
159 msg = ' '.join(pieces[3:])[1:]
160 self.parsemsg(user, target, msg)
84b7c247 161 def _got353(self, pieces):
2591a1c8 162 prefixes = {'@': 'op', '+': 'voice'}
84b7c247 163 chan = self.parent.channel(pieces[4])
164 names = pieces[5:]
165 names[0] = names[0][1:] #remove colon
166 for n in names:
2591a1c8 167 if n[0] in prefixes:
168 user = self.parent.user(n[1:])
169 chan.userjoin(user, prefixes[n[0]])
84b7c247 170 else:
2591a1c8 171 user = self.parent.user(n)
84b7c247 172 chan.userjoin(user)
173 user.join(chan)
28d06664 174 def _got354(self, pieces):
14011220 175 qt = int(pieces[3])
176 if qt < 3:
177 nick, auth = pieces[4:6]
178 chan = None
179 else:
180 chan, nick, auth = pieces[4:7]
181 chan = self.parent.channel(chan)
e40e5b39 182 user = self.parent.user(nick)
183 user.authed(auth)
14011220 184
185 if chan is not None:
186 user.join(chan)
187 chan.userjoin(user)
188
189 if qt == 2: # triggered by !auth
e40e5b39 190 if user.isauthed():
191 if user.glevel > 0:
192 self.msg(nick, "You are now known as #%s (access level: %s)" % (auth, user.glevel))
193 else:
194 self.msg(nick, "You are now known as #%s (not staff)" % (auth))
195 else:
196 self.msg(nick, "I tried, but you're not authed!")
0784e720 197 def _got433(self, pieces):
198 if not self.conn.registered(): #we're trying to connect
71ef8273 199 newnick = "%s%d" % (self.nick, random.randint(111, 999))
0784e720 200 self.conn.send("NICK %s" % (newnick))
201 self.nick = newnick
28d06664 202 def _gotjoin(self, pieces):
203 nick = pieces[0].split('!')[0][1:]
204 chan = self.parent.channel(pieces[2])
205
206 if nick == self.nick:
14011220 207 self.conn.send("WHO %s c%%cant,3" % (chan))
28d06664 208 else:
209 user = self.parent.user(nick, justjoined=True)
210 chan.userjoin(user)
211 user.join(chan)
6de27fd4 212 def _clientLeft(self, nick, chan):
28d06664 213 if nick != self.nick:
14011220 214 gone = self.parent.user(nick).part(chan)
28d06664 215 chan.userpart(self.parent.user(nick))
14011220 216 if gone:
217 self.parent.user(nick).quit()
218 del self.parent.users[nick.lower()]
6de27fd4 219 def _gotpart(self, pieces):
220 nick = pieces[0].split('!')[0][1:]
221 chan = self.parent.channel(pieces[2])
222 self._clientLeft(nick, chan)
223 def _gotkick(self, pieces):
224 nick = pieces[3]
225 chan = self.parent.channel(pieces[2])
226 self._clientLeft(nick, chan)
28d06664 227 def _gotquit(self, pieces):
228 nick = pieces[0].split('!')[0][1:]
229 if nick != self.nick:
14011220 230 for chan in self.parent.user(nick).chans:
231 chan.userpart(self.parent.user(nick))
28d06664 232 self.parent.user(nick).quit()
233 del self.parent.users[nick.lower()]
234 def _gotnick(self, pieces):
235 oldnick = pieces[0].split('!')[0][1:]
236 newnick = pieces[2][1:]
237 if newnick.lower() != oldnick.lower():
238 self.parent.users[newnick.lower()] = self.parent.users[oldnick.lower()]
239 del self.parent.users[oldnick.lower()]
240 self.parent.users[newnick.lower()].nickchange(newnick)
84b7c247 241 def _gotmode(self, pieces):
242 source = pieces[0].split('!')[0][1:]
fe73f782 243 chan = pieces[2]
244 if not chan.startswith("#"): return
84b7c247 245 chan = self.parent.channel(pieces[2])
246 mode = pieces[3]
247 args = pieces[4:]
248
249 adding = True
250 for c in mode:
251 if c == '+':
252 adding = True
253 elif c == '-':
254 adding = False
255 elif c == 'o':
256 if adding:
257 chan.userop(self.parent.user(args.pop(0)))
258 else:
259 chan.userdeop(self.parent.user(args.pop(0)))
260 elif c == 'v':
261 if adding:
262 chan.uservoice(self.parent.user(args.pop(0)))
263 else:
264 chan.userdevoice(self.parent.user(args.pop(0)))
265 else:
266 pass # don't care about other modes
b6212f14 267
a38e8be0 268 def __debug_cbexception(self, source, *args, **kwargs):
6b6f9624 269 if self.parent.cfg.getboolean('debug', 'cbexc'):
f59f8c9b 270 self.conn.send("PRIVMSG %s :%09.3f \ 34\1f!!! CBEXC\1f\ 3 %s" % (self.parent.cfg.get('debug', 'owner'), time.time() % 100000, source))
3d724d3a 271 __import__('traceback').print_exc()
a8553c45 272 self.log('!', "CBEXC %s %r %r" % (source, args, kwargs))
273# print "%09.3f %s [!] CBEXC %s %r %r" % (time.time() % 100000, self.nick, source, args, kwargs)
3d724d3a 274
275
839d2b35 276 def parsemsg(self, user, target, msg):
83c2f201 277 if user.glevel <= -2: return # short circuit if user is IGNORED
839d2b35 278 chan = None
6de27fd4 279 chanparam = None # was the channel specified as part of the command?
877cd61d 280 if len(msg) == 0:
281 return
282
839d2b35 283 if target == self.nick:
fd07173d 284 if msg.startswith("\001"): #ctcp
a76c4bd8 285 msg = msg.strip("\001")
286 if msg == "VERSION":
287 self.msg(user, "\001VERSION Erebus v%d.%d - http://github.com/zonidjan/erebus" % (self.parent.APIVERSION, self.parent.RELEASE))
288 return
6de27fd4 289
10b86b56 290 triggerused = msg.startswith(self.parent.trigger)
291 if triggerused: msg = msg[len(self.parent.trigger):]
292 pieces = msg.split()
293
3296dba1 294 if len(pieces) == 0:
295 return
296
6de27fd4 297 if target != self.nick: # message was sent to a channel
90b64dc0 298 try:
3296dba1 299 if pieces[0][:-1].lower() == self.nick.lower() and (pieces[0][-1] == ":" or pieces[0][-1] == ","):
300 pieces.pop(0) # command actually starts with next word
301 if len(pieces) == 0: # is there still anything left?
302 return
303 msg = ' '.join(pieces)
304 triggerused = True
90b64dc0 305 except IndexError:
a76c4bd8 306 return # "message" is empty
839d2b35 307
827ec8f0 308 if len(pieces) > 1:
309 chanword = pieces[1]
310 if chanword.startswith('#'):
311 chanparam = self.parent.channel(chanword)
312
313 if target != self.nick: # message was sent to a channel
314 chan = self.parent.channel(target)
315 if not triggerused:
316 if self.parent.haschanhook(target.lower()):
317 for callback in self.parent.getchanhook(target.lower()):
318 try:
319 cbret = callback(self, user, chan, *pieces)
320 except NotImplementedError:
321 self.msg(user, "Command not implemented.")
322 except:
323 self.msg(user, "Command failed. Code: CBEXC%09.3f" % (time.time() % 100000))
324 self.__debug_cbexception("chanhook", user=user, target=target, msg=msg)
325 return # not to bot, don't process!
326
db50981b 327 cmd = pieces[0].lower()
6de27fd4 328 rancmd = False
db50981b 329 if self.parent.hashook(cmd):
e4a4c762 330 for callback in self.parent.gethook(cmd):
827ec8f0 331 if chanparam is not None and (callback.needchan or callback.wantchan):
6de27fd4 332 chan = chanparam
333 pieces.pop(1)
e4a4c762 334 if chan is None and callback.needchan:
6de27fd4 335 rancmd = True
e4a4c762 336 self.msg(user, "You need to specify a channel for that command.")
586997a7 337 elif user.glevel >= callback.reqglevel and (not callback.needchan or chan.levelof(user.auth) >= callback.reqclevel):
6de27fd4 338 rancmd = True
3d724d3a 339 try:
340 cbret = callback(self, user, chan, target, *pieces[1:])
10b86b56 341 if cbret is NotImplemented:
342 raise NotImplementedError
d6052ebf 343 elif isinstance(cbret, stringbase):
344 self.reply(chan, user, cbret)
e40e5b39 345 except NotImplementedError:
346 self.msg(user, "Command not implemented.")
3d724d3a 347 except Exception:
348 self.msg(user, "Command failed. Code: CBEXC%09.3f" % (time.time() % 100000))
a38e8be0 349 self.__debug_cbexception("hook", user=user, target=target, msg=msg)
e40e5b39 350 except SystemExit as e:
2729abc8 351 try:
352 curs = self.parent.query("UPDATE bots SET connected = 0")
353 curs.close()
354 except: pass
e40e5b39 355 raise e
32b160dc 356 else:
6de27fd4 357 rancmd = True
32b160dc 358 self.msg(user, "I don't know that command.")
6de27fd4 359 if not rancmd:
360 self.msg(user, "You don't have enough access to run that command.")
3d724d3a 361
362 def __debug_nomsg(self, target, msg):
6b6f9624 363 if self.parent.cfg.getboolean('debug', 'nomsg'):
f59f8c9b 364 self.conn.send("PRIVMSG %s :%09.3f \ 34\1f!!! NOMSG\1f\ 3 %r, %r" % (self.parent.cfg.get('debug', 'owner'), time.time() % 100000, target, msg))
a8553c45 365 self.log('!', "!!! NOMSG")
366# print "%09.3f %s [!] %s" % (time.time() % 100000, self.nick, "!!! NOMSG")
3d724d3a 367 __import__('traceback').print_stack()
49a455aa 368
d6052ebf 369
370 def reply(self, chan, user, msg):
371 if chan is not None:
372 self.msg(chan, "%s: %s" % (user, msg))
373 else:
374 self.msg(user, msg)
375
4d925ae3 376 def msg(self, target, msg, truncate=False):
3569ead3 377 if self.parent.cfg.getboolean('erebus', 'nofakelag'): return self.fastmsg(target, msg)
c6e6807f 378 cmd = self._formatmsg(target, msg)
4d925ae3 379 if len(cmd) > MAXLEN:
380 if not truncate:
381 return False
382 else:
383 cmd = cmd[:MAXLEN]
c6e6807f 384 if self.conn.exceeded or self.conn.bytessent+len(cmd) >= self.conn.recvq:
385 self.msgqueue.append(cmd)
386 else:
387 self.conn.send(cmd)
388 self.conn.exceeded = True
656dc5a4 389 return True
e64ac4a0 390
4d925ae3 391 def slowmsg(self, target, msg, truncate=False):
3569ead3 392 if self.parent.cfg.getboolean('erebus', 'nofakelag'): return self.fastmsg(target, msg)
c6e6807f 393 cmd = self._formatmsg(target, msg)
4d925ae3 394 if len(cmd) > MAXLEN:
395 if not truncate:
396 return False
397 else:
398 cmd = cmd[:MAXLEN]
c6e6807f 399 if self.conn.exceeded or self.conn.bytessent+len(cmd) >= self.conn.recvq:
400 self.slowmsgqueue.append(cmd)
401 else:
402 self.conn.send(cmd)
403 self.conn.exceeded = True
656dc5a4 404 return True
2bb267e0 405
4d925ae3 406 def fastmsg(self, target, msg, truncate=False):
656dc5a4 407 cmd = self._formatmsg(target, msg)
4d925ae3 408 if len(cmd) > MAXLEN:
409 if not truncate:
410 return False
411 else:
412 cmd = cmd[:MAXLEN]
656dc5a4 413 self.conn.send(cmd)
c6e6807f 414 self.conn.exceeded = True
656dc5a4 415 return True
c6e6807f 416
417 def _formatmsg(self, target, msg):
3d724d3a 418 if target is None or msg is None:
28d06664 419 return self.__debug_nomsg(target, msg)
3d724d3a 420
6681579e 421 target = str(target)
e64ac4a0 422
fd07173d 423 if target.startswith('#'): command = "PRIVMSG %s :%s" % (target, msg)
e64ac4a0 424 else: command = "NOTICE %s :%s" % (target, msg)
425
c6e6807f 426 return command
e64ac4a0 427
428 def _popmsg(self):
429 self.makemsgtimer()
c6e6807f 430 self.conn.bytessent -= self.conn.recvq/3
431 if self.conn.bytessent < 0: self.conn.bytessent = 0
432 self.conn.exceeded = False
e64ac4a0 433
434 try:
c6e6807f 435 cmd = self.msgqueue.popleft()
436 if not self.conn.exceeded and self.conn.bytessent+len(cmd) < self.conn.recvq:
437 self.conn.send(cmd)
438 self.conn.exceeded = True
439 else: raise IndexError
2bb267e0 440 except IndexError:
441 try:
c6e6807f 442 cmd = self.slowmsgqueue.popleft()
443 if not self.conn.exceeded and self.conn.bytessent+len(cmd) < self.conn.recvq:
444 self.conn.send(cmd)
445 self.conn.exceeded = True
2bb267e0 446 except IndexError:
447 pass
c6e6807f 448 self.msgtimer.start()
e64ac4a0 449
450 def makemsgtimer(self):
c6e6807f 451 self.msgtimer = threading.Timer(3, self._popmsg)
e64ac4a0 452 self.msgtimer.daemon = True
a4eacae2 453
49a455aa 454 def join(self, chan):
455 self.conn.send("JOIN %s" % (chan))
a4eacae2 456
49a455aa 457 def part(self, chan):
458 self.conn.send("PART %s" % (chan))
a4eacae2 459
49a455aa 460 def quit(self, reason="Shutdown"):
461 self.conn.send("QUIT :%s" % (reason))
b25d4368 462
a12f7519 463 def __str__(self): return self.nick
464 def __repr__(self): return "<Bot %r>" % (self.nick)
465
b25d4368 466class BotConnection(object):
a12f7519 467 def __init__(self, parent, bind, server, port):
b25d4368 468 self.parent = parent
a28e2ae9 469 self.buffer = bytearray(8192)
b25d4368 470 self.socket = None
471
b25d4368 472 self.bind = bind
473 self.server = server
474 self.port = int(port)
b25d4368 475
7631844f 476 self.state = 0 # 0=disconnected, 1=registering, 2=connected
477
c6e6807f 478 self.bytessent = 0
479 self.recvq = 500
480 self.exceeded = False
481
b25d4368 482 def connect(self):
483 self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
484 self.socket.bind((self.bind, 0))
485 self.socket.connect((self.server, self.port))
d1ea2946 486 return True
487 def register(self):
488 if self.state == 0:
489 self.send("NICK %s" % (self.parent.nick))
490 self.send("USER %s 0 * :%s" % (self.parent.user, self.parent.realname))
491 self.state = 1
49a455aa 492 return True
b25d4368 493
494 def registered(self, done=False):
495 if done: self.state = 2
496 return self.state == 2
497
b25d4368 498 def send(self, line):
134c1193 499 if self.parent.parent.cfg.getboolean('debug', 'io'):
6b6f9624 500 self.parent.log('O', line)
c6e6807f 501 self.bytessent += len(line)
666366fd 502 try:
503 self._write(line)
504 except socket.error as e:
505 self.parent._goterror(repr(e))
a4eacae2 506
7631844f 507 def _write(self, line):
a28e2ae9 508 self.socket.sendall(line.encode('utf-8', 'backslashreplace')+b"\r\n")
a4eacae2 509
b25d4368 510 def read(self):
511 self.buffer += self.socket.recv(8192)
512 lines = []
a4eacae2 513
a28e2ae9 514 while b"\r\n" in self.buffer:
515 pieces = self.buffer.split(b"\r\n", 1)
516 lines.append(pieces[0].decode('utf-8', 'backslashreplace'))
b25d4368 517 self.buffer = pieces[1]
a4eacae2 518
b25d4368 519 return lines
a12f7519 520
28d06664 521 def __str__(self): return self.parent.nick
a12f7519 522 def __repr__(self): return "<BotConnection %r (%r)>" % (self.socket.fileno(), self.parent.nick)