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