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