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