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