1 # Erebus IRC bot - Author: John Runyon
2 # vim: fileencoding=utf-8
3 # module helper functions, see modules/modtest.py for usage
4 # This file is released into the public domain; see http://unlicense.org/
9 from functools
import wraps
11 if sys
.version_info
.major
< 3:
12 stringbase
= basestring
16 """Used to return an error to the bot core."""
18 def __init__(self
, desc
):
20 def __nonzero__(self
):
21 return False #object will test to False
22 __bool__
= __nonzero__
#py3 compat
24 return '<modlib.error %r>' % self
.errormsg
26 return str(self
.errormsg
)
29 # default (global) access levels
35 AUTHED
= 0 # Users which have are known to be authed
36 ANYONE
= -1 # non-authed users have glevel set to -1
37 IGNORED
= -2 # If the user is IGNORED, no hooks or chanhooks will fire for their messages. numhooks can still be fired.
49 # (channel) access levels
55 PUBLIC
= 0 # Anyone (use glevel to control whether auth is needed)
56 BANNED
= -1 # The default reqclevel is PUBLIC, so any commands which needchan will be ignored from BANNED users unless the command reqclevel=-1
58 clevs
= [None, 'Friend', 'Voice', 'Op', 'Master', 'Owner', 'Banned']
61 WRONGARGS
= "Wrong number of arguments."
63 def __init__(self
, name
):
64 self
.Socketlike
= Socketlike
65 self
.hooks
= {} # {command:handler}
66 self
.chanhooks
= {} # {channel:handler}
67 self
.exceptionhooks
= [] # [(exception,handler)]
68 self
.numhooks
= {} # {numeric:handler}
69 self
.sockhooks
= [] # [(af,ty,address,handler_class)]
70 self
.sockets
= [] # [(sock,obj)]
74 self
.name
= (name
.split("."))[-1]
76 def modstart(self
, parent
):
77 #modstart can return a few things...
78 # None: unspecified success
79 # False: unspecified error
80 # modlib.error (or anything else False-y): specified error
81 # True: unspecified success
82 # non-empty string (or anything else True-y): specified success
83 #"specified" values will be printed. unspecified values will result in "OK" or "failed"
85 for cmd
, func
in self
.hooks
.items():
86 parent
.hook(cmd
, func
)
87 parent
.hook("%s.%s" % (self
.name
, cmd
), func
)
88 for chan
, func
in self
.chanhooks
.items():
89 parent
.hookchan(chan
, func
)
90 for exc
, func
in self
.exceptionhooks
:
91 parent
.hookexception(exc
, func
)
92 for num
, func
in self
.numhooks
.items():
93 parent
.hooknum(num
, func
)
94 for hookdata
in self
.sockhooks
:
95 self
._create
_socket
(*hookdata
)
97 for func
, args
, kwargs
in self
.helps
:
99 self
.mod('help').reghelp(func
, *args
, **kwargs
)
103 def modstop(self
, parent
):
104 for cmd
, func
in self
.hooks
.items():
105 parent
.unhook(cmd
, func
)
106 parent
.unhook("%s.%s" % (self
.name
, cmd
), func
)
107 for chan
, func
in self
.chanhooks
.items():
108 parent
.unhookchan(chan
, func
)
109 for exc
, func
in self
.exceptionhooks
:
110 parent
.unhookexception(exc
, func
)
111 for num
, func
in self
.numhooks
.items():
112 parent
.unhooknum(num
, func
)
113 for sock
, obj
in self
.sockets
:
114 self
._destroy
_socket
(sock
, obj
)
116 for func
, args
, kwargs
in self
.helps
:
118 self
.mod('help').dereghelp(func
, *args
, **kwargs
)
123 def hook(self
, cmd
=None, needchan
=True, glevel
=ANYONE
, clevel
=PUBLIC
, wantchan
=None):
124 if wantchan
is None: wantchan
= needchan
125 _cmd
= cmd
#save this since it gets wiped out...
127 cmd
= _cmd
#...and restore it
129 cmd
= func
.__name
__ # default to function name
130 if isinstance(cmd
, stringbase
):
133 if clevel
> self
.PUBLIC
and not needchan
:
134 raise Exception('clevel must be left at default if needchan is False')
136 func
.needchan
= needchan
137 func
.wantchan
= wantchan
138 func
.reqglevel
= glevel
139 func
.reqclevel
= clevel
141 func
.module
= func
.__module
__.split('.')[1]
145 if self
.parent
is not None:
146 self
.parent
.hook(c
, func
)
147 self
.parent
.hook("%s.%s" % (self
.name
, c
), func
)
151 def hookchan(self
, chan
, glevel
=ANYONE
, clevel
=PUBLIC
):
153 self
.chanhooks
[chan
] = func
154 if self
.parent
is not None:
155 self
.parent
.hookchan(chan
, func
)
159 def hookexception(self
, exc
):
161 self
.exceptionhooks
.append((exc
, func
))
162 if self
.parent
is not None:
163 self
.parent
.hookexception(exc
, func
)
167 def hooknum(self
, num
):
169 self
.numhooks
[str(num
)] = func
170 if self
.parent
is not None:
171 self
.parent
.hooknum(str(num
), func
)
175 def bind(self
, bindto
, data
=None):
176 """Used as a decorator on a class which implements getdata and parse methods.
177 See modules/sockets.py for an example.
183 raise Exception('bindto must have a value')
185 return self
._hooksocket
(socket
.AF_UNIX
, socket
.SOCK_STREAM
, bindto
)
186 if len(bindto
) > 5 and bindto
[0:5] == 'unix:':
187 return self
._hooksocket
(socket
.AF_UNIX
, socket
.SOCK_STREAM
, bindto
[5:])
189 ty
= socket
.SOCK_STREAM
191 if len(bindto
) > 4 and bindto
[0:4] == 'udp:':
192 ty
= socket
.SOCK_DGRAM
194 if len(bindto
) > 4 and bindto
[0:4] == 'tcp:':
197 pieces
= bindto
.rsplit(':', 1)
201 return self
._hooksocket
(af
, ty
, (host
, port
), data
)
203 def bind_tcp(self
, host
, port
, data
=None):
204 return self
._hooksocket
(socket
.AF_INET
, socket
.SOCK_STREAM
, (host
, port
), data
)
205 def bind_udp(self
, host
, port
, data
=None):
206 return self
._hooksocket
(socket
.AF_INET
, socket
.SOCK_DGRAM
, (host
, port
), data
)
207 def bind_unix(self
, path
, data
=None):
208 return self
._hooksocket
(socket
.AF_UNIX
, socket
.SOCK_STREAM
, path
, data
)
209 def _hooksocket(self
, af
, ty
, address
, data
):
211 if not issubclass(cls
, Socketlike
):
212 raise Exception('Attempted to hook a socket without a class to process data')
213 self
.sockhooks
.append((af
, ty
, address
, cls
, data
))
214 if self
.parent
is not None:
215 self
._create
_socket
(af
, ty
, address
, cls
, data
)
218 def _create_socket(self
, af
, ty
, address
, cls
, data
):
219 ty
= ty | socket
.SOCK_NONBLOCK
220 sock
= socket
.socket(af
, ty
)
221 sock
.setsockopt(socket
.SOL_SOCKET
, socket
.SO_REUSEADDR
, 1)
223 obj
= _ListenSocket(self
, sock
, cls
, data
)
224 self
.sockets
.append((sock
,obj
))
226 self
.parent
.newfd(obj
, sock
.fileno())
227 self
.parent
.log(repr(obj
), '?', 'Socket ready to accept new connections (%r, %r, %r, %r)' % (af
, ty
, address
, cls
))
228 def _destroy_socket(self
, sock
, obj
):
231 def mod(self
, modname
):
232 if self
.parent
is not None:
233 return self
.parent
.module(modname
)
235 return error('unknown parent')
238 def flags(self
, *flags
):
239 """Parses out "flags" to a command, like `MODUNLOAD -AUTOLOAD somemodule`
241 @lib.flags('autoload', 'force')
242 def baz(bot, user, chan, realtarget, flags, *args)
244 Note the added `flags` argument, which will be a dict - in this case `{'autounload':true,'force':false}`."""
246 func
.flags
= [f
.lower() for f
in flags
]
249 def parseargs(bot
, user
, chan
, realtarget
, *_args
):
250 args
= list(_args
) # we need a copy, also need a list, iterate over _args-tuple, mutate args-list
251 found_flags
= {f: False for f in flags}
253 if arg
[0] == "-" and len(arg
) > 1:
255 possible_flag
= arg
[1:].lower()
257 if possible_flag
== f
: # Exact match?
258 found_flags
[possible_flag
] = True
262 elif f
.find(possible_flag
) == 0: # Is the current word a prefix of a flag?
263 if found_prefix
is not None: # Is it also a prefix of another flag?
264 return 'Error: %s is a prefix of multiple flags (%s, %s).' % (possible_flag
, found_prefix
[1], f
)
266 found_prefix
= (arg
,f
)
267 if found_prefix
is not None: # found (only one) prefix
268 found_flags
[found_prefix
[1]] = True
269 args
.remove(found_prefix
[0])
270 return func(bot
, user
, chan
, realtarget
, found_flags
, *args
)
276 def argsEQ(self
, num
):
279 def checkargs(bot
, user
, chan
, realtarget
, *args
):
281 if hasattr(checkargs
, 'flags'):
283 if len(args
)-adjuster
== num
:
284 return func(bot
, user
, chan
, realtarget
, *args
)
286 bot
.msg(user
, self
.WRONGARGS
)
290 def argsGE(self
, num
):
293 def checkargs(bot
, user
, chan
, realtarget
, *args
):
295 if hasattr(checkargs
, 'flags'):
297 if len(args
)-adjuster
>= num
:
298 return func(bot
, user
, chan
, realtarget
, *args
)
300 bot
.msg(user
, self
.WRONGARGS
)
304 def help(self
, *args
, **kwargs
):
305 """help(syntax, shorthelp, longhelp?, more lines longhelp?, cmd=...?)
307 help("<user> <pass>", "login")
308 ^ Help will only be one line. Command name determined based on function name.
309 help("<user> <level>", "add a user", cmd=("adduser", "useradd"))
310 ^ Help will be listed under ADDUSER; USERADD will say "alias for adduser"
311 help(None, "do stuff", "This command is really complicated.")
312 ^ Command takes no args. Short description (in overall HELP listing) is "do stuff".
313 Long description (HELP <command>) will say "<command> - do stuff", newline, "This command is really complicated."
316 if self
.parent
is not None:
318 self
.mod('help').reghelp(func
, *args
, **kwargs
)
321 self
.helps
.append((func
, args
, kwargs
))
325 class Socketlike(abc
.ABC
):
326 def __init__(self
, sock
, data
):
327 """This default method saves the socket in self.sock and creates self.buffer for getdata(). The data is discarded."""
332 """This default method gets LF or CRLF separated lines from the socket and returns an array of completely-seen lines to the core.
333 This should work well for most line-based protocols (like IRC)."""
334 recvd
= self
.sock
.recv(8192)
335 if recvd
== b
"": # EOF
336 if len(self
.buffer) != 0:
337 # Process what's left in the buffer. We'll get called again after.
338 remaining_buf
= self
.buffer.decode('utf-8', 'backslashreplace')
340 return [remaining_buf
]
342 # Nothing left in the buffer. Return None to signal the core to close this socket.
347 while b
"\n" in self
.buffer:
348 pieces
= self
.buffer.split(b
"\n", 1)
349 s
= pieces
[0].decode('utf-8', 'backslashreplace').rstrip("\r")
350 lines
.append(pieces
[0].decode('utf-8', 'backslashreplace'))
351 self
.buffer = pieces
[1]
356 return '%s#%d' % (self
.__class
__.__name
__, self
.sock
.fileno())
358 return '<%s.%s #%d %s:%d>' % ((self
.__class
__.__module
__, self
.__class
__.__name
__, self
.sock
.fileno())+self
.sock
.getpeername())
361 def parse(self
, chunk
): pass
364 def __subclasshook__(cls
, C
):
365 if cls
is Socketlike
:
366 if any('parse' in B
.__dict
__ and 'getdata' in B
.__dict
__ for B
in C
.__mro
__):
368 return NotImplemented
370 class _ListenSocket(Socketlike
):
371 def __init__(self
, lib
, sock
, cls
, data
):
378 def _make_closer(self
, obj
, client
):
380 self
.lib
.parent
.log(repr(self
), '?', 'Closing child socket #%d' % (client
.fileno()))
383 except AttributeError:
385 self
.lib
.parent
.delfd(client
.fileno())
386 client
.shutdown(socket
.SHUT_RDWR
)
388 self
.clients
.remove((client
,obj
))
392 # getdata will never return a non-empty array, so parse will never be called; but Socketlike requires this method
396 client
, addr
= self
.sock
.accept()
397 obj
= self
.cls(client
, self
.data
)
398 obj
.close
= self
._make
_closer
(obj
, client
)
399 self
.lib
.parent
.log(repr(self
), '?', 'New connection #%d from %s' % (client
.fileno(), addr
))
400 self
.clients
.append((client
,obj
))
401 self
.lib
.parent
.newfd(obj
, client
.fileno())
405 self
.lib
.parent
.log(repr(self
), '?', 'Socket closing')
406 if self
.sock
.fileno() != -1:
407 self
.lib
.parent
.delfd(self
.sock
.fileno())
408 self
.sock
.shutdown(socket
.SHUT_RDWR
)
410 for client
, obj
in self
.clients
:
413 def __repr__(self
): return '<_ListenSocket #%d>' % (self
.sock
.fileno())