From eb1e27e20b206ca29cfc4d3a8bacbaf126980ebc Mon Sep 17 00:00:00 2001 From: Valentin Lorentz Date: Thu, 7 May 2020 21:00:16 +0200 Subject: [PATCH] Add echo messages, either with the echo-message capability or simulated. Plugin can opt in to getting echo messages by setting the class attribute 'echo_message = True' if they want to get echos. This defaults to False in order not to break existing plugins, and because they usually don't need it (there's outFilter for most cases). --- src/irclib.py | 66 +++++++++++++++++++++++++++++++++++++-------- test/test_irclib.py | 62 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+), 11 deletions(-) diff --git a/src/irclib.py b/src/irclib.py index d5cc74178..f12b23d33 100644 --- a/src/irclib.py +++ b/src/irclib.py @@ -117,6 +117,7 @@ class IrcCallback(IrcCommandDispatcher, log.Firewalled): """ callAfter = () callBefore = () + echo_message = False __firewalled__ = {'die': None, 'reset': None, '__call__': None, @@ -181,6 +182,20 @@ class IrcCallback(IrcCommandDispatcher, log.Firewalled): def __call__(self, irc, msg): """Used for handling each message.""" + if not self.echo_message \ + and msg.command in ('PRIVMSG', 'NOTICE', 'TAGMSG') \ + and ('label' in msg.server_tags + or not msg.tagged('receivedAt')): + # This is an echo of a message we sent; and the plugin didn't + # opt-in to receiving echos; ignoring it. + # `'label' in msg.server_tags` detects echos when labeled-response + # is enabled; and `not msg.tag('receivedAt')` detects simulated + # echos. As we don't enable real echo-message unless + # labeled-response is enabled; this is an exhaustive check of echos + # in all cases. + # See "When a client sends a private message to its own nick" at + # + return method = self.dispatchCommand(msg.command, msg.args) if method is not None: method(irc, msg) @@ -1057,12 +1072,19 @@ class Irc(IrcCommandDispatcher, log.Firewalled): self._truncateMsg(msg) - # I don't think we should do this. Why should it matter? If it's - # something important, then the server will send it back to us, - # and if it's just a privmsg/notice/etc., we don't care. - # On second thought, we need this for testing. - if world.testing: - self.state.addMsg(self, msg) + if msg.command.upper() in ('PRIVMSG', 'NOTICE', 'TAGMSG') \ + and 'echo-message' not in self.state.capabilities_ack: + # echo-message is not implemented by server; let's emulate it + # here, just before sending it to the driver. + assert not msg.tagged('receivedAt') + self.feedMsg(msg, tag=False) + else: + # I don't think we should do this. Why should it matter? If it's + # something important, then the server will send it back to us, + # and if it's just a privmsg/notice/etc., we don't care. + # On second thought, we need this for testing. + if world.testing: + self.state.addMsg(self, msg) log.debug('Outgoing message (%s): %s', self.network, str(msg).rstrip('\r\n')) return msg elif self.zombie: @@ -1098,9 +1120,13 @@ class Irc(IrcCommandDispatcher, log.Firewalled): return channel.lstrip(statusmsg_chars) _numericErrorCommandRe = re.compile(r'^[45][0-9][0-9]$') - def feedMsg(self, msg): - """Called by the IrcDriver; feeds a message received.""" - self._tagMsg(msg) + def feedMsg(self, msg, tag=True): + """Called by the IrcDriver; feeds a message received. + + `tag=False` is used when simulating echo messages, to skip adding + received* tags.""" + if tag: + self._tagMsg(msg) channel = msg.channel preInFilter = str(msg).rstrip('\r\n') @@ -1250,11 +1276,12 @@ class Irc(IrcCommandDispatcher, log.Firewalled): self.REQUEST_CAPABILITIES.add('sasl') + # Note: echo-message is only requested if labeled-response is available. REQUEST_CAPABILITIES = set(['account-notify', 'extended-join', 'multi-prefix', 'metadata-notify', 'account-tag', 'userhost-in-names', 'invite-notify', 'server-time', 'chghost', 'batch', 'away-notify', 'message-tags', - 'msgid', 'setname', 'labeled-response']) + 'msgid', 'setname', 'labeled-response', 'echo-message']) def _queueConnectMessages(self): if self.zombie: @@ -1654,7 +1681,24 @@ class Irc(IrcCommandDispatcher, log.Firewalled): def _requestCaps(self, caps): self.state.capabilities_req |= caps - caps = ' '.join(sorted(caps)) + caps = list(sorted(caps)) + cap_lines = [] + if 'echo-message' in caps \ + and 'labeled-response' not in self.state.capabilities_ack: + # Make sure echo-message is never requested unless we either have + # labeled-response already, or we request it *on the same line* + # so they are both accepted or both rejected). The reason for this + # is that this is required to properly deal with PRIVMSGs sent to + # oneself. + # See "When a client sends a private message to its own nick" at + # + caps.remove('echo-message') + if 'labeled-response' in caps: + caps.remove('labeled-response') + # This makes sure they are always on the same line (which + # happens to be the first): + caps = ['echo-message', 'labeled-response'] + caps + caps = ' '.join(caps) # textwrap works here because in ASCII, all chars are 1 bytes: cap_lines = textwrap.wrap( caps, MAX_LINE_SIZE-len('CAP REQ :'), diff --git a/test/test_irclib.py b/test/test_irclib.py index 555da2440..04fb954ec 100644 --- a/test/test_irclib.py +++ b/test/test_irclib.py @@ -498,6 +498,68 @@ class IrcCapsTestCase(SupyTestCase): self.assertEqual(m.args[0], 'REQ', m) self.assertEqual(m.args[1], 'b'*400) + def testNoEchomessageWithoutLabeledresponse(self): + self.irc = irclib.Irc('test') + + m = self.irc.takeMsg() + self.assertTrue(m.command == 'CAP', 'Expected CAP, got %r.' % m) + self.assertTrue(m.args == ('LS', '302'), 'Expected CAP LS 302, got %r.' % m) + + m = self.irc.takeMsg() + self.assertTrue(m.command == 'NICK', 'Expected NICK, got %r.' % m) + + m = self.irc.takeMsg() + self.assertTrue(m.command == 'USER', 'Expected USER, got %r.' % m) + + self.irc.feedMsg(ircmsgs.IrcMsg(command='CAP', + args=('*', 'LS', 'account-notify echo-message'))) + + m = self.irc.takeMsg() + self.assertTrue(m.command == 'CAP', 'Expected CAP, got %r.' % m) + self.assertEqual(m.args[0], 'REQ', m) + self.assertEqual(m.args[1], 'account-notify') + + m = self.irc.takeMsg() + self.assertIsNone(m) + + def testEchomessageLabeledresponseGrouped(self): + self.irc = irclib.Irc('test') + + m = self.irc.takeMsg() + self.assertTrue(m.command == 'CAP', 'Expected CAP, got %r.' % m) + self.assertTrue(m.args == ('LS', '302'), 'Expected CAP LS 302, got %r.' % m) + + m = self.irc.takeMsg() + self.assertTrue(m.command == 'NICK', 'Expected NICK, got %r.' % m) + + m = self.irc.takeMsg() + self.assertTrue(m.command == 'USER', 'Expected USER, got %r.' % m) + + self.irc.REQUEST_CAPABILITIES = set([ + 'account-notify', 'a'*490, 'echo-message', 'labeled-response']) + self.irc.feedMsg(ircmsgs.IrcMsg(command='CAP', args=( + '*', 'LS', + 'account-notify ' + 'a'*490 + ' echo-message labeled-response'))) + + m = self.irc.takeMsg() + self.assertTrue(m.command == 'CAP', 'Expected CAP, got %r.' % m) + self.assertEqual(m.args[0], 'REQ', m) + self.assertEqual(m.args[1], 'echo-message labeled-response') + + m = self.irc.takeMsg() + self.assertTrue(m.command == 'CAP', 'Expected CAP, got %r.' % m) + self.assertEqual(m.args[0], 'REQ', m) + self.assertEqual(m.args[1], 'a'*490) + + m = self.irc.takeMsg() + self.assertTrue(m.command == 'CAP', 'Expected CAP, got %r.' % m) + self.assertEqual(m.args[0], 'REQ', m) + self.assertEqual(m.args[1], 'account-notify') + + m = self.irc.takeMsg() + self.assertIsNone(m) + + class StsTestCase(SupyTestCase): def setUp(self): self.irc = irclib.Irc('test')