From e9fe15bd7d037343bd617ac576db99439629f444 Mon Sep 17 00:00:00 2001 From: James Lu Date: Fri, 4 May 2018 22:37:25 -0700 Subject: [PATCH] [WIP] Further revise the persistent channels implementation - Make dynamic_channels per plugin as well as per network to work around relay-service conflicts (#265) - Introduce ServiceBot.(add|remove)_persistent_channel() to add/remove persistent channels and optionally join/part them - Introduce ServiceBot.part(), which checks remaining persistent channels list and parts a channel only if it is still not marked persistent - Refactor automode to autojoin channels on ENDBURST instead of plugin load - Refactor relay to manage persistent channels on join/part/(de)init, both locally (namespace 'relay_local') and remotely (namespace 'relay_remote') --- plugins/automode.py | 20 +++--- plugins/relay.py | 37 ++++++++--- utils.py | 147 +++++++++++++++++++++++++++++++------------- 3 files changed, 146 insertions(+), 58 deletions(-) diff --git a/plugins/automode.py b/plugins/automode.py index 5cce99a..d1d329c 100644 --- a/plugins/automode.py +++ b/plugins/automode.py @@ -35,13 +35,6 @@ 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) - def die(irc=None): """Saves the Automode database and quit.""" datastore.die() @@ -120,6 +113,19 @@ 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: + return + + for entry in db: + netname, channel = entry.split('#', 1) + channel = '#' + channel + if netname == irc.name: + modebot.add_persistent_channel(irc, 'automode', channel) + +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 diff --git a/plugins/relay.py b/plugins/relay.py index ddadcec..647e031 100644 --- a/plugins/relay.py +++ b/plugins/relay.py @@ -532,14 +532,14 @@ 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_local', 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) + world.services['pylink'].remove_persistent_channel(channel, 'relay_local', try_part=False) relay = get_relay(irc, channel) if relay and channel in irc.channels: @@ -549,13 +549,18 @@ 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.') - if user != irc.pseudoclient.uid and not irc.users[user].channels: - remoteuser = get_orig_user(irc, user) - del relayusers[remoteuser][irc.name] - irc.quit(user, 'Left all shared channels.') + # 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: + sbot.part(irc, channel) + else: + irc.part(user, channel, 'Channel delinked.') + if user != irc.pseudoclient.uid and not irc.users[user].channels: + remoteuser = get_orig_user(irc, user) + del relayusers[remoteuser][irc.name] + irc.quit(user, 'Left all shared channels.') def check_claim(irc, channel, sender, chanobj=None): """ @@ -734,6 +739,12 @@ 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) + + # Join the service bot on the remote channel persistently. + rsbot = remoteirc.get_service_bot(u) + if rsbot: + rsbot.add_persistent_channel(irc, 'relay_remote', channel, try_join=False) + queued_users.append(userpair) if queued_users: @@ -806,6 +817,14 @@ 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(u) + if rsbot: + try: + sbot.remove_persistent_channel(irc, 'relay_remote', channel, try_part=False) + except KeyError: + pass + # Part the relay client with the channel delinked message. remoteirc.part(remoteuser, remotechan, 'Channel delinked.') diff --git a/utils.py b/utils.py index 0c0277b..a108973 100644 --- a/utils.py +++ b/utils.py @@ -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,93 @@ 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): + """ + 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: + for dch_namespace, dch_data in self.dynamic_channels.items(): + if irc.name in dch_data and channel in dch_data[irc.name]: + log.debug('(%s/%s) Not parting %r because namespace %r still registers it ' + 'as a dynamic channel.', irc.name, self.name, channel, + dch_namespace) + break + else: + to_part.append(channel) + irc.part(uid, channel, '') # TODO: configurable part message? + ''' + + 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, '') # TODO: configurable part message? + 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': ''}]) def reply(self, irc, text, notice=None, private=None): """Replies to a message as the service in question.""" @@ -399,11 +428,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): + """ + 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]) + + 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