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