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