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