2015-07-11 05:26:46 +02:00
|
|
|
# relay.py: PyLink Relay plugin
|
|
|
|
import sys
|
|
|
|
import os
|
|
|
|
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
|
|
import pickle
|
|
|
|
import sched
|
|
|
|
import threading
|
|
|
|
import time
|
2015-07-13 01:59:49 +02:00
|
|
|
import string
|
2015-07-13 08:28:54 +02:00
|
|
|
from collections import defaultdict
|
2015-07-11 05:26:46 +02:00
|
|
|
|
|
|
|
import utils
|
|
|
|
from log import log
|
|
|
|
|
|
|
|
dbname = "pylinkrelay.db"
|
2015-07-13 08:28:54 +02:00
|
|
|
relayusers = defaultdict(dict)
|
2015-07-14 01:09:22 +02:00
|
|
|
relayservers = defaultdict(dict)
|
2015-07-11 05:26:46 +02:00
|
|
|
|
2015-07-13 08:28:54 +02:00
|
|
|
def normalizeNick(irc, netname, nick, separator="/"):
|
2015-07-12 23:02:17 +02:00
|
|
|
orig_nick = nick
|
|
|
|
protoname = irc.proto.__name__
|
|
|
|
maxnicklen = irc.maxnicklen
|
|
|
|
if protoname == 'charybdis':
|
|
|
|
# Charybdis doesn't allow / in usernames, and will quit with
|
|
|
|
# a protocol violation if there is one.
|
|
|
|
separator = separator.replace('/', '|')
|
|
|
|
nick = nick.replace('/', '|')
|
2015-07-13 01:59:49 +02:00
|
|
|
if nick.startswith(tuple(string.digits)):
|
|
|
|
# On TS6 IRCd-s, nicks that start with 0-9 are only allowed if
|
|
|
|
# they match the UID of the originating server. Otherwise, you'll
|
|
|
|
# get nasty protocol violations!
|
|
|
|
nick = '_' + nick
|
2015-07-12 23:02:17 +02:00
|
|
|
tagnicks = True
|
|
|
|
|
|
|
|
suffix = separator + netname
|
|
|
|
nick = nick[:maxnicklen]
|
|
|
|
# Maximum allowed length of a nickname.
|
|
|
|
allowedlength = maxnicklen - len(suffix)
|
|
|
|
# If a nick is too long, the real nick portion must be cut off, but the
|
|
|
|
# /network suffix must remain the same.
|
|
|
|
|
|
|
|
nick = nick[:allowedlength]
|
|
|
|
nick += suffix
|
|
|
|
while utils.nickToUid(irc, nick):
|
|
|
|
# The nick we want exists? Darn, create another one then.
|
|
|
|
# Increase the separator length by 1 if the user was already tagged,
|
|
|
|
# but couldn't be created due to a nick conflict.
|
|
|
|
# This can happen when someone steals a relay user's nick.
|
|
|
|
new_sep = separator + separator[-1]
|
2015-07-13 08:28:54 +02:00
|
|
|
nick = normalizeNick(irc, netname, orig_nick, separator=new_sep)
|
2015-07-12 23:02:17 +02:00
|
|
|
finalLength = len(nick)
|
|
|
|
assert finalLength <= maxnicklen, "Normalized nick %r went over max " \
|
|
|
|
"nick length (got: %s, allowed: %s!" % (nick, finalLength, maxnicklen)
|
|
|
|
|
|
|
|
return nick
|
|
|
|
|
2015-07-11 05:26:46 +02:00
|
|
|
def loadDB():
|
|
|
|
global db
|
|
|
|
try:
|
|
|
|
with open(dbname, "rb") as f:
|
|
|
|
db = pickle.load(f)
|
|
|
|
except (ValueError, IOError):
|
|
|
|
log.exception("Relay: failed to load links database %s"
|
|
|
|
", creating a new one in memory...", dbname)
|
|
|
|
db = {}
|
|
|
|
|
2015-07-12 22:09:35 +02:00
|
|
|
def exportDB(scheduler):
|
2015-07-13 04:03:18 +02:00
|
|
|
scheduler.enter(30, 1, exportDB, argument=(scheduler,))
|
2015-07-12 22:09:35 +02:00
|
|
|
log.debug("Relay: exporting links database to %s", dbname)
|
2015-07-11 05:26:46 +02:00
|
|
|
with open(dbname, 'wb') as f:
|
|
|
|
pickle.dump(db, f, protocol=4)
|
|
|
|
|
2015-07-13 08:28:54 +02:00
|
|
|
def findRelay(chanpair):
|
|
|
|
if chanpair in db: # This chanpair is a shared channel; others link to it
|
|
|
|
return chanpair
|
|
|
|
# This chanpair is linked *to* a remote channel
|
|
|
|
for name, dbentry in db.items():
|
|
|
|
if chanpair in dbentry['links']:
|
|
|
|
return name
|
2015-07-13 04:03:18 +02:00
|
|
|
|
2015-07-14 04:46:24 +02:00
|
|
|
def findRemoteChan(remotenetname, query):
|
|
|
|
chanpair = findRelay(query)
|
|
|
|
if chanpair is None:
|
|
|
|
return
|
|
|
|
if chanpair[0] == remotenetname:
|
|
|
|
return chanpair[1]
|
|
|
|
else:
|
|
|
|
for link in db[chanpair]['links']:
|
|
|
|
if link[0] == remotenetname:
|
|
|
|
return link[1]
|
|
|
|
|
2015-07-13 09:01:04 +02:00
|
|
|
def initializeChannel(irc, channel):
|
|
|
|
irc.proto.joinClient(irc, irc.pseudoclient.uid, channel)
|
|
|
|
c = irc.channels[channel]
|
|
|
|
relay = findRelay((irc.name, channel))
|
2015-07-14 04:46:24 +02:00
|
|
|
if relay is None:
|
|
|
|
return
|
2015-07-13 09:01:04 +02:00
|
|
|
users = c.users.copy()
|
|
|
|
for link in db[relay]['links']:
|
|
|
|
try:
|
|
|
|
remotenet, remotechan = link
|
|
|
|
users.update(utils.networkobjects[remotechan].channels[remotechan].users)
|
|
|
|
except KeyError:
|
|
|
|
pass
|
|
|
|
log.debug('(%s) relay users: %s', irc, users)
|
|
|
|
relayJoins(irc, channel, users, c.ts, c.modes)
|
2015-07-13 08:28:54 +02:00
|
|
|
|
|
|
|
def handle_join(irc, numeric, command, args):
|
|
|
|
channel = args['channel']
|
|
|
|
if not findRelay((irc.name, channel)):
|
|
|
|
# No relay here, return.
|
|
|
|
return
|
|
|
|
modes = args['modes']
|
|
|
|
ts = args['ts']
|
|
|
|
users = set(args['users'])
|
2015-07-13 09:01:04 +02:00
|
|
|
# users.update(irc.channels[channel].users)
|
|
|
|
relayJoins(irc, channel, users, ts, modes)
|
|
|
|
utils.add_hook(handle_join, 'JOIN')
|
|
|
|
|
2015-07-14 04:53:57 +02:00
|
|
|
def handle_quit(irc, numeric, command, args):
|
|
|
|
ouruser = numeric
|
|
|
|
for netname, user in relayusers[(irc.name, numeric)].items():
|
|
|
|
remoteirc = utils.networkobjects[netname]
|
|
|
|
remoteirc.proto.quitClient(remoteirc, user, args['text'])
|
|
|
|
del relayusers[(irc.name, ouruser)]
|
|
|
|
utils.add_hook(handle_quit, 'QUIT')
|
|
|
|
|
|
|
|
def handle_nick(irc, numeric, command, args):
|
|
|
|
for netname, user in relayusers[(irc.name, numeric)].items():
|
|
|
|
remoteirc = utils.networkobjects[netname]
|
|
|
|
newnick = normalizeNick(remoteirc, irc.name, args['newnick'])
|
|
|
|
remoteirc.proto.nickClient(remoteirc, user, newnick)
|
|
|
|
utils.add_hook(handle_nick, 'NICK')
|
|
|
|
|
|
|
|
def handle_part(irc, numeric, command, args):
|
|
|
|
channel = args['channel']
|
|
|
|
text = args['text']
|
|
|
|
for netname, user in relayusers[(irc.name, numeric)].items():
|
|
|
|
remotechan = findRemoteChan(netname, (irc.name, channel))
|
|
|
|
remoteirc = utils.networkobjects[netname]
|
|
|
|
remoteirc.proto.partClient(remoteirc, user, remotechan, text)
|
|
|
|
utils.add_hook(handle_part, 'PART')
|
|
|
|
|
2015-07-13 09:01:04 +02:00
|
|
|
def relayJoins(irc, channel, users, ts, modes):
|
2015-07-14 04:53:57 +02:00
|
|
|
queued_users = []
|
2015-07-13 08:28:54 +02:00
|
|
|
for user in users:
|
|
|
|
try:
|
|
|
|
if irc.users[user].remote:
|
|
|
|
# Is the .remote atrribute set? If so, don't relay already
|
|
|
|
# relayed clients; that'll trigger an endless loop!
|
|
|
|
continue
|
|
|
|
except AttributeError: # Nope, it isn't.
|
|
|
|
pass
|
|
|
|
if user == irc.pseudoclient.uid:
|
|
|
|
# We don't need to clone the PyLink pseudoclient... That's
|
|
|
|
# meaningless.
|
|
|
|
continue
|
|
|
|
userobj = irc.users[user]
|
|
|
|
userpair_index = relayusers.get((irc.name, user))
|
|
|
|
ident = userobj.ident
|
|
|
|
host = userobj.host
|
|
|
|
realname = userobj.realname
|
|
|
|
log.debug('Okay, spawning %s/%s everywhere', user, irc.name)
|
|
|
|
for name, remoteirc in utils.networkobjects.items():
|
|
|
|
if name == irc.name:
|
|
|
|
# Don't relay things to their source network...
|
|
|
|
continue
|
2015-07-14 01:09:22 +02:00
|
|
|
try: # Spawn our pseudoserver first
|
|
|
|
relayservers[remoteirc.name][irc.name] = sid = \
|
|
|
|
remoteirc.proto.spawnServer(remoteirc, '%s.relay' % irc.name,
|
|
|
|
endburst=False)
|
|
|
|
# We want to wait a little bit for the remote IRCd to send their users,
|
|
|
|
# so we can join them as part of a burst on remote networks.
|
|
|
|
# Because IRC is asynchronous, we can't really control how long
|
|
|
|
# this will take.
|
|
|
|
endburst_timer = threading.Timer(0.5, remoteirc.proto.endburstServer,
|
|
|
|
args=(remoteirc, sid))
|
|
|
|
log.debug('(%s) Setting timer to BURST %s', remoteirc.name, sid)
|
|
|
|
endburst_timer.start()
|
|
|
|
except ValueError:
|
|
|
|
# Server already exists (raised by the protocol module).
|
|
|
|
sid = relayservers[remoteirc.name][irc.name]
|
|
|
|
log.debug('(%s) Have we bursted %s yet? %s', remoteirc.name, sid,
|
|
|
|
remoteirc.servers[sid].has_bursted)
|
|
|
|
nick = normalizeNick(remoteirc, irc.name, userobj.nick)
|
2015-07-13 08:28:54 +02:00
|
|
|
# If the user (stored here as {(netname, UID):
|
|
|
|
# {network1: UID1, network2: UID2}}) exists, don't spawn it
|
|
|
|
# again!
|
|
|
|
u = None
|
|
|
|
if userpair_index is not None:
|
2015-07-13 08:31:26 +02:00
|
|
|
u = userpair_index.get(remoteirc.name)
|
2015-07-13 08:28:54 +02:00
|
|
|
if u is None: # .get() returns None if not found
|
|
|
|
u = remoteirc.proto.spawnClient(remoteirc, nick, ident=ident,
|
2015-07-14 01:09:22 +02:00
|
|
|
host=host, realname=realname,
|
|
|
|
server=sid).uid
|
2015-07-13 08:28:54 +02:00
|
|
|
remoteirc.users[u].remote = irc.name
|
|
|
|
relayusers[(irc.name, userobj.uid)][remoteirc.name] = u
|
|
|
|
remoteirc.users[u].remote = irc.name
|
2015-07-14 04:46:24 +02:00
|
|
|
remotechan = findRemoteChan(remoteirc.name, (irc.name, channel))
|
2015-07-14 01:09:22 +02:00
|
|
|
if not remoteirc.servers[sid].has_bursted:
|
|
|
|
# TODO: join users in batches with SJOIN, not one by one.
|
2015-07-14 03:20:51 +02:00
|
|
|
prefix = ''
|
|
|
|
for pmode in ('owner', 'admin', 'op', 'halfop', 'voice'):
|
|
|
|
if pmode not in remoteirc.cmodes: # Mode isn't supported by IRCd
|
|
|
|
continue
|
|
|
|
# If the user is in the respective list for the prefix
|
|
|
|
# mode (e.g. the op list)
|
|
|
|
if user in irc.channels[channel].prefixmodes[pmode+'s']:
|
|
|
|
prefix += remoteirc.cmodes[pmode]
|
2015-07-14 04:46:24 +02:00
|
|
|
remoteirc.proto.sjoinServer(remoteirc, sid, remotechan, [(prefix, u)], ts=ts)
|
2015-07-14 01:09:22 +02:00
|
|
|
else:
|
2015-07-14 04:46:24 +02:00
|
|
|
remoteirc.proto.joinClient(remoteirc, u, remotechan)
|
2015-07-13 08:28:54 +02:00
|
|
|
|
|
|
|
def removeChannel(irc, channel):
|
2015-07-13 04:03:18 +02:00
|
|
|
if channel not in map(str.lower, irc.serverdata['channels']):
|
|
|
|
irc.proto.partClient(irc, irc.pseudoclient.uid, channel)
|
|
|
|
|
2015-07-13 08:28:54 +02:00
|
|
|
def relay(homeirc, func, args):
|
|
|
|
"""<source IRC network object> <function name> <args>
|
|
|
|
|
|
|
|
Relays a call to <function name>(<args>) to every IRC object's protocol
|
|
|
|
module except the source IRC network's."""
|
|
|
|
for name, irc in utils.networkobjects.items():
|
|
|
|
if name == homeirc.name:
|
|
|
|
continue
|
|
|
|
f = getattr(irc.proto, func)
|
|
|
|
f(*args)
|
|
|
|
|
2015-07-11 05:26:46 +02:00
|
|
|
@utils.add_cmd
|
|
|
|
def create(irc, source, args):
|
|
|
|
"""<channel>
|
|
|
|
|
|
|
|
Creates the channel <channel> over the relay."""
|
|
|
|
try:
|
|
|
|
channel = args[0].lower()
|
|
|
|
except IndexError:
|
|
|
|
utils.msg(irc, source, "Error: not enough arguments. Needs 1: channel.")
|
2015-07-12 22:09:35 +02:00
|
|
|
return
|
2015-07-11 05:26:46 +02:00
|
|
|
if not utils.isChannel(channel):
|
|
|
|
utils.msg(irc, source, 'Error: invalid channel %r.' % channel)
|
|
|
|
return
|
2015-07-13 02:59:09 +02:00
|
|
|
if source not in irc.channels[channel].users:
|
2015-07-11 05:26:46 +02:00
|
|
|
utils.msg(irc, source, 'Error: you must be in %r to complete this operation.' % channel)
|
|
|
|
return
|
|
|
|
if not utils.isOper(irc, source):
|
2015-07-13 02:59:09 +02:00
|
|
|
utils.msg(irc, source, 'Error: you must be opered in order to complete this operation.')
|
2015-07-11 05:26:46 +02:00
|
|
|
return
|
2015-07-13 02:59:09 +02:00
|
|
|
db[(irc.name, channel)] = {'claim': [irc.name], 'links': set(), 'blocked_nets': set()}
|
2015-07-13 08:28:54 +02:00
|
|
|
initializeChannel(irc, channel)
|
2015-07-11 05:26:46 +02:00
|
|
|
utils.msg(irc, source, 'Done.')
|
|
|
|
|
|
|
|
@utils.add_cmd
|
|
|
|
def destroy(irc, source, args):
|
|
|
|
"""<channel>
|
|
|
|
|
|
|
|
Destroys the channel <channel> over the relay."""
|
|
|
|
try:
|
|
|
|
channel = args[0].lower()
|
|
|
|
except IndexError:
|
|
|
|
utils.msg(irc, source, "Error: not enough arguments. Needs 1: channel.")
|
2015-07-12 22:09:35 +02:00
|
|
|
return
|
2015-07-11 05:26:46 +02:00
|
|
|
if not utils.isChannel(channel):
|
|
|
|
utils.msg(irc, source, 'Error: invalid channel %r.' % channel)
|
|
|
|
return
|
|
|
|
if not utils.isOper(irc, source):
|
2015-07-13 02:59:09 +02:00
|
|
|
utils.msg(irc, source, 'Error: you must be opered in order to complete this operation.')
|
2015-07-11 05:26:46 +02:00
|
|
|
return
|
|
|
|
|
2015-07-13 02:59:09 +02:00
|
|
|
if (irc.name, channel) in db:
|
|
|
|
del db[(irc.name, channel)]
|
2015-07-13 08:28:54 +02:00
|
|
|
removeChannel(irc, channel)
|
2015-07-11 05:26:46 +02:00
|
|
|
utils.msg(irc, source, 'Done.')
|
|
|
|
else:
|
|
|
|
utils.msg(irc, source, 'Error: no such relay %r exists.' % channel)
|
2015-07-13 02:59:09 +02:00
|
|
|
return
|
2015-07-11 05:26:46 +02:00
|
|
|
|
2015-07-13 02:59:09 +02:00
|
|
|
@utils.add_cmd
|
|
|
|
def link(irc, source, args):
|
|
|
|
"""<remotenet> <channel> <local channel>
|
|
|
|
|
|
|
|
Links channel <channel> on <remotenet> over the relay to <local channel>.
|
|
|
|
If <local channel> is not specified, it defaults to the same name as
|
|
|
|
<channel>."""
|
|
|
|
try:
|
|
|
|
channel = args[1].lower()
|
|
|
|
remotenet = args[0].lower()
|
|
|
|
except IndexError:
|
|
|
|
utils.msg(irc, source, "Error: not enough arguments. Needs 2-3: remote netname, channel, local channel name (optional).")
|
|
|
|
return
|
|
|
|
try:
|
|
|
|
localchan = args[2].lower()
|
|
|
|
except IndexError:
|
|
|
|
localchan = channel
|
|
|
|
for c in (channel, localchan):
|
|
|
|
if not utils.isChannel(c):
|
|
|
|
utils.msg(irc, source, 'Error: invalid channel %r.' % c)
|
|
|
|
return
|
|
|
|
if source not in irc.channels[localchan].users:
|
|
|
|
utils.msg(irc, source, 'Error: you must be in %r to complete this operation.' % localchan)
|
|
|
|
return
|
|
|
|
if not utils.isOper(irc, source):
|
|
|
|
utils.msg(irc, source, 'Error: you must be opered in order to complete this operation.')
|
|
|
|
return
|
|
|
|
if remotenet not in utils.networkobjects:
|
|
|
|
utils.msg(irc, source, 'Error: no network named %r exists.' % remotenet)
|
|
|
|
return
|
|
|
|
if (irc.name, localchan) in db:
|
|
|
|
utils.msg(irc, source, 'Error: channel %r is already part of a relay.' % localchan)
|
|
|
|
return
|
|
|
|
for dbentry in db.values():
|
|
|
|
if (irc.name, localchan) in dbentry['links']:
|
|
|
|
utils.msg(irc, source, 'Error: channel %r is already part of a relay.' % localchan)
|
|
|
|
return
|
|
|
|
try:
|
|
|
|
entry = db[(remotenet, channel)]
|
|
|
|
except KeyError:
|
|
|
|
utils.msg(irc, source, 'Error: no such relay %r exists.' % channel)
|
|
|
|
return
|
|
|
|
else:
|
|
|
|
entry['links'].add((irc.name, localchan))
|
2015-07-13 08:28:54 +02:00
|
|
|
initializeChannel(irc, localchan)
|
2015-07-13 02:59:09 +02:00
|
|
|
utils.msg(irc, source, 'Done.')
|
2015-07-12 22:09:35 +02:00
|
|
|
|
2015-07-13 02:59:09 +02:00
|
|
|
@utils.add_cmd
|
|
|
|
def delink(irc, source, args):
|
|
|
|
"""<local channel> [<network>]
|
|
|
|
|
|
|
|
Delinks channel <local channel>. <network> must and can only be specified
|
|
|
|
if you are on the host network for <local channel>, and allows you to
|
|
|
|
pick which network to delink. To remove all networks from a relay, use the
|
|
|
|
'destroy' command instead."""
|
|
|
|
try:
|
|
|
|
channel = args[0].lower()
|
|
|
|
except IndexError:
|
|
|
|
utils.msg(irc, source, "Error: not enough arguments. Needs 1-2: channel, remote netname (optional).")
|
|
|
|
return
|
|
|
|
try:
|
|
|
|
remotenet = args[1].lower()
|
|
|
|
except IndexError:
|
|
|
|
remotenet = None
|
|
|
|
if not utils.isOper(irc, source):
|
|
|
|
utils.msg(irc, source, 'Error: you must be opered in order to complete this operation.')
|
|
|
|
return
|
|
|
|
if not utils.isChannel(channel):
|
|
|
|
utils.msg(irc, source, 'Error: invalid channel %r.' % channel)
|
|
|
|
return
|
|
|
|
for dbentry in db.values():
|
|
|
|
if (irc.name, channel) in dbentry['links']:
|
|
|
|
entry = dbentry
|
|
|
|
break
|
|
|
|
if (irc.name, channel) in db: # We own this channel
|
|
|
|
if remotenet is None:
|
|
|
|
utils.msg(irc, source, "Error: you must select a network to delink, or use the 'destroy' command no remove this relay entirely.")
|
|
|
|
return
|
|
|
|
else:
|
|
|
|
for entry in db.values():
|
|
|
|
for link in entry['links'].copy():
|
|
|
|
if link[0] == remotenet:
|
|
|
|
entry['links'].remove(link)
|
2015-07-13 08:28:54 +02:00
|
|
|
removeChannel(utils.networkobjects[remotenet], link[1])
|
2015-07-13 02:59:09 +02:00
|
|
|
else:
|
|
|
|
entry['links'].remove((irc.name, channel))
|
2015-07-13 08:28:54 +02:00
|
|
|
removeChannel(irc, channel)
|
2015-07-13 02:59:09 +02:00
|
|
|
utils.msg(irc, source, 'Done.')
|
|
|
|
|
2015-07-13 09:01:04 +02:00
|
|
|
def initializeAll(irc):
|
2015-07-14 01:07:55 +02:00
|
|
|
utils.started.wait()
|
2015-07-13 09:01:04 +02:00
|
|
|
for chanpair, entrydata in db.items():
|
|
|
|
network, channel = chanpair
|
|
|
|
initializeChannel(irc, channel)
|
|
|
|
for link in entrydata['links']:
|
|
|
|
network, channel = link
|
|
|
|
initializeChannel(irc, channel)
|
2015-07-13 08:28:54 +02:00
|
|
|
def main():
|
2015-07-11 05:26:46 +02:00
|
|
|
loadDB()
|
2015-07-13 08:28:54 +02:00
|
|
|
utils.schedulers['relaydb'] = scheduler = sched.scheduler()
|
|
|
|
scheduler.enter(30, 1, exportDB, argument=(scheduler,))
|
|
|
|
# Thread this because exportDB() queues itself as part of its
|
|
|
|
# execution, in order to get a repeating loop.
|
|
|
|
thread = threading.Thread(target=scheduler.run)
|
|
|
|
thread.daemon = True
|
|
|
|
thread.start()
|
2015-07-14 01:07:55 +02:00
|
|
|
'''
|
2015-07-13 09:01:04 +02:00
|
|
|
for ircobj in utils.networkobjects.values():
|
|
|
|
initializeAll(irc)
|
2015-07-14 01:07:55 +02:00
|
|
|
|
2015-07-13 08:28:54 +02:00
|
|
|
# Same goes for all the other initialization stuff; we only
|
|
|
|
# want it to happen once.
|
|
|
|
for network, ircobj in utils.networkobjects.items():
|
|
|
|
if ircobj.name != irc.name:
|
|
|
|
irc.proto.spawnServer(irc, '%s.relay' % network)
|
|
|
|
'''
|
|
|
|
|
2015-07-13 09:01:04 +02:00
|
|
|
def handle_endburst(irc, numeric, command, args):
|
2015-07-14 01:07:55 +02:00
|
|
|
thread = threading.Thread(target=initializeAll, args=(irc,))
|
|
|
|
thread.start()
|
2015-07-13 09:01:04 +02:00
|
|
|
utils.add_hook(handle_endburst, "ENDBURST")
|