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