Route commands from Network.command back to the original network (#1540)

Add a replyIrc parameter to ReplyIrcProxy to run a command on one network, but route the replies to another.
This fixes a long standing issue where replies for remote commands are often lost to the void, as the nick of the caller may not exist on the target network (or worse, it could belong to a completely unrelated person).

Closes GH-556.

Co-authored-by: Val Lorentz <progval+git@progval.net>
This commit is contained in:
James Lu 2023-06-04 12:39:56 -07:00 committed by GitHub
parent 654937ecfc
commit 416a05e326
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 56 additions and 26 deletions

View File

@ -149,7 +149,7 @@ class Network(callbacks.Plugin):
Gives the bot <command> (with its associated <arg>s) on <network>. Gives the bot <command> (with its associated <arg>s) on <network>.
""" """
self.Proxy(otherIrc, msg, commandAndArgs) self.Proxy(otherIrc, msg, commandAndArgs, replyIrc=irc)
command = wrap(command, ['admin', ('networkIrc', True), many('something')]) command = wrap(command, ['admin', ('networkIrc', True), many('something')])
def cmdall(self, irc, msg, args, commandAndArgs): def cmdall(self, irc, msg, args, commandAndArgs):

View File

@ -31,7 +31,7 @@
from supybot.test import * from supybot.test import *
class NetworkTestCase(PluginTestCase): class NetworkTestCase(PluginTestCase):
plugins = ['Network', 'Utilities'] plugins = ['Network', 'Utilities', 'String', 'Misc']
def testNetworks(self): def testNetworks(self):
self.assertNotError('networks') self.assertNotError('networks')
@ -39,6 +39,28 @@ class NetworkTestCase(PluginTestCase):
self.assertResponse('network command %s echo 1' % self.irc.network, self.assertResponse('network command %s echo 1' % self.irc.network,
'1') '1')
def testCommandRoutesBackToCaller(self):
self.otherIrc = getTestIrc("testnet1")
# This will fail with timeout if the response never comes back
self.assertResponse(
'network command testnet1 echo $network', 'testnet1')
def testCommandRoutesErrorsBackToCaller(self):
self.otherIrc = getTestIrc("testnet2")
self.assertRegexp(
f'network command testnet2 re s/.*// test',
'I tried to send you an empty message')
def testCommandRoutesMoreBackToCaller(self):
self.otherIrc = getTestIrc("testnet3")
self.assertNotError('clearmores')
self.assertError('more')
self.assertRegexp(
f'network command testnet3 echo {"Hello"*300}',
r'Hello.*\(\d+ more messages\)')
self.assertRegexp(
'more',
r'Hello.*\(\d+ more messages\)')
# vim:set shiftwidth=4 softtabstop=4 expandtab textwidth=79: # vim:set shiftwidth=4 softtabstop=4 expandtab textwidth=79:

View File

@ -638,10 +638,15 @@ _repr = repr
class ReplyIrcProxy(RichReplyMethods): class ReplyIrcProxy(RichReplyMethods):
"""This class is a thin wrapper around an irclib.Irc object that gives it """This class is a thin wrapper around an irclib.Irc object that gives it
the reply() and error() methods (as well as everything in RichReplyMethods, the reply() and error() methods (as well as everything in RichReplyMethods,
based on those two).""" based on those two).
If ``replyIrc`` is given in addition to ``irc``, commands will be run on ``irc``
but replies will be delivered to ``replyIrc``. This is used by the Network
plugin to run commands on other networks."""
_mores = ircutils.IrcDict() _mores = ircutils.IrcDict()
def __init__(self, irc, msg): def __init__(self, irc, msg, replyIrc=None):
self.irc = irc self.irc = irc
self.replyIrc = replyIrc or irc
self.msg = msg self.msg = msg
self.getRealIrc()._setMsgChannel(self.msg) self.getRealIrc()._setMsgChannel(self.msg)
@ -669,8 +674,8 @@ class ReplyIrcProxy(RichReplyMethods):
if msg is None: if msg is None:
msg = self.msg msg = self.msg
if s: if s:
m = _makeErrorReply(self, msg, s, **kwargs) m = _makeErrorReply(self.replyIrc, msg, s, **kwargs)
self.irc.queueMsg(m) self.replyIrc.queueMsg(m)
return m return m
def _defaultPrefixNick(self, msg): def _defaultPrefixNick(self, msg):
@ -754,29 +759,29 @@ class ReplyIrcProxy(RichReplyMethods):
def _sendReply(self, s, target, msg, sendImmediately=False, def _sendReply(self, s, target, msg, sendImmediately=False,
noLengthCheck=False, **kwargs): noLengthCheck=False, **kwargs):
if sendImmediately: if sendImmediately:
sendMsg = self.irc.sendMsg sendMsg = self.replyIrc.sendMsg
else: else:
sendMsg = self.irc.queueMsg sendMsg = self.replyIrc.queueMsg
if isinstance(self.irc, self.__class__): if isinstance(self.replyIrc, self.__class__):
s = s[:conf.supybot.reply.maximumLength()] s = s[:conf.supybot.reply.maximumLength()]
return self.irc.reply(s, return self.replyIrc.reply(s,
noLengthCheck=noLengthCheck, noLengthCheck=noLengthCheck,
**kwargs) **kwargs)
elif noLengthCheck: elif noLengthCheck:
# noLengthCheck only matters to NestedCommandsIrcProxy, so # noLengthCheck only matters to NestedCommandsIrcProxy, so
# it's not used here. Just in case you were wondering. # it's not used here. Just in case you were wondering.
m = _makeReply(self, msg, s, **kwargs) m = _makeReply(self.replyIrc, msg, s, **kwargs)
sendMsg(m) sendMsg(m)
return m return m
else: else:
s = ircutils.safeArgument(s) s = ircutils.safeArgument(s)
allowedLength = conf.get(conf.supybot.reply.mores.length, allowedLength = conf.get(conf.supybot.reply.mores.length,
channel=target, network=self.irc.network) channel=target, network=self.replyIrc.network)
if not allowedLength: # 0 indicates this. if not allowedLength: # 0 indicates this.
allowedLength = 512 - self._replyOverhead(msg, **kwargs) allowedLength = 512 - self._replyOverhead(msg, **kwargs)
maximumMores = conf.get(conf.supybot.reply.mores.maximum, maximumMores = conf.get(conf.supybot.reply.mores.maximum,
channel=target, network=self.irc.network) channel=target, network=self.replyIrc.network)
maximumLength = allowedLength * maximumMores maximumLength = allowedLength * maximumMores
if len(s) > maximumLength: if len(s) > maximumLength:
log.warning('Truncating to %s bytes from %s bytes.', log.warning('Truncating to %s bytes from %s bytes.',
@ -785,12 +790,12 @@ class ReplyIrcProxy(RichReplyMethods):
s_size = len(s.encode()) if minisix.PY3 else len(s) s_size = len(s.encode()) if minisix.PY3 else len(s)
if s_size <= allowedLength or \ if s_size <= allowedLength or \
not conf.get(conf.supybot.reply.mores, not conf.get(conf.supybot.reply.mores,
channel=target, network=self.irc.network): channel=target, network=self.replyIrc.network):
# There's no need for action=self.action here because # There's no need for action=self.action here because
# action implies noLengthCheck, which has already been # action implies noLengthCheck, which has already been
# handled. Let's stick an assert in here just in case. # handled. Let's stick an assert in here just in case.
assert not kwargs.get('action') assert not kwargs.get('action')
m = _makeReply(self, msg, s, **kwargs) m = _makeReply(self.replyIrc, msg, s, **kwargs)
sendMsg(m) sendMsg(m)
return m return m
# The '(XX more messages)' may have not the same # The '(XX more messages)' may have not the same
@ -803,7 +808,7 @@ class ReplyIrcProxy(RichReplyMethods):
chunks.reverse() chunks.reverse()
instant = conf.get(conf.supybot.reply.mores.instant, instant = conf.get(conf.supybot.reply.mores.instant,
channel=target, network=self.irc.network) channel=target, network=self.replyIrc.network)
# Big complex loop ahead, with lots of cases and opportunities for # Big complex loop ahead, with lots of cases and opportunities for
# off-by-one errors. Here is the meaning of each of the variables # off-by-one errors. Here is the meaning of each of the variables
@ -859,9 +864,9 @@ class ReplyIrcProxy(RichReplyMethods):
if is_instant and not is_first: if is_instant and not is_first:
d = kwargs.copy() d = kwargs.copy()
d['prefixNick'] = False d['prefixNick'] = False
msgs.append(_makeReply(self, msg, chunk, **d)) msgs.append(_makeReply(self.replyIrc, msg, chunk, **d))
else: else:
msgs.append(_makeReply(self, msg, chunk, **kwargs)) msgs.append(_makeReply(self.replyIrc, msg, chunk, **kwargs))
instant_messages = [] instant_messages = []
@ -876,13 +881,15 @@ class ReplyIrcProxy(RichReplyMethods):
# return m # return m
if conf.supybot.protocols.irc.experimentalExtensions() \ if conf.supybot.protocols.irc.experimentalExtensions() \
and 'draft/multiline' in self.state.capabilities_ack \ and 'draft/multiline' in \
self.replyIrc.state.capabilities_ack \
and len(instant_messages) > 1: and len(instant_messages) > 1:
# More than one message to send now, and we are allowed to use # More than one message to send now, and we are allowed to use
# multiline batches, so let's do it # multiline batches, so let's do it
self.queueMultilineBatches( self.replyIrc.queueMultilineBatches(
instant_messages, target, msg.nick, concat=True, instant_messages, target, msg.nick, concat=True,
allowedLength=allowedLength, sendImmediately=sendImmediately) allowedLength=allowedLength,
sendImmediately=sendImmediately)
else: else:
for instant_msg in instant_messages: for instant_msg in instant_messages:
sendMsg(instant_msg) sendMsg(instant_msg)
@ -892,7 +899,7 @@ class ReplyIrcProxy(RichReplyMethods):
prefix = msg.prefix prefix = msg.prefix
if target and ircutils.isNick(target): if target and ircutils.isNick(target):
try: try:
state = self.getRealIrc().state state = self.replyIrc.state
prefix = state.nickToHostmask(target) prefix = state.nickToHostmask(target)
except KeyError: except KeyError:
pass # We'll leave it as it is. pass # We'll leave it as it is.
@ -973,9 +980,10 @@ SimpleProxy = ReplyIrcProxy # Backwards-compatibility
class NestedCommandsIrcProxy(ReplyIrcProxy): class NestedCommandsIrcProxy(ReplyIrcProxy):
"A proxy object to allow proper nesting of commands (even threaded ones)." "A proxy object to allow proper nesting of commands (even threaded ones)."
def __init__(self, irc, msg, args, nested=0): def __init__(self, irc, msg, args, nested=0, replyIrc=None):
assert isinstance(args, list), 'Args should be a list, not a string.' assert isinstance(args, list), 'Args should be a list, not a string.'
super(NestedCommandsIrcProxy, self).__init__(irc, msg) super(NestedCommandsIrcProxy, self).__init__(
irc, msg, replyIrc=replyIrc)
self.nested = nested self.nested = nested
self.repliedTo = False self.repliedTo = False
if not self.nested and isinstance(irc, self.__class__): if not self.nested and isinstance(irc, self.__class__):