diff --git a/coremods/service_support.py b/coremods/service_support.py index 6305c50..1c899ba 100644 --- a/coremods/service_support.py +++ b/coremods/service_support.py @@ -73,9 +73,8 @@ def spawn_service(irc, source, command, args): log.debug('(%s) spawn_service: irc.pseudoclient set to UID %s', irc.name, u) irc.pseudoclient = userobj - channels = set(irc.serverdata.get(name+'_channels', [])) | set(irc.serverdata.get('channels', [])) | \ - sbot.extra_channels.get(irc.name, set()) - sbot.join(irc, channels) + # Enumerate & join network defined channels. + sbot.join(irc, sbot.get_persistent_channels(irc)) utils.add_hook(spawn_service, 'PYLINK_NEW_SERVICE') @@ -108,26 +107,66 @@ def handle_kill(irc, source, command, args): irc.pseudoclient = None userdata = args.get('userdata') sbot = irc.get_service_bot(target) - servicename = None - 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 = 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 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): + """Monitors channel joins for dynamic service bot joining.""" + channel = args['channel'] + users = irc.channels[channel].users + for servicename, sbot in world.services.items(): + if channel in sbot.get_persistent_channels(irc) and \ + sbot.uids.get(irc.name) not in users: + log.debug('(%s) Dynamically joining service %r to channel %r.', irc.name, servicename, channel) + sbot.join(irc, channel) +utils.add_hook(handle_join, 'JOIN') +utils.add_hook(handle_join, 'PYLINK_SERVICE_JOIN') + +def _services_dynamic_part(irc, channel): + """Dynamically removes service bots from empty channels.""" + # If all remaining users in the channel are service bots, make them all part. + if all(irc.is_internal_client(u) for u in irc.channels[channel].users): + for u in irc.channels[channel].users.copy(): + sbot = irc.get_service_bot(u) + if sbot: + log.debug('(%s) Dynamically parting service %r from channel %r.', irc.name, sbot.name, channel) + irc.part(u, channel) + return True + +def handle_part(irc, source, command, args): + """Monitors channel joins for dynamic service bot joining.""" + for channel in args['channels']: + _services_dynamic_part(irc, channel) +utils.add_hook(handle_part, 'PART') + def handle_kick(irc, source, command, args): """Handle KICKs to the PyLink service bots, rejoining channels as needed.""" - kicked = args['target'] channel = args['channel'] - sbot = irc.get_service_bot(kicked) - if sbot: - sbot.join(irc, channel) + # Skip autorejoin routines if the channel is now empty. + if not _services_dynamic_part(irc, channel): + kicked = args['target'] + sbot = irc.get_service_bot(kicked) + if sbot: + sbot.join(irc, channel) utils.add_hook(handle_kick, 'KICK') def handle_commands(irc, source, command, args): @@ -142,7 +181,7 @@ def handle_commands(irc, source, command, args): utils.add_hook(handle_commands, 'PRIVMSG') # Register the main PyLink service. All command definitions MUST go after this! -# TODO: be more specific, and possibly allow plugins to modify this to mention +# TODO: be more specific in description, and possibly allow plugins to modify this to mention # their features? mydesc = "\x02PyLink\x02 provides extended network services for IRC." utils.register_service('pylink', default_nick="PyLink", desc=mydesc, manipulatable=True) diff --git a/plugins/relay.py b/plugins/relay.py index f5d4850..ddadcec 100644 --- a/plugins/relay.py +++ b/plugins/relay.py @@ -539,23 +539,19 @@ def remove_channel(irc, channel): if irc is None: return - if channel not in map(str.lower, irc.serverdata.get('channels', [])): - world.services['pylink'].extra_channels[irc.name].discard(channel) - if irc.pseudoclient: - irc.part(irc.pseudoclient.uid, channel, 'Channel delinked.') + world.services['pylink'].dynamic_channels[irc.name].discard(channel) relay = get_relay(irc, channel) if relay and channel in irc.channels: for user in irc.channels[channel].users.copy(): - if not is_relay_client(irc, user): + # Relay a /part of all local users. + if not irc.is_internal_client(user): relay_part(irc, channel, user) - # Don't ever part the main client from any of its autojoin channels. else: - if user == irc.pseudoclient.uid and channel in \ - irc.serverdata.get('channels', []): + # Part and quit all relay clients. + if irc.get_service_bot(user): # ...but ignore service bots continue irc.part(user, channel, 'Channel delinked.') - # Don't ever quit it either... if user != irc.pseudoclient.uid and not irc.users[user].channels: remoteuser = get_orig_user(irc, user) del relayusers[remoteuser][irc.name] diff --git a/utils.py b/utils.py index 4e408e8..0c0277b 100644 --- a/utils.py +++ b/utils.py @@ -13,7 +13,7 @@ import collections import argparse from .log import log -from . import world, conf +from . import world, conf, structures # Load the protocol and plugin packages. from pylinkirc import protocols, plugins @@ -152,9 +152,10 @@ class ServiceBot(): # spawned. self.uids = {} - # Track what channels other than those defined in the config - # that the bot should join by default. - self.extra_channels = collections.defaultdict(set) + # 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])) # Service description, used in the default help command if one is given. self.desc = desc @@ -183,9 +184,14 @@ class ServiceBot(): else: raise NotImplementedError("Network specific plugins not supported yet.") - def join(self, irc, channels, autojoin=True): + def join(self, irc, channels, ignore_empty=True): """ - Joins the given service bot to the given channel(s). + 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. """ if isinstance(irc, str): @@ -198,10 +204,6 @@ class ServiceBot(): channels = [channels] channels = set(channels) - if autojoin: - log.debug('(%s/%s) Adding channels %s to autojoin', netname, self.name, channels) - self.extra_channels[netname] |= channels - # If the network was given as a string, look up the Irc object here. try: irc = world.networkobjects[netname] @@ -209,6 +211,10 @@ class ServiceBot(): 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: @@ -221,10 +227,16 @@ class ServiceBot(): for chan in channels: if irc.is_channel(chan): - if chan in irc.channels and u in irc.channels[chan].users: - log.debug('(%s) Skipping join of services %s to channel %s - it is already present', irc.name, self.name, 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) + continue + elif ignore_empty: + log.debug('(%s) Skipping joining service %r to empty channel %r', irc.name, self.name, chan) continue + log.debug('(%s) Joining services %s to channel %s with modes %r', irc.name, self.name, chan, joinmodes) + if joinmodes: # Modes on join were specified; use SJOIN to burst our service irc.proto.sjoin(irc.sid, chan, [(joinmodes, u)]) else: @@ -387,6 +399,15 @@ 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): + """ + Returns a set of persistent channels for the IRC network. + """ + channels = self.dynamic_channels[irc.name].copy() + channels |= set(irc.serverdata.get(self.name+'_channels', [])) + channels |= set(irc.serverdata.get('channels', [])) + return channels + def _show_command_help(self, irc, command, private=False, shortform=False): """ Shows help for the given command.