### # Resilience - plugin.py # # Per-network / per-channel automatic IRC self-maintenance: # - Join retry with configurable max attempts (ban / full / invite-only / bad-key) # - Per-error command hook (onBanCommand, onFullCommand, etc.) # - MODE -b self-unban fallback when bot retains ops # - Auto-rejoin after kick with configurable max attempts # - Auto-reop after deop (halfop self-op → onDeopCommand fallback) # - Per-channel on-join command (e.g. ChanServ UP) # - Per-network perform on connect (comma-separated, $substitutions) # - Per-network nick-recovery commands + periodic polling # - Nick password stored privately, available as $password everywhere ### import re import string import time import supybot.conf as conf import supybot.world as world import supybot.ircmsgs as ircmsgs import supybot.ircutils as ircutils import supybot.schedule as schedule import supybot.callbacks as callbacks from supybot.commands import wrap from supybot.i18n import PluginInternationalization _ = PluginInternationalization('Resilience') # --------------------------------------------------------------------------- # Module-level helpers (no state, safe to call from anywhere) # --------------------------------------------------------------------------- def _desired_nick(irc): """Return the configured nick for this network, falling back to global.""" n = conf.supybot.networks.get(irc.network).nick() return n if n else conf.supybot.nick() # Split on real commas, not escaped ones. _REAL_COMMA = re.compile(r'(? 1 else '' if ':' in rest: idx = rest.index(':') positional = rest[:idx].split() trailing = rest[idx + 1:] args = tuple(positional) + (trailing,) else: args = tuple(rest.split()) if rest else () return ircmsgs.IrcMsg(command=command, args=args) # --------------------------------------------------------------------------- # Main plugin class # --------------------------------------------------------------------------- class Resilience(callbacks.Plugin): """ Automatic channel and nick resilience for Limnoria. All settings live under supybot.plugins.Resilience.* and support Limnoria's three-level hierarchy: global default └─ per-network override (config network supybot.plugins.Resilience.X value) └─ per-channel override (config channel #chan supybot.plugins.Resilience.X value) Network-only settings (perform, nickPassword, recoverNick, etc.) are set with 'config network ...' only. See 'help resilience' and 'config list supybot.plugins.Resilience' for the full list of options. """ def __init__(self, irc): self.__parent = super(Resilience, self) self.__parent.__init__(irc) # (network, channel) -> schedule event name self._joinRetryEvents = {} self._reopEvents = {} self._kickRejoinEvents = {} # (network, channel) -> int — attempt counters self._joinAttempts = {} # reset on successful join self._kickAttempts = {} # reset on successful join # network -> schedule event name self._nickPollEvents = {} self._performEvents = {} # network -> schedule event name for the delayed NICK after recover cmds self._nickClaimEvents = {} # ----------------------------------------------------------------------- # Cleanup # ----------------------------------------------------------------------- def die(self): for mapping in (self._joinRetryEvents, self._reopEvents, self._kickRejoinEvents, self._nickPollEvents, self._performEvents, self._nickClaimEvents): for name in list(mapping.values()): try: schedule.removeEvent(name) except KeyError: pass mapping.clear() self.__parent.die() # ----------------------------------------------------------------------- # Scheduling helpers # ----------------------------------------------------------------------- def _safe_add_event(self, func, when, name): """Add a one-shot event, silently replacing any existing event with the same name.""" try: schedule.removeEvent(name) except KeyError: pass return schedule.addEvent(func, when, name=name) def _safe_add_periodic(self, func, interval, name): """Add a periodic event, silently replacing any existing one.""" try: schedule.removeEvent(name) except KeyError: pass return schedule.addPeriodicEvent(func, interval, name=name, now=False) def _cancel_event(self, mapping, key): """Pop a key from a dict and cancel its scheduled event.""" name = mapping.pop(key, None) if name is not None: try: schedule.removeEvent(name) except KeyError: pass # ----------------------------------------------------------------------- # Substitution / command sending helpers # ----------------------------------------------------------------------- def _password(self, network): return self.registryValue('nickPassword', network=network) def _sub(self, template, irc, channel=''): """Substitute $-variables in template using the current irc state.""" return _substitute( template, irc, desired=_desired_nick(irc), password=self._password(irc.network), channel=channel, ) def _send_raw(self, irc, template, channel='', label='cmd'): """ Substitute, parse, and send a single command template. Does nothing if the template is empty. """ raw = template.strip() if not raw: return substituted = self._sub(raw, irc, channel=channel) msg = _parse_irc_command(substituted) if msg is None: return self.log.info('Resilience[%s]: %s → %s', irc.network, label, substituted) irc.sendMsg(msg) def _send_command_list(self, irc, raw_string, channel='', label='perform'): """ Parse, substitute, and send a comma-separated list of command templates. Does nothing if raw_string is empty. """ for cmd in _split_commands(raw_string): self._send_raw(irc, cmd, channel=channel, label=label) # ----------------------------------------------------------------------- # Perform on connect # ----------------------------------------------------------------------- def _schedule_perform(self, irc): raw = self.registryValue('perform', network=irc.network).strip() if not raw: return delay = self.registryValue('performDelay', network=irc.network) net = irc.network name = 'Resilience_perform_%s' % net def _fire(): self._performEvents.pop(net, None) self._send_command_list(irc, raw, label='perform') if delay == 0: _fire() else: self._performEvents[net] = self._safe_add_event( _fire, time.time() + delay, name) # ----------------------------------------------------------------------- # Nick recovery # ----------------------------------------------------------------------- def _start_nick_poll(self, irc): """Start periodic polling to reclaim the configured nick.""" net = irc.network delay = self.registryValue('recoverNickDelay', network=net) if delay == 0 or net in self._nickPollEvents: return name = 'Resilience_nickpoll_%s' % net def _poll(): desired = _desired_nick(irc) if ircutils.strEqual(irc.nick, desired): self._stop_nick_poll(irc) return # Only attempt if the nick appears free if desired not in irc.state.nicksToHostmasks: self.log.info('Resilience[%s]: polling — attempting NICK %s', net, desired) irc.queueMsg(ircmsgs.nick(desired)) self._nickPollEvents[net] = self._safe_add_periodic(_poll, delay, name) self.log.info('Resilience[%s]: started nick poll every %ds', net, delay) def _stop_nick_poll(self, irc): self._cancel_event(self._nickPollEvents, irc.network) def _send_nick_recover_commands(self, irc): """Send nickRecoverCommands then schedule a delayed NICK attempt.""" net = irc.network raw = self.registryValue('nickRecoverCommands', network=net).strip() if raw: self._send_command_list(irc, raw, label='nickRecover') # Wait recoverNickDelay seconds then try NICK . # This gives services (RECOVER/RELEASE on DALnet etc.) time to process. delay = self.registryValue('recoverNickDelay', network=net) desired = _desired_nick(irc) name = 'Resilience_nickclaim_%s' % net def _claim(): self._nickClaimEvents.pop(net, None) if not ircutils.strEqual(irc.nick, desired): self.log.info('Resilience[%s]: sending NICK %s', net, desired) irc.queueMsg(ircmsgs.nick(desired)) if delay == 0: _claim() else: self._nickClaimEvents[net] = self._safe_add_event( _claim, time.time() + delay, name) # ----------------------------------------------------------------------- # Join retry internals # ----------------------------------------------------------------------- def _schedule_join_retry(self, irc, channel): key = (irc.network, channel) delay = self.registryValue('retryJoinDelay', channel, irc.network) name = 'Resilience_join_%s_%s' % (irc.network, channel) def _retry(): self._joinRetryEvents.pop(key, None) if channel not in irc.state.channels: self.log.info('Resilience[%s]: retrying JOIN %s (attempt %d)', irc.network, channel, self._joinAttempts.get(key, 0)) ng = conf.supybot.networks.get(irc.network) irc.queueMsg(ng.channels.join(channel)) self._joinRetryEvents[key] = self._safe_add_event( _retry, time.time() + delay, name) def _cancel_join_retry(self, irc, channel): self._cancel_event(self._joinRetryEvents, (irc.network, channel)) def _handle_join_error(self, irc, msg, error_label, cmd_config_key): """ Shared handler for 471 / 473 / 474 / 475. Flow: 1. Check retryJoin master switch and per-channel config. 2. Increment attempt counter; bail if over retryJoinMax. 3. If selfUnban and it's a ban: attempt MODE -b. 4. Send the on*Command template if configured. 5. Schedule the next join attempt. """ channel = msg.args[1] net = irc.network if not self.registryValue('retryJoin', channel, net): return key = (net, channel) max_ = self.registryValue('retryJoinMax', channel, net) attempt = self._joinAttempts.get(key, 0) + 1 self._joinAttempts[key] = attempt if max_ > 0 and attempt > max_: self.log.warning( 'Resilience[%s]: giving up on %s after %d attempt(s) (%s).', net, channel, max_, error_label) self._joinAttempts.pop(key, None) return delay = self.registryValue('retryJoinDelay', channel, net) self.log.info( 'Resilience[%s]: cannot join %s (%s), attempt %d/%s, retry in %ds.', net, channel, error_label, attempt, str(max_) if max_ > 0 else '∞', delay) # MODE -b self-unban (only useful if we somehow still have ops) if error_label == 'banned': if self.registryValue('selfUnban', channel, net): self._try_self_unban(irc, channel) # Send the per-error command hook cmd_raw = self.registryValue(cmd_config_key, channel, net).strip() if cmd_raw: self._send_raw(irc, cmd_raw, channel=channel, label=cmd_config_key) self._schedule_join_retry(irc, channel) # ----------------------------------------------------------------------- # MODE -b self-unban # ----------------------------------------------------------------------- def _try_self_unban(self, irc, channel): """ If the bot still has ops in the channel, find every ban mask that matches its current hostmask and remove them with MODE -b. """ if channel not in irc.state.channels: return False chanstate = irc.state.channels[channel] if irc.nick not in chanstate.ops: return False try: hostmask = irc.state.nickToHostmask(irc.nick) except KeyError: hostmask = irc.prefix matching = [bm for bm in chanstate.bans if ircutils.hostmaskPatternEqual(bm, hostmask)] if not matching: return False self.log.info('Resilience[%s]: MODE -b self-unban in %s: %s', irc.network, channel, ', '.join(matching)) chunk_size = irc.state.supported.get('modes', 1) or 1 for i in range(0, len(matching), chunk_size): chunk = matching[i:i + chunk_size] irc.queueMsg(ircmsgs.mode( channel, ['-' + 'b' * len(chunk)] + chunk)) return True # ----------------------------------------------------------------------- # Kick rejoin internals # ----------------------------------------------------------------------- def _schedule_kick_rejoin(self, irc, channel): key = (irc.network, channel) delay = self.registryValue('rejoinKickDelay', channel, irc.network) name = 'Resilience_kick_%s_%s' % (irc.network, channel) def _rejoin(): self._kickRejoinEvents.pop(key, None) if channel not in irc.state.channels: self.log.info('Resilience[%s]: rejoining %s after kick ' '(attempt %d)', irc.network, channel, self._kickAttempts.get(key, 0)) ng = conf.supybot.networks.get(irc.network) irc.queueMsg(ng.channels.join(channel)) self._kickRejoinEvents[key] = self._safe_add_event( _rejoin, time.time() + delay, name) # ----------------------------------------------------------------------- # Reop internals # ----------------------------------------------------------------------- def _schedule_reop(self, irc, channel): key = (irc.network, channel) delay = self.registryValue('autoReopDelay', channel, irc.network) name = 'Resilience_reop_%s_%s' % (irc.network, channel) def _reop(): self._reopEvents.pop(key, None) if channel not in irc.state.channels: return chanstate = irc.state.channels[channel] if irc.nick in chanstate.ops: return # already opped, nothing to do # Try halfop self-op first (no external command needed) if irc.nick in chanstate.halfops: self.log.info('Resilience[%s]: reop via halfop in %s', irc.network, channel) irc.queueMsg(ircmsgs.op(channel, irc.nick)) return # Fall back to the configured command (e.g. ChanServ OP) cmd_raw = self.registryValue( 'onDeopCommand', channel, irc.network).strip() if cmd_raw: self._send_raw(irc, cmd_raw, channel=channel, label='onDeopCommand') else: self.log.warning( 'Resilience[%s]: deopped in %s, no halfop and ' 'onDeopCommand not set — cannot self-reop.', irc.network, channel) self._reopEvents[key] = self._safe_add_event( _reop, time.time() + delay, name) # ----------------------------------------------------------------------- # IRC event handlers # ----------------------------------------------------------------------- def do001(self, irc, msg): """New connection: clear all stale state for this network.""" net = irc.network for key in [k for k in self._joinRetryEvents if k[0] == net]: self._cancel_event(self._joinRetryEvents, key) for key in [k for k in self._kickRejoinEvents if k[0] == net]: self._cancel_event(self._kickRejoinEvents, key) for key in [k for k in self._joinAttempts if k[0] == net]: self._joinAttempts.pop(key, None) for key in [k for k in self._kickAttempts if k[0] == net]: self._kickAttempts.pop(key, None) self._stop_nick_poll(irc) self._cancel_event(self._nickClaimEvents, net) def do376(self, irc, msg): """End of MOTD: fire perform, start nick recovery if needed.""" self._schedule_perform(irc) desired = _desired_nick(irc) if not ircutils.strEqual(irc.nick, desired): if self.registryValue('recoverNick', network=irc.network): self.log.info( 'Resilience[%s]: connected as %s, want %s — ' 'starting nick recovery.', irc.network, irc.nick, desired) self._send_nick_recover_commands(irc) self._start_nick_poll(irc) do377 = do422 = do376 # ERR_NOMOTD and RPL_MOTD (alt) # --- Join errors --- def do471(self, irc, msg): """Channel is full (+l).""" self._handle_join_error(irc, msg, 'full', 'onFullCommand') def do473(self, irc, msg): """Channel is invite-only (+i).""" self._handle_join_error(irc, msg, 'invite-only', 'onInviteOnlyCommand') def do474(self, irc, msg): """Banned from channel.""" self._handle_join_error(irc, msg, 'banned', 'onBanCommand') def do475(self, irc, msg): """Bad channel key.""" self._handle_join_error(irc, msg, 'bad-key', 'onBadKeyCommand') def doJoin(self, irc, msg): """Successful join: reset attempt counters, cancel pending retries, fire onJoinCommand if configured.""" if not ircutils.strEqual(msg.nick, irc.nick): return channel = msg.channel net = irc.network key = (net, channel) self._cancel_join_retry(irc, channel) self._cancel_event(self._kickRejoinEvents, key) self._joinAttempts.pop(key, None) self._kickAttempts.pop(key, None) cmd_raw = self.registryValue('onJoinCommand', channel, net).strip() if cmd_raw: self._send_raw(irc, cmd_raw, channel=channel, label='onJoinCommand') # --- Kick --- def doKick(self, irc, msg): channel = msg.channel if not ircutils.strEqual(msg.args[1], irc.nick): return if not self.registryValue('rejoinOnKick', channel, irc.network): self.log.info('Resilience[%s]: kicked from %s, rejoin disabled.', irc.network, channel) return key = (irc.network, channel) max_ = self.registryValue('rejoinKickMax', channel, irc.network) attempt = self._kickAttempts.get(key, 0) + 1 self._kickAttempts[key] = attempt if max_ > 0 and attempt > max_: self.log.warning( 'Resilience[%s]: giving up rejoining %s after %d kick(s).', irc.network, channel, max_) self._kickAttempts.pop(key, None) return self.log.info('Resilience[%s]: kicked from %s by %s, attempt %d/%s.', irc.network, channel, msg.nick, attempt, str(max_) if max_ > 0 else '∞') self._schedule_kick_rejoin(irc, channel) # --- Deop --- def doMode(self, irc, msg): channel = msg.args[0] if not irc.isChannel(channel): return if not self.registryValue('autoReop', channel, irc.network): return for (mode, value) in ircutils.separateModes(msg.args[1:]): if mode == '-o' and value and ircutils.strEqual(value, irc.nick): self.log.info('Resilience[%s]: deopped in %s by %s.', irc.network, channel, msg.nick) self._schedule_reop(irc, channel) # --- Nick events --- def doNick(self, irc, msg): net = irc.network desired = _desired_nick(irc) # Did WE just successfully change to our desired nick? if ircutils.strEqual(msg.nick, irc.nick): if ircutils.strEqual(msg.args[0], desired): self._stop_nick_poll(irc) self._cancel_event(self._nickClaimEvents, net) return # Did someone ELSE just vacate our desired nick? if ircutils.strEqual(msg.nick, desired) and \ not ircutils.strEqual(irc.nick, desired): if self.registryValue('recoverNick', network=net): self.log.info( 'Resilience[%s]: %s freed %s via NICK, claiming.', net, msg.nick, desired) irc.queueMsg(ircmsgs.nick(desired)) def doQuit(self, irc, msg): net = irc.network desired = _desired_nick(irc) if ircutils.strEqual(msg.nick, desired) and \ not ircutils.strEqual(irc.nick, desired): if self.registryValue('recoverNick', network=net): self.log.info( 'Resilience[%s]: %s quit, claiming freed nick.', net, desired) irc.queueMsg(ircmsgs.nick(desired)) def do433(self, irc, msg): """Nick in use: make sure the poll loop is running.""" if not ircutils.strEqual(irc.nick, _desired_nick(irc)): if self.registryValue('recoverNick', network=irc.network): self._start_nick_poll(irc) # ----------------------------------------------------------------------- # User-facing commands # ----------------------------------------------------------------------- # --- perform ----------------------------------------------------------- class perform(callbacks.Commands): """ Manage the per-network perform list (commands sent on connect). Commands are comma-separated raw IRC strings. Use \\, for a literal comma inside a single command. Substitutions: $nick, $botnick, $currentnick, $network, $password. Subcommands: set, show, run, clear """ def set(self, irc, msg, args, network, commands): """ , , ... Set the perform list for . Separate commands with commas; use \\, for a literal comma inside a command. Example: perform set libera PRIVMSG NickServ :IDENTIFY $password, MODE $nick +ix """ plugin = irc.getCallback('Resilience') plugin.setRegistryValue('perform', commands, network=network) n = len(_split_commands(commands)) irc.reply(_('Perform list for %s set (%d command(s)).' % ( network, n))) set = wrap(set, ['admin', 'somethingWithoutSpaces', 'text']) def show(self, irc, msg, args, network): """ Show the current perform list for . """ plugin = irc.getCallback('Resilience') raw = plugin.registryValue('perform', network=network).strip() if not raw: irc.reply(_('No perform commands set for %s.' % network)) return cmds = _split_commands(raw) irc.reply(' '.join('%d: %s' % (i + 1, c) for i, c in enumerate(cmds))) show = wrap(show, ['admin', 'somethingWithoutSpaces']) def run(self, irc, msg, args, network): """ Immediately send the perform list for without reconnecting. Useful for testing. """ plugin = irc.getCallback('Resilience') target = next((o for o in world.ircs if o.network == network), None) if target is None: irc.error(_('Not connected to %s.' % network), Raise=True) raw = plugin.registryValue('perform', network=network).strip() if not raw: irc.error(_('No perform commands set for %s.' % network), Raise=True) plugin._send_command_list(target, raw, label='perform(manual)') irc.replySuccess() run = wrap(run, ['admin', 'somethingWithoutSpaces']) def clear(self, irc, msg, args, network): """ Clear the perform list for . """ irc.getCallback('Resilience').setRegistryValue( 'perform', '', network=network) irc.replySuccess() clear = wrap(clear, ['admin', 'somethingWithoutSpaces']) # --- nickrecover ------------------------------------------------------- class nickrecover(callbacks.Commands): """ Manage per-network nick-recovery commands. Sent when the bot is not using its configured nick. Same comma-separated format as perform. Substitutions include $nick (desired), $currentnick (actual), $password. Subcommands: set, show, run, clear """ def set(self, irc, msg, args, network, commands): """ , , ... Set the nick-recovery command list for . Example for DALnet: nickrecover set dalnet PRIVMSG NickServ :RECOVER $nick $password, PRIVMSG NickServ :RELEASE $nick $password """ plugin = irc.getCallback('Resilience') plugin.setRegistryValue('nickRecoverCommands', commands, network=network) n = len(_split_commands(commands)) irc.reply(_('Nick-recovery list for %s set (%d command(s)).' % ( network, n))) set = wrap(set, ['admin', 'somethingWithoutSpaces', 'text']) def show(self, irc, msg, args, network): """ Show the current nick-recovery command list for . """ plugin = irc.getCallback('Resilience') raw = plugin.registryValue('nickRecoverCommands', network=network).strip() if not raw: irc.reply(_('No nick-recovery commands set for %s.' % network)) return cmds = _split_commands(raw) irc.reply(' '.join('%d: %s' % (i + 1, c) for i, c in enumerate(cmds))) show = wrap(show, ['admin', 'somethingWithoutSpaces']) def run(self, irc, msg, args, network): """ Immediately run the nick-recovery commands for . """ plugin = irc.getCallback('Resilience') target = next((o for o in world.ircs if o.network == network), None) if target is None: irc.error(_('Not connected to %s.' % network), Raise=True) raw = plugin.registryValue('nickRecoverCommands', network=network).strip() if not raw: irc.error( _('No nick-recovery commands set for %s.' % network), Raise=True) plugin._send_command_list(target, raw, label='nickRecover(manual)') irc.replySuccess() run = wrap(run, ['admin', 'somethingWithoutSpaces']) def clear(self, irc, msg, args, network): """ Clear the nick-recovery command list for . """ irc.getCallback('Resilience').setRegistryValue( 'nickRecoverCommands', '', network=network) irc.replySuccess() clear = wrap(clear, ['admin', 'somethingWithoutSpaces']) # --- nickpassword ------------------------------------------------------ def nickpassword(self, irc, msg, args, network, password): """ Set the nick password for . Stored privately and available as $password in all command templates. """ self.setRegistryValue('nickPassword', password, network=network) irc.replySuccess() nickpassword = wrap(nickpassword, ['admin', 'somethingWithoutSpaces', 'something']) # --- Join retry status / control --------------------------------------- def retrylist(self, irc, msg, args): """takes no arguments Show all channels currently pending a join retry, across all networks. """ if not self._joinRetryEvents: irc.reply(_('No pending join retries.')) return entries = sorted('%s @ %s' % (ch, net) for (net, ch) in self._joinRetryEvents) irc.reply(', '.join(entries)) retrylist = wrap(retrylist, ['admin']) def retrycancel(self, irc, msg, args, channel): """[] Cancel the pending join retry for on the current network. defaults to the current channel. """ key = (irc.network, channel) if key in self._joinRetryEvents: self._cancel_join_retry(irc, channel) self._joinAttempts.pop(key, None) irc.replySuccess() else: irc.reply(_('No pending retry for %s on %s.' % ( channel, irc.network))) retrycancel = wrap(retrycancel, ['admin', 'inChannel']) def retrynow(self, irc, msg, args, channel): """[] Cancel any scheduled retry and immediately attempt to join on the current network. """ key = (irc.network, channel) self._cancel_join_retry(irc, channel) self._joinAttempts.pop(key, None) ng = conf.supybot.networks.get(irc.network) irc.queueMsg(ng.channels.join(channel)) irc.noReply() retrynow = wrap(retrynow, ['admin', 'inChannel']) # --- Nick recovery control -------------------------------------------- def claimnick(self, irc, msg, args): """takes no arguments Immediately run nick-recovery commands for the current network and attempt to reclaim the configured nick. """ desired = _desired_nick(irc) if ircutils.strEqual(irc.nick, desired): irc.reply(_('Already using the configured nick (%s).' % desired)) return self._send_nick_recover_commands(irc) if self.registryValue('recoverNick', network=irc.network): self._start_nick_poll(irc) irc.noReply() claimnick = wrap(claimnick, ['admin']) # --- Manual reop ------------------------------------------------------- def reop(self, irc, msg, args, channel): """[] Attempt to recover ops in : tries halfop self-op first, then falls back to onDeopCommand if configured. defaults to the current channel. """ if channel not in irc.state.channels: irc.error(_('I am not in %s.' % channel), Raise=True) chanstate = irc.state.channels[channel] if irc.nick in chanstate.ops: irc.reply(_('I already have ops in %s.' % channel)) return if irc.nick in chanstate.halfops: irc.queueMsg(ircmsgs.op(channel, irc.nick)) irc.replySuccess() return cmd_raw = self.registryValue('onDeopCommand', channel, irc.network).strip() if cmd_raw: self._send_raw(irc, cmd_raw, channel=channel, label='onDeopCommand(manual)') irc.replySuccess() else: irc.error( _('No halfop and onDeopCommand not set for %s.' % channel), Raise=True) reop = wrap(reop, ['admin', 'inChannel']) # --- Manual self-unban ------------------------------------------------- def selfunban(self, irc, msg, args, channel): """[] Attempt to remove own ban masks from via MODE -b. Requires the bot to currently have ops in the channel. defaults to the current channel. """ if self._try_self_unban(irc, channel): irc.replySuccess() else: irc.error( _('Cannot unban self in %s ' '(not opped there, or no matching bans).' % channel), Raise=True) selfunban = wrap(selfunban, ['admin', 'inChannel']) Class = Resilience # vim:set shiftwidth=4 softtabstop=4 expandtab textwidth=79: