3
0
mirror of https://github.com/jlu5/PyLink.git synced 2024-11-27 21:19:31 +01:00

Merge branch 'services-v3' into devel

- Revamp persistent channel registration to be plugin specific, effectively working around relay-services conflicts (closes #265)
- New abstraction in ServiceBot: add/remove_persistent_channel() to manage persistent channels independently of explicit joins and parts
- Introduce ServiceBot.part(), which sends a part "request" that succeeds only if a channel is not marked persistent by any plugin
- Replace ServiceBot.join() calls with the new registration mechanism, which queues joins instead of dropping them if the service client is not yet ready (closes #596)
This commit is contained in:
James Lu 2018-05-05 22:54:56 -07:00
commit c71e9a6410
4 changed files with 161 additions and 84 deletions

View File

@ -98,7 +98,7 @@ def handle_endburst(irc, source, command, args):
for name, sbot in world.services.items():
spawn_service(irc, source, command, {'name': name})
utils.add_hook(handle_endburst, 'ENDBURST')
utils.add_hook(handle_endburst, 'ENDBURST', priority=500)
def handle_kill(irc, source, command, args):
"""Handle KILLs to PyLink service bots, respawning them as needed."""
@ -107,26 +107,17 @@ def handle_kill(irc, source, command, args):
irc.pseudoclient = None
userdata = args.get('userdata')
sbot = irc.get_service_bot(target)
servicename = None
channels = []
if userdata:
# Look for the target's service name
servicename = getattr(userdata, 'service', servicename)
channels = getattr(userdata, 'channels', channels)
elif sbot:
# Or its service bot instance
if userdata and hasattr(userdata, 'service'): # Look for the target's service name attribute
servicename = userdata.service
elif sbot: # Or their service bot instance
servicename = sbot.name
channels = irc.users[target].channels
if servicename:
log.info('(%s) Received kill to service %r (nick: %r) from %s (reason: %r).', irc.name, servicename,
userdata.nick if userdata else irc.users[target].nick, irc.get_hostmask(source), args.get('text'))
spawn_service(irc, source, command, {'name': servicename})
# Rejoin the killed service bot to all channels it was previously in.
world.services[servicename].join(irc, channels)
utils.add_hook(handle_kill, 'KILL')
def handle_join(irc, source, command, args):
@ -173,7 +164,7 @@ def handle_kick(irc, source, command, args):
if not _services_dynamic_part(irc, channel):
kicked = args['target']
sbot = irc.get_service_bot(kicked)
if sbot:
if sbot and channel in sbot.get_persistent_channels(irc):
sbot.join(irc, channel)
utils.add_hook(handle_kick, 'KICK')

View File

@ -26,6 +26,20 @@ db = datastore.store
default_permissions = {"$ircop": ['automode.manage.relay_owned', 'automode.sync.relay_owned',
'automode.list']}
def _join_db_channels(irc):
"""
Joins the Automode service client to channels on the current network in its DB.
"""
if not irc.connected.is_set():
log.debug('(%s) _join_db_channels: aborting, network not ready yet', irc.name)
return
for entry in db:
netname, channel = entry.split('#', 1)
channel = '#' + channel
if netname == irc.name:
modebot.add_persistent_channel(irc, 'automode', channel)
def main(irc=None):
"""Main function, called during plugin loading at start."""
@ -35,12 +49,9 @@ def main(irc=None):
# Register our permissions.
permissions.add_default_permissions(default_permissions)
# Queue joins to all channels where Automode has entries.
for entry in db:
netname, channel = entry.split('#', 1)
channel = '#' + channel
log.debug('automode: auto-joining %s on %s', channel, netname)
modebot.join(netname, channel, ignore_empty=True)
if irc: # After initial startup
for ircobj in world.networkobjects.values():
_join_db_channels(ircobj)
def die(irc=None):
"""Saves the Automode database and quit."""
@ -120,6 +131,12 @@ def match(irc, channel, uids=None):
irc.call_hooks([modebot_uid, 'AUTOMODE_MODE',
{'target': channel, 'modes': outgoing_modes, 'parse_as': 'MODE'}])
def handle_endburst(irc, source, command, args):
"""ENDBURST hook handler - used to join the Automode service to channels where it has entries."""
if source == irc.uplink:
_join_db_channels(irc)
utils.add_hook(handle_endburst, 'ENDBURST')
def handle_join(irc, source, command, args):
"""
Automode JOIN listener. This sets modes accordingly if the person joining matches a mask in the
@ -213,8 +230,8 @@ def setacc(irc, source, args):
log.info('(%s) %s set modes +%s for %s on %s', ircobj.name, irc.get_hostmask(source), modes, mask, channel)
reply(irc, "Done. \x02%s\x02 now has modes \x02+%s\x02 in \x02%s\x02." % (mask, modes, channel))
# Join the Automode bot to the channel if not explicitly told to.
modebot.join(ircobj, channel)
# Join the Automode bot to the channel persistently.
modebot.add_persistent_channel(irc, 'automode', channel)
modebot.add_cmd(setacc, aliases=('setaccess', 'set'), featured=True)
@ -248,6 +265,7 @@ def delacc(irc, source, args):
if not dbentry:
log.debug("Automode: purging empty channel pair %s/%s", ircobj.name, channel)
del db[ircobj.name+channel]
modebot.remove_persistent_channel(irc, 'automode', channel)
modebot.add_cmd(delacc, aliases=('delaccess', 'del'), featured=True)
@ -327,6 +345,7 @@ def clearacc(irc, source, args):
del db[ircobj.name+channel]
log.info('(%s) %s cleared modes on %s', ircobj.name, irc.get_hostmask(source), channel)
reply(irc, "Done. Removed all Automode access entries for \x02%s\x02." % channel)
modebot.remove_persistent_channel(irc, 'automode', channel)
else:
error(irc, "No Automode access entries exist for \x02%s\x02." % channel)

View File

@ -12,6 +12,8 @@ from pylinkirc.coremods import permissions
# Sets the timeout to wait for as individual servers / the PyLink daemon to start up.
TCONDITION_TIMEOUT = 2
CHANNEL_DELINKED_PARTMSG = 'Channel delinked.'
### GLOBAL (statekeeping) VARIABLES
relayusers = defaultdict(dict)
relayservers = defaultdict(dict)
@ -509,16 +511,6 @@ def initialize_channel(irc, channel):
# Join their (remote) users and set their modes, if applicable.
if remotechan in remoteirc.channels:
rc = remoteirc.channels[remotechan]
'''
if not hasattr(rc, '_relay_initial_burst'):
rc._relay_initial_burst = threading.Event()
if rc._relay_initial_burst.is_set():
log.debug('(%s) relay.initialize_channel: skipping inbound burst from %s/%s => %s/%s '
'as it has already been bursted', irc.name, remoteirc.name, remotechan, irc.name, channel)
continue
rc._relay_initial_burst.set()
'''
relay_joins(remoteirc, remotechan, rc.users, rc.ts, targetirc=irc)
# Only update the topic if it's different from what we already have,
@ -532,14 +524,17 @@ def initialize_channel(irc, channel):
relay_joins(irc, channel, c.users, c.ts)
if 'pylink' in world.services:
world.services['pylink'].join(irc, channel)
world.services['pylink'].add_persistent_channel(irc, 'relay', channel)
def remove_channel(irc, channel):
"""Destroys a relay channel by parting all of its users."""
if irc is None:
return
world.services['pylink'].dynamic_channels[irc.name].discard(channel)
try:
world.services['pylink'].remove_persistent_channel(irc, 'relay', channel, part_reason=CHANNEL_DELINKED_PARTMSG)
except KeyError:
log.warning('(%s) relay: failed to remove persistent channel %r on delink', irc.name, channel, exc_info=True)
relay = get_relay(irc, channel)
if relay and channel in irc.channels:
@ -549,9 +544,17 @@ def remove_channel(irc, channel):
relay_part(irc, channel, user)
else:
# Part and quit all relay clients.
if irc.get_service_bot(user): # ...but ignore service bots
continue
irc.part(user, channel, 'Channel delinked.')
# Service bots are treated differently: they have plugin-defined persistent
# channels, so we can request a part and it will apply if no other plugins
# have the channel registered.
sbot = irc.get_service_bot(user)
if sbot:
try:
sbot.remove_persistent_channel(irc, 'relay', channel, part_reason=CHANNEL_DELINKED_PARTMSG)
except KeyError:
pass
else:
irc.part(user, channel, CHANNEL_DELINKED_PARTMSG)
if user != irc.pseudoclient.uid and not irc.users[user].channels:
remoteuser = get_orig_user(irc, user)
del relayusers[remoteuser][irc.name]
@ -715,6 +718,11 @@ def relay_joins(irc, channel, users, ts, targetirc=None, **kwargs):
if not u:
continue
# Join the service bot on the remote channel persistently.
rsbot = remoteirc.get_service_bot(u)
if rsbot:
rsbot.add_persistent_channel(irc, 'relay', channel, try_join=False)
if (remotechan not in remoteirc.channels) or u not in remoteirc.channels[remotechan].users:
# Note: only join users if they aren't already joined. This prevents op floods
# on charybdis from repeated SJOINs sent for one user.
@ -734,6 +742,7 @@ def relay_joins(irc, channel, users, ts, targetirc=None, **kwargs):
# proto.sjoin() takes its users as a list of (prefix mode characters, UID) pairs.
userpair = (prefixes, u)
queued_users.append(userpair)
if queued_users:
@ -806,8 +815,16 @@ def relay_part(irc, *args, **kwargs):
# user doesn't exist, just do nothing.
return
# Remove any persistent channel entries from the remote end.
rsbot = remoteirc.get_service_bot(remoteuser)
if rsbot:
try:
sbot.remove_persistent_channel(irc, 'relay', channel, try_part=False)
except KeyError:
pass
# Part the relay client with the channel delinked message.
remoteirc.part(remoteuser, remotechan, 'Channel delinked.')
remoteirc.part(remoteuser, remotechan, CHANNEL_DELINKED_PARTMSG)
# If the relay client no longer has any channels, quit them to prevent inflating /lusers.
if is_relay_client(remoteirc, remoteuser) and not remoteirc.users[remoteuser].channels:

134
utils.py
View File

@ -154,8 +154,9 @@ class ServiceBot():
# Track plugin-defined persistent channels. The bot will leave them if they're empty,
# and rejoin whenever someone else does.
self.dynamic_channels = structures.KeyedDefaultdict(lambda netname:
structures.IRCCaseInsensitiveSet(world.networkobjects[netname]))
# This is stored as a nested dictionary:
# {"plugin1": {"net1": IRCCaseInsensitiveSet({"#a", "#b"}), "net2": ...}, ...}
self.dynamic_channels = {}
# Service description, used in the default help command if one is given.
self.desc = desc
@ -186,65 +187,80 @@ class ServiceBot():
def join(self, irc, channels, ignore_empty=True):
"""
Joins the given service bot to the given channel(s). channels can be an iterable of channel names
or the name of a single channel (str).
Joins the given service bot to the given channel(s). "channels" can be
an iterable of channel names or the name of a single channel (str).
The ignore_empty option sets whether we should skip joining empty channels and join them
later when we see someone else join. This is option is disabled on networks where we cannot
monitor channel state.
The ignore_empty option sets whether we should skip joining empty
channels and join them later when we see someone else join. This is
option is disabled on networks where we cannot monitor channel state.
"""
uid = self.uids.get(irc.name)
if uid is None:
return
if isinstance(irc, str):
netname = irc
else:
netname = irc.name
# Ensure type safety: pluralize strings if only one channel was given, then convert to set.
if isinstance(channels, str):
channels = [channels]
channels = set(channels)
# If the network was given as a string, look up the Irc object here.
try:
irc = world.networkobjects[netname]
except KeyError:
log.debug('(%s/%s) Skipping join(), IRC object not initialized yet', netname, self.name)
return
if irc.has_cap('visible-state-only'):
# Disable dynamic channel joining on networks where we can't monitor channels for joins.
ignore_empty = False
try:
u = self.uids[irc.name]
except KeyError:
log.debug('(%s/%s) Skipping join(), UID not initialized yet', irc.name, self.name)
return
# Specify modes to join the services bot with.
joinmodes = irc.serverdata.get("%s_joinmodes" % self.name) or conf.conf.get(self.name, {}).get('joinmodes') or ''
joinmodes = irc.get_service_option(self.name, 'joinmodes', default='')
joinmodes = ''.join([m for m in joinmodes if m in irc.prefixmodes])
for chan in channels:
if irc.is_channel(chan):
if chan in irc.channels:
if u in irc.channels[chan].users:
log.debug('(%s) Skipping join of service %r to channel %r - it is already present', irc.name, self.name, chan)
for channel in channels:
if irc.is_channel(channel):
if channel in irc.channels:
if uid in irc.channels[channel].users:
log.debug('(%s/%s) Skipping join to %r - we are already present', irc.name, self.name, channel)
continue
elif ignore_empty:
log.debug('(%s) Skipping joining service %r to empty channel %r', irc.name, self.name, chan)
log.debug('(%s/%s) Skipping joining empty channel %r', irc.name, self.name, channel)
continue
log.debug('(%s) Joining services %s to channel %s with modes %r', irc.name, self.name, chan, joinmodes)
log.debug('(%s/%s) Joining channel %s with modes %r', irc.name, self.name, channel, joinmodes)
if joinmodes: # Modes on join were specified; use SJOIN to burst our service
irc.proto.sjoin(irc.sid, chan, [(joinmodes, u)])
irc.proto.sjoin(irc.sid, channel, [(joinmodes, uid)])
else:
irc.proto.join(u, chan)
irc.proto.join(uid, channel)
irc.call_hooks([irc.sid, 'PYLINK_SERVICE_JOIN', {'channel': chan, 'users': [u]}])
irc.call_hooks([irc.sid, 'PYLINK_SERVICE_JOIN', {'channel': channel, 'users': [uid]}])
else:
log.warning('(%s) Ignoring invalid autojoin channel %r.', irc.name, chan)
log.warning('(%s/%s) Ignoring invalid channel %r', irc.name, self.name, channel)
def part(self, irc, channels, reason=''):
"""
Parts the given service bot from the given channel(s) if no plugins
still register it as a persistent dynamic channel.
"channels" can be an iterable of channel names or the name of a single
channel (str).
"""
uid = self.uids.get(irc.name)
if uid is None:
return
if isinstance(channels, str):
channels = [channels]
to_part = []
persistent_channels = self.get_persistent_channels(irc)
for channel in channels:
if channel in irc.channels and uid in irc.channels[channel].users:
if channel in persistent_channels:
log.debug('(%s/%s) Not parting %r because it is registered '
'as a dynamic channel: %r', irc.name, self.name, channel,
persistent_channels)
continue
to_part.append(channel)
irc.part(uid, channel, reason)
else:
log.debug('(%s/%s) Ignoring part to %r, we are not there', irc.name, self.name, channel)
continue
irc.call_hooks([uid, 'PYLINK_SERVICE_PART', {'channels': to_part, 'text': reason}])
def reply(self, irc, text, notice=None, private=None):
"""Replies to a message as the service in question."""
@ -399,11 +415,45 @@ class ServiceBot():
sbconf = conf.conf.get(self.name, {})
return irc.serverdata.get("%s_realname" % self.name) or sbconf.get('realname') or conf.conf['pylink'].get('realname') or self.name
def get_persistent_channels(self, irc):
def add_persistent_channel(self, irc, namespace, channel, try_join=True):
"""
Returns a set of persistent channels for the IRC network.
Adds a persistent channel to the service bot on the given network and namespace.
"""
channels = self.dynamic_channels[irc.name].copy()
namespace = self.dynamic_channels.setdefault(namespace, {})
chanlist = namespace.setdefault(irc.name, structures.IRCCaseInsensitiveSet(irc))
chanlist.add(channel)
if try_join:
self.join(irc, [channel])
def remove_persistent_channel(self, irc, namespace, channel, try_part=True, part_reason=''):
"""
Removes a persistent channel from the service bot on the given network and namespace.
"""
chanlist = self.dynamic_channels[namespace][irc.name].remove(channel)
if try_part and irc.connected.is_set():
self.part(irc, [channel], reason=part_reason)
def get_persistent_channels(self, irc, namespace=None):
"""
Returns a set of persistent channels for the IRC network, optionally filtering
by namespace is one is given.
"""
channels = structures.IRCCaseInsensitiveSet(irc)
if namespace:
chanlist = self.dynamic_channels.get(namespace, {}).get(irc.name, set())
log.debug('(%s/%s) get_persistent_channels: adding channels '
'%r from namespace %r (single)', irc.name, self.name,
chanlist, namespace)
channels |= chanlist
else:
for dch_namespace, dch_data in self.dynamic_channels.items():
chanlist = dch_data.get(irc.name, set())
log.debug('(%s/%s) get_persistent_channels: adding channels '
'%r from namespace %r', irc.name, self.name,
chanlist, dch_namespace)
channels |= chanlist
channels |= set(irc.serverdata.get(self.name+'_channels', []))
channels |= set(irc.serverdata.get('channels', []))
return channels