]> jfr.im git - erebus.git/blob - bot.py
update comments
[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 os, random, socket, struct, sys, threading, time, traceback, fcntl
8 from collections import deque
9
10 if sys.version_info.major < 3:
11 timerbase = threading._Timer
12 stringbase = basestring
13 else:
14 timerbase = threading.Timer
15 stringbase = str
16
17 class MyTimer(timerbase):
18 def __init__(self, *args, **kwargs):
19 timerbase.__init__(self, *args, **kwargs)
20 self.daemon = True
21
22 if sys.version_info.major < 3:
23 stringbase = basestring
24 else:
25 stringbase = str
26
27 #bots = {'erebus': bot.Bot(nick='Erebus', user='erebus', bind='', server='irc.quakenet.org', port=6667, realname='Erebus')}
28 class Bot(object):
29 def __init__(self, parent, nick, user, bind, authname, authpass, server, port, realname):
30 self.maxlen = 510
31
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 self.connecttime = 0 # time at which we received numeric 001
42 self.server = server # the address we try to (re-)connect to
43 self.port = port
44 self.servername = server # the name of the server we got connected to
45
46 curs = self.parent.query("SELECT chname FROM chans WHERE bot = %s AND active = 1", (self.permnick,))
47 if curs:
48 chansres = curs.fetchall()
49 curs.close()
50 self.chans = [self.parent.newchannel(self, row['chname']) for row in chansres]
51 else:
52 self.chans = []
53
54 self.conn = BotConnection(self, bind, server, port)
55
56 self.lastreceived = time.time() #time we last received a line from the server
57 self.watchdog()
58
59 self.msgqueue = deque()
60 self.slowmsgqueue = deque()
61 self._makemsgtimer()
62 self._msgtimer.start()
63 self.joined_chans = False
64
65 def __del__(self):
66 try:
67 curs = self.parent.query("UPDATE bots SET connected = 0 WHERE nick = %s", (self.permnick,))
68 curs.close()
69 except: pass
70
71 def watchdog(self):
72 if time.time() > int(self.parent.cfg.get('watchdog', 'maxtime', default=300))+self.lastreceived:
73 self.parse("ERROR :Fake-error from watchdog timer.")
74 return
75 if self.conn.registered():
76 self.conn.send("PING :%s" % (time.time()))
77 self._checknick()
78 watchdogtimer = MyTimer(int(self.parent.cfg.get('watchdog', 'interval', default=30)), self.watchdog)
79 watchdogtimer.start()
80
81 def log(self, *args, **kwargs):
82 self.parent.log(self.nick, *args, **kwargs)
83
84 def connect(self):
85 self.log('!', "Connecting")
86 if self.conn.connect():
87 self.log('!', "Connected")
88 self.parent.newfd(self, self.conn.socket.fileno())
89
90 def getdata(self):
91 try:
92 recvd = self.conn.read()
93 self.lastreceived = time.time()
94 return recvd
95 except EOFError as e:
96 return [":%s ERROR :%s%r" % (self.nick, e.__class__.__name__, e.args)]
97
98 def _checknick(self): # check if we're using the right nick, try changing
99 if self.nick != self.permnick and self.conn.registered():
100 self.conn.send("NICK %s" % (self.permnick))
101
102 def parse(self, line):
103 pieces = line.split()
104
105 if pieces[0][0] == ":":
106 numeric = pieces[1]
107 else:
108 numeric = pieces[0]
109
110 # dispatch dict
111 dispatch = {
112 'NOTICE': self._gotconnected,
113 '001': self._got001,
114 '004': self._got004,
115 '376': self._gotRegistered,
116 '422': self._gotRegistered,
117 'PRIVMSG': self._gotprivmsg,
118 '353': self._got353, #NAMES
119 '354': self._got354, #WHO
120 '396': self._gotHiddenHost, # hidden host has been set
121 '433': self._got433, #nick in use
122 '437': self._got433, #nick protected
123 'JOIN': self._gotjoin,
124 'PART': self._gotpart,
125 'KICK': self._gotkick,
126 'QUIT': self._gotquit,
127 'NICK': self._gotnick,
128 'MODE': self._gotmode,
129 'PING': self._gotping,
130 'ERROR': self._goterror,
131 }
132
133 if self.parent.hasnumhook(numeric):
134 hooks = self.parent.getnumhook(numeric)
135 for callback in hooks:
136 try:
137 callback(self, line)
138 except Exception:
139 self._cbexception("numhook", line)
140
141 if numeric in dispatch:
142 dispatch[numeric](pieces)
143
144 def _gotconnected(self, pieces):
145 if not self.conn.registered():
146 self.conn.register()
147 def _gotping(self, pieces):
148 self.conn.send("PONG %s" % (pieces[1]))
149 self._checknick()
150 def _goterror(self, pieces):
151 # TODO: better handling, just reconnect that single bot
152 error = ' '.join(pieces)
153 try:
154 raise Exception(error)
155 except Exception as e:
156 self.parent.mustquit = e
157 try:
158 self.quit("Error detected: %s" % (error))
159 except: pass
160 try:
161 curs = self.parent.query("UPDATE bots SET connected = 0")
162 curs.close()
163 except: pass
164 self.log('!', 'Bot exiting due to: %s' % (error))
165 def _got001(self, pieces):
166 # We wait until the end of MOTD instead to consider ourselves registered, but consider uptime as of 001
167 self.connecttime = time.time()
168 def _got004(self, pieces):
169 self.servername = pieces[3]
170 def _gotRegistered(self, pieces):
171 self.conn.registered(True)
172
173 curs = self.parent.query("UPDATE bots SET connected = 1 WHERE nick = %s", (self.permnick,))
174 if curs: curs.close()
175
176 self.conn.send("MODE %s +x" % (pieces[2]))
177 if self.authname is not None and self.authpass is not None:
178 self.conn.send(self.parent.cfg.get('erebus', 'auth_command', "AUTH %s %s") % (self.authname, self.authpass))
179 if not self.parent.cfg.getboolean('erebus', 'wait_for_hidden_host'):
180 for c in self.chans:
181 self.join(c.name)
182 self.joined_chans = True
183 def _gotHiddenHost(self, pieces):
184 if not self.joined_chans and self.parent.cfg.getboolean('erebus', 'wait_for_hidden_host'):
185 for c in self.chans:
186 self.join(c.name)
187 self.joined_chans = True
188 def _gotprivmsg(self, pieces):
189 nick = pieces[0].split('!')[0][1:]
190 user = self.parent.user(nick)
191 target = pieces[2]
192 msg = ' '.join(pieces[3:])[1:]
193 self.parsemsg(user, target, msg)
194 def _got353(self, pieces):
195 prefixes = {'@': 'op', '+': 'voice'}
196 chan = self.parent.channel(pieces[4])
197 names = pieces[5:]
198 names[0] = names[0][1:] #remove colon
199 for n in names:
200 if n[0] in prefixes:
201 user = self.parent.user(n[1:])
202 chan.userjoin(user, prefixes[n[0]])
203 else:
204 user = self.parent.user(n)
205 chan.userjoin(user)
206 user.join(chan)
207 def _got354(self, pieces):
208 qt = int(pieces[3])
209 if qt < 3:
210 nick, auth = pieces[4:6]
211 chan = None
212 else:
213 chan, nick, auth = pieces[4:7]
214 chan = self.parent.channel(chan)
215 user = self.parent.user(nick)
216 user.authed(auth)
217
218 if chan is not None:
219 user.join(chan)
220 chan.userjoin(user)
221
222 if qt == 2: # triggered by !auth
223 if user.isauthed():
224 if user.glevel > 0:
225 self.msg(nick, "You are now known as #%s (access level: %s)" % (auth, user.glevel))
226 else:
227 self.msg(nick, "You are now known as #%s (not staff)" % (auth))
228 else:
229 self.msg(nick, "I tried, but you're not authed!")
230 def _got433(self, pieces):
231 if not self.conn.registered(): #we're trying to connect
232 newnick = "%s%d" % (self.nick, random.randint(111, 999))
233 self.conn.send("NICK %s" % (newnick))
234 self.nick = newnick
235 def _gotjoin(self, pieces):
236 nick = pieces[0].split('!')[0][1:]
237 chan = self.parent.channel(pieces[2])
238
239 if nick == self.nick:
240 self.conn.send("WHO %s c%%cant,3" % (chan))
241 else:
242 user = self.parent.user(nick, send_who=True)
243 chan.userjoin(user)
244 user.join(chan)
245 def _clientLeft(self, nick, chan):
246 if nick == self.nick:
247 for u in chan.users:
248 if u.nick != self.nick:
249 self._clientLeft(u.nick, chan)
250 else:
251 user = self.parent.user(nick)
252 gone = user.part(chan)
253 chan.userpart(user)
254 if gone:
255 user.quit()
256 del self.parent.users[nick.lower()]
257 def _gotpart(self, pieces):
258 nick = pieces[0].split('!')[0][1:]
259 chan = self.parent.channel(pieces[2])
260 self._clientLeft(nick, chan)
261 def _gotkick(self, pieces):
262 nick = pieces[3]
263 chan = self.parent.channel(pieces[2])
264 self._clientLeft(nick, chan)
265 def _gotquit(self, pieces):
266 nick = pieces[0].split('!')[0][1:]
267 if nick != self.nick:
268 for chan in self.parent.user(nick).chans:
269 chan.userpart(self.parent.user(nick))
270 self.parent.user(nick).quit()
271 del self.parent.users[nick.lower()]
272 def _gotnick(self, pieces):
273 oldnick = pieces[0].split('!')[0][1:]
274 newnick = pieces[2][1:]
275 if oldnick == self.nick:
276 self.nick = newnick
277 else:
278 if newnick.lower() != oldnick.lower():
279 self.parent.users[newnick.lower()] = self.parent.users[oldnick.lower()]
280 del self.parent.users[oldnick.lower()]
281 self.parent.users[newnick.lower()].nickchange(newnick)
282 def _gotmode(self, pieces):
283 source = pieces[0].split('!')[0][1:]
284 chan = pieces[2]
285 if not chan.startswith("#"): return
286 chan = self.parent.channel(pieces[2])
287 mode = pieces[3]
288 args = pieces[4:]
289
290 adding = True
291 for c in mode:
292 if c == '+':
293 adding = True
294 elif c == '-':
295 adding = False
296 elif c == 'o':
297 if adding:
298 chan.userop(self.parent.user(args.pop(0)))
299 else:
300 chan.userdeop(self.parent.user(args.pop(0)))
301 elif c == 'v':
302 if adding:
303 chan.uservoice(self.parent.user(args.pop(0)))
304 else:
305 chan.userdevoice(self.parent.user(args.pop(0)))
306 else:
307 pass # don't care about other modes
308
309 def _cbexception(self, source, *args, chained=False, **kwargs):
310 if not chained: # skip hooks if we were caused by a hook
311 exc = sys.exception()
312 if self.parent.hasexceptionhook(exc):
313 for callback in self.parent.getexceptionhook(exc):
314 try:
315 callback(self, exc, source, *args, **kwargs)
316 except Exception:
317 self._cbexception('exceptionhook', chained=True, module=callback.__module__, function=callback.__name__, underlying=(source, args, kwargs))
318 if self.parent.cfg.getboolean('debug', 'cbexc'):
319 self.conn.send("PRIVMSG %s :%09.3f \ 34\1f!!! CBEXC\1f\ 3 %s" % (self.parent.cfg.get('debug', 'owner'), time.time() % 100000, source))
320 traceback.print_exc(chain=not chained)
321 self.log('!', "CBEXC %s %r %r" % (source, args, kwargs))
322
323
324 def parsemsg(self, user, target, msg):
325 if user.glevel <= -2: return # short circuit if user is IGNORED
326 chan = None
327 chanparam = None # was the channel specified as part of the command?
328 if len(msg) == 0:
329 return
330
331 if target == self.nick and msg.startswith("\001"): #ctcp
332 msg = msg.strip("\001")
333 if msg:
334 pieces = msg.split()
335 if pieces[0] == "CLIENTINFO":
336 self.msg(user, "\001CLIENTINFO VERSION PING\001")
337 elif pieces[0] == "VERSION":
338 self.msg(user, "\001VERSION Erebus v%d.%d - http://jfr.im/git/erebus.git\001" % (self.parent.APIVERSION, self.parent.RELEASE))
339 elif pieces[0] == "PING":
340 if len(pieces) > 1:
341 self.msg(user, "\001PING %s\001" % (' '.join(pieces[1:])))
342 else:
343 self.msg(user, "\001PING\001")
344 return
345
346 triggerused = msg.startswith(self.parent.trigger)
347 if triggerused: msg = msg[len(self.parent.trigger):]
348 pieces = msg.split()
349
350 if len(pieces) == 0:
351 return
352
353 if target != self.nick: # message was sent to a channel
354 try:
355 if pieces[0][:-1].lower() == self.nick.lower() and (pieces[0][-1] == ":" or pieces[0][-1] == ","):
356 pieces.pop(0) # command actually starts with next word
357 if len(pieces) == 0: # is there still anything left?
358 return
359 msg = ' '.join(pieces)
360 triggerused = True
361 except IndexError:
362 return # "message" is empty
363
364 if len(pieces) > 1:
365 chanword = pieces[1]
366 if chanword.startswith('#'):
367 chanparam = self.parent.channel(chanword)
368
369 if target != self.nick: # message was sent to a channel
370 chan = self.parent.channel(target)
371 if not triggerused:
372 if self.parent.haschanhook(target.lower()):
373 for callback in self.parent.getchanhook(target.lower()):
374 try:
375 cbret = callback(self, user, chan, *pieces)
376 if isinstance(cbret, stringbase):
377 self.reply(target, user, cbret)
378 except:
379 self.msg(user, "Command failed. Code: CBEXC%09.3f" % (time.time() % 100000))
380 self._cbexception("chanhook", user=user, target=target, msg=msg)
381 return # not to bot, don't process!
382
383 cmd = pieces[0].lower()
384 rancmd = False
385 if self.parent.hashook(cmd):
386 for callback in self.parent.gethook(cmd):
387 if chanparam is not None and (callback.needchan or callback.wantchan):
388 chan = chanparam
389 pieces.pop(1)
390 if chan is None and callback.needchan:
391 rancmd = True
392 self.msg(user, "You need to specify a channel for that command.")
393 elif user.glevel >= callback.reqglevel and (not callback.needchan or chan.levelof(user.auth) >= callback.reqclevel):
394 rancmd = True
395 try:
396 cbret = callback(self, user, chan, target, *pieces[1:])
397 if isinstance(cbret, stringbase):
398 self.reply(target, user, cbret)
399 except Exception:
400 self.msg(user, "Command failed. Code: CBEXC%09.3f" % (time.time() % 100000))
401 self._cbexception("hook", user=user, target=target, msg=msg)
402 except SystemExit as e:
403 self.parent.mustquit = e
404 try:
405 curs = self.parent.query("UPDATE bots SET connected = 0")
406 curs.close()
407 except: pass
408 raise e
409 else:
410 rancmd = True
411 self.msg(user, "I don't know that command.")
412 if not rancmd:
413 self.msg(user, "You don't have enough access to run that command.")
414
415 def __debug_nomsg(self, target, msg):
416 if self.parent.cfg.getboolean('debug', 'nomsg'):
417 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))
418 self.log('!', "!!! NOMSG")
419 # print "%09.3f %s [!] %s" % (time.time() % 100000, self.nick, "!!! NOMSG")
420 traceback.print_stack()
421
422
423 def reply(self, chan, user, msg):
424 if chan is not None and (isinstance(chan, self.parent.Channel) or (isinstance(chan, stringbase) and chan[0] == "#")):
425 self.msg(chan, "%s: %s" % (user, msg))
426 else:
427 self.msg(user, msg)
428
429 """
430 Does the work for msg/slowmsg/fastmsg. Uses the append_callback to append to the correct queue.
431
432 In the case of fastmsg, self.conn.exceeded may be True, however, in this case append_callback=self.conn.send, so it will still be sent immediately.
433 """
434 def _msg(self, target, msg, truncate, append_callback, msgtype):
435 if self.parent.cfg.getboolean('erebus', 'nofakelag'): append_callback = self.conn.send
436
437 cmd = self._formatmsg(target, msg, msgtype)
438 # The max length is much shorter than conn.maxlen (510) because of the length the server adds on about the source (us).
439 # If you know your hostmask, you can of course figure the exact length, but it's very difficult to reliably know your hostmask.
440 maxlen = self.maxmsglen()
441 if len(cmd) > maxlen:
442 if not truncate:
443 return False
444 else:
445 cmd = cmd[:maxlen]
446
447 if self.conn.exceeded or self.conn.bytessent+len(cmd) >= self.conn.recvq:
448 append_callback(cmd)
449 else:
450 self.conn.send(cmd)
451
452 self.conn.exceeded = True
453 return True
454
455 def msg(self, target, msg, truncate=False, *, msgtype=None):
456 """msgtype must be a valid IRC command, i.e. NOTICE or PRIVMSG; or leave as None to use default"""
457 return self._msg(target, msg, truncate, self.msgqueue.append, msgtype)
458
459 def slowmsg(self, target, msg, truncate=False, *, msgtype=None):
460 return self._msg(target, msg, truncate, self.slowmsgqueue.append, msgtype)
461
462 def fastmsg(self, target, msg, truncate=False, *, msgtype=None):
463 return self._msg(target, msg, truncate, self.conn.send, msgtype)
464
465 def _formatmsg(self, target, msg, msgtype):
466 if target is None or msg is None:
467 return self.__debug_nomsg(target, msg)
468
469 target = str(target)
470
471 if msgtype is not None: command = "%s %s :%s" % (msgtype, target, msg)
472 elif target.startswith('#'): command = "PRIVMSG %s :%s" % (target, msg)
473 else: command = "NOTICE %s :%s" % (target, msg)
474
475 return command
476
477 def _popmsg(self):
478 self._makemsgtimer()
479 self.conn.bytessent -= self.conn.recvq/3
480 if self.conn.bytessent < 0: self.conn.bytessent = 0
481 self.conn.exceeded = True
482
483 cmd = None
484 try:
485 cmd = self.msgqueue.popleft()
486 except IndexError:
487 try:
488 cmd = self.slowmsgqueue.popleft()
489 except IndexError:
490 pass
491
492 if cmd is not None:
493 if self.conn.bytessent+len(cmd) > self.conn.recvq: # If it's too long
494 if len(cmd) > self.conn.recvq: # Is the command itself somehow over max length???
495 self._msgtimer.start()
496 raise ValueError('Somehow a command that was too long made it into the message queue. Uhoh!', cmd)
497 # Discard the message.
498 self.msgqueue.appendleft(cmd) # Phew, we've just sent too much recently. Put it (back) on the (primary) queue.
499 else:
500 self.conn.send(cmd)
501
502 self._msgtimer.start()
503
504 def _makemsgtimer(self):
505 self._msgtimer = MyTimer(3, self._popmsg)
506
507 def join(self, chan):
508 self.conn.send("JOIN %s" % (chan))
509
510 def part(self, chan):
511 self.conn.send("PART %s" % (chan))
512
513 def quit(self, reason="Shutdown"):
514 self.conn.send("QUIT :%s" % (reason))
515
516 def maxmsglen(self):
517 return (
518 self.maxlen
519 - 63 # max hostname len
520 - 11 # max ident len
521 - 3 # the symbols in :nick!user@host
522 - len(self.nick)
523 )
524
525 def __str__(self): return self.nick
526 def __repr__(self): return "<Bot %r>" % (self.nick)
527
528 class BotConnection(object):
529 def __init__(self, parent, bind, server, port):
530 self.parent = parent
531 self.buffer = bytearray()
532 self.socket = None
533
534 self.bind = bind
535 self.server = server
536 self.port = int(port)
537
538 self.state = 0 # 0=disconnected, 1=registering, 2=connected
539
540 self.bytessent = 0
541 self.recvq = 510 # How much we can send per period
542 self.exceeded = False
543 self._nowrite = False
544
545 def connect(self):
546 if self.parent.parent.cfg.getboolean('erebus', 'tls'):
547 import ssl
548 undersocket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
549 context = ssl.create_default_context()
550 self.socket = context.wrap_socket(undersocket, server_hostname=self.server)
551 else:
552 self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
553 self.socket.setsockopt(socket.SOL_TCP, socket.TCP_NODELAY, 1) # Does Python make SOL_TCP portable? Who knows, it's not documented, and it appears to come from the _socket C lib.
554 self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, struct.pack('ii', 0, 0))
555 self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
556 self.socket.bind((self.bind, 0))
557 self._write_oidentd()
558 self.socket.connect((self.server, self.port))
559 return True
560 def register(self):
561 if self.state == 0:
562 pss = self.parent.parent.cfg.get('erebus', 'pass')
563 if pss:
564 self.send("PASS %s" % (pss))
565 self.send("NICK %s" % (self.parent.nick))
566 self.send("USER %s 0 * :%s" % (self.parent.user, self.parent.realname))
567 self.state = 1
568 return True
569
570 def registered(self, done=False):
571 if done:
572 self.state = 2
573 self._unwrite_oidentd()
574 return self.state == 2
575
576 def send(self, line):
577 if not self._nowrite:
578 if self.parent.parent.cfg.getboolean('debug', 'io'):
579 self.parent.log('O', line)
580 self.bytessent += len(line)
581 try:
582 self._write(line)
583 except socket.error as e:
584 self._nowrite = True
585 self.parent._goterror(repr(e))
586 else:
587 if self.parent.parent.cfg.getboolean('debug', 'io'):
588 self.parent.log('X', line)
589
590 def _write(self, line):
591 self.socket.sendall(line.encode('utf-8', 'surrogateescape')+b"\r\n")
592
593 def _getsockerr(self):
594 try: # SO_ERROR might not exist on all platforms
595 return self.socket.getsockopt(socket.SOL_SOCKET, socket.SO_ERROR)
596 except:
597 return None
598
599 def read(self):
600 recvd = self.socket.recv(8192)
601 if recvd == b"":
602 raise EOFError("socket.recv returned empty", self.parent.nick, self._getsockerr())
603 self.buffer += recvd
604 lines = []
605
606 while b"\r\n" in self.buffer:
607 pieces = self.buffer.split(b"\r\n", 1)
608 lines.append(pieces[0].decode('utf-8', 'backslashreplace'))
609 self.buffer = pieces[1]
610
611 return lines
612
613 def _format_oidentd(self):
614 ident = self.parent.user
615 fport = self.parent.port
616 from_ = self.bind
617 lport = self.socket.getsockname()[1]
618 if from_:
619 return 'fport %s from %s lport %s { reply "%s" }\n' % (fport, from_, lport, ident)
620 else:
621 return 'fport %s lport %s { reply "%s" }\n' % (fport, lport, ident)
622 def _write_oidentd(self):
623 path = self.parent.parent.cfg.get('erebus', 'oidentd_path')
624 if path is not None:
625 with open(path, 'a') as fh:
626 fcntl.lockf(fh, fcntl.LOCK_EX)
627 fh.write(self._format_oidentd())
628 fcntl.lockf(fh, fcntl.LOCK_UN)
629 def _unwrite_oidentd(self):
630 path = self.parent.parent.cfg.get('erebus', 'oidentd_path')
631 if path is not None:
632 with open(path, 'r+') as fh:
633 fcntl.lockf(fh, fcntl.LOCK_EX)
634 data = fh.read()
635 newdata = data.replace(self._format_oidentd(), '')
636 fh.seek(0)
637 fh.write(newdata)
638 fh.truncate()
639 fcntl.lockf(fh, fcntl.LOCK_UN)
640
641 def __str__(self): return self.parent.nick
642 def __repr__(self): return "<BotConnection %r (%r)>" % (self.socket.fileno(), self.parent.nick)