From 2902a85dbd1bd86a09599a276e99ace4732e7712 Mon Sep 17 00:00:00 2001 From: Val Lorentz Date: Sat, 10 Jun 2023 08:28:08 +0200 Subject: [PATCH 01/73] Fix STS parsing and handling of unchecked-TLS connections (#1524) * ircutils: Fix incorrect log message on invalid STS policy * STS: fix confusion over what a secure connection is irclib computed 'secure_connection' when TLS is enabled and TLS certs are checked; but ircutils used the value to parse STS policies, which should only care about being TLS or not. This commit fixes the incorrect parsing on unchecked-TLS, and triggers a reconnect when a STS policy is encountered in this case, to force TLS certs to be checked before storing the policy. * Accept STS policies when reconnecting after getting it over cleartext ircutils.parseStsPolicy() was passed self.driver.ssl which is the configured value, even though the connection was forced to be TLS temporarily * ci: Lower timeout * Fix typo in test name Co-authored-by: James Lu --------- Co-authored-by: James Lu --- .github/workflows/test.yml | 1 + src/drivers/__init__.py | 2 +- src/irclib.py | 23 +++++++++++-- src/ircutils.py | 13 ++++--- test/test_irclib.py | 70 +++++++++++++++++++++----------------- 5 files changed, 67 insertions(+), 42 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c04590105..695ad1f18 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,6 +11,7 @@ jobs: build: runs-on: ${{ matrix.runs-on }} + timeout-minutes: 10 strategy: matrix: include: diff --git a/src/drivers/__init__.py b/src/drivers/__init__.py index 453901587..d09caa716 100644 --- a/src/drivers/__init__.py +++ b/src/drivers/__init__.py @@ -108,7 +108,7 @@ class ServersMixin(object): # The policy was stored, which means it was received on a secure # connection. - policy = ircutils.parseStsPolicy(log, policy, secure_connection=True) + policy = ircutils.parseStsPolicy(log, policy, tls_connection=True) if lastDisconnect + policy['duration'] < time.time(): log.info('STS policy expired, removing.') diff --git a/src/irclib.py b/src/irclib.py index 6929ebb70..b3ed60036 100644 --- a/src/irclib.py +++ b/src/irclib.py @@ -2079,11 +2079,13 @@ class Irc(IrcCommandDispatcher, log.Firewalled): self.capUpkeep(msg) def _onCapSts(self, policy, msg): + tls_connection = self.driver.currentServer.force_tls_verification \ + or self.driver.ssl secure_connection = self.driver.currentServer.force_tls_verification \ or (self.driver.ssl and self.driver.anyCertValidationEnabled()) parsed_policy = ircutils.parseStsPolicy( - log, policy, secure_connection=secure_connection) + log, policy, tls_connection=tls_connection) if parsed_policy is None: # There was an error (and it was logged). Ignore it and proceed # with the connection. @@ -2106,11 +2108,28 @@ class Irc(IrcCommandDispatcher, log.Firewalled): self.driver.currentServer.hostname, self.driver.currentServer.port, policy) + elif self.driver.ssl: + # SSL enabled, but certificates are not checked -> reconnect on the + # same port and check certificates, before storing the STS policy. + hostname = self.driver.currentServer.hostname + port = self.driver.currentServer.port + attempt = self.driver.currentServer.attempt + + log.info('Got STS policy over insecure TLS connection; ' + 'reconnecting to check certificates. %r', + self.driver.currentServer) + # Reconnect to the server, but with TLS *and* certificate + # validation this time. + self.state.fsm.on_shutdown(self, msg) + + self.driver.reconnect( + server=Server(hostname, port, attempt, True), + wait=True) else: hostname = self.driver.currentServer.hostname attempt = self.driver.currentServer.attempt - log.info('Got STS policy over insecure connection; ' + log.info('Got STS policy over insecure (cleartext) connection; ' 'reconnecting to secure port. %r', self.driver.currentServer) # Reconnect to the server, but with TLS *and* certificate diff --git a/src/ircutils.py b/src/ircutils.py index da7397810..035ba9a13 100644 --- a/src/ircutils.py +++ b/src/ircutils.py @@ -1072,28 +1072,27 @@ def parseCapabilityKeyValue(s): return d - -def parseStsPolicy(logger, policy, secure_connection): +def parseStsPolicy(logger, policy, tls_connection): parsed_policy = parseCapabilityKeyValue(policy) for key in ('port', 'duration'): - if key == 'duration' and not secure_connection: + if key == 'duration' and not tls_connection: if key in parsed_policy: del parsed_policy[key] continue - elif key == 'port' and secure_connection: + elif key == 'port' and tls_connection: if key in parsed_policy: del parsed_policy[key] continue if parsed_policy.get(key) is None: - logger.error('Missing or empty "%s" key in STS policy.' - 'Aborting connection.', key) + logger.error('Missing or empty "%s" key in STS policy. ' + 'Ignoring policy.', key) return None try: parsed_policy[key] = int(parsed_policy[key]) except ValueError: logger.error('Expected integer as value for key "%s" in STS ' - 'policy, got %r instead. Aborting connection.', + 'policy, got %r instead. Ignoring policy.', key, parsed_policy[key]) return None diff --git a/test/test_irclib.py b/test/test_irclib.py index fdad88159..230b1468b 100644 --- a/test/test_irclib.py +++ b/test/test_irclib.py @@ -840,79 +840,85 @@ class StsTestCase(SupyTestCase): def tearDown(self): ircdb.networks.networks = {} - def testStsInSecureConnection(self): + def _testStsInSecureConnection(self, cap_value): self.irc.driver.anyCertValidationEnabled.return_value = True self.irc.driver.ssl = True self.irc.driver.currentServer = drivers.Server('irc.test', 6697, None, False) self.irc.feedMsg(ircmsgs.IrcMsg(command='CAP', - args=('*', 'LS', 'sts=duration=42,port=12345'))) + args=('*', 'LS', 'sts=' + cap_value))) self.assertEqual(ircdb.networks.getNetwork('test').stsPolicies, { - 'irc.test': (6697, 'duration=42,port=12345')}) + 'irc.test': (6697, cap_value)}) self.irc.driver.reconnect.assert_not_called() - def testStsInSecureConnectionNoPort(self): + def testStsInSecureConnectionWithPort(self): + self._testStsInSecureConnection('duration=42,port=12345') + + def testStsInSecureConnectionWithoutPort(self): + self._testStsInSecureConnection('duration=42') + + def testStsInSecureConnectionMissingDuration(self): + # "A persistence policy, expressed via the duration key. REQUIRED on a + # secure connection" self.irc.driver.anyCertValidationEnabled.return_value = True self.irc.driver.ssl = True self.irc.driver.currentServer = drivers.Server('irc.test', 6697, None, False) self.irc.feedMsg(ircmsgs.IrcMsg(command='CAP', - args=('*', 'LS', 'sts=duration=42'))) + args=('*', 'LS', 'sts=port=12345'))) - self.assertEqual(ircdb.networks.getNetwork('test').stsPolicies, { - 'irc.test': (6697, 'duration=42')}) + self.assertEqual(ircdb.networks.getNetwork('test').stsPolicies, {}) self.irc.driver.reconnect.assert_not_called() - def testStsInInsecureTlsConnection(self): + def _testStsInInsecureTlsConnection(self, cap_value): self.irc.driver.anyCertValidationEnabled.return_value = False self.irc.driver.ssl = True - self.irc.driver.currentServer = drivers.Server('irc.test', 6667, None, False) + self.irc.driver.currentServer = drivers.Server('irc.test', 6697, None, False) self.irc.feedMsg(ircmsgs.IrcMsg(command='CAP', - args=('*', 'LS', 'sts=duration=42,port=6697'))) + args=('*', 'LS', 'sts=' + cap_value))) self.assertEqual(ircdb.networks.getNetwork('test').stsPolicies, {}) self.irc.driver.reconnect.assert_called_once_with( server=drivers.Server('irc.test', 6697, None, True), wait=True) - def testStsInCleartextConnection(self): + def testStsInInsecureTlsConnectionWithPort(self): + self._testStsInInsecureTlsConnection('duration=42,port=6697') + + def testStsInInsecureTlsConnectionWithoutPort(self): + self._testStsInInsecureTlsConnection('duration=42') + + def _testStsInCleartextConnection(self, cap_value): self.irc.driver.anyCertValidationEnabled.return_value = False - self.irc.driver.ssl = True + self.irc.driver.ssl = False self.irc.driver.currentServer = drivers.Server('irc.test', 6667, None, False) self.irc.feedMsg(ircmsgs.IrcMsg(command='CAP', - args=('*', 'LS', 'sts=duration=42,port=6697'))) + args=('*', 'LS', 'sts=' + cap_value))) self.assertEqual(ircdb.networks.getNetwork('test').stsPolicies, {}) self.irc.driver.reconnect.assert_called_once_with( server=drivers.Server('irc.test', 6697, None, True), wait=True) + def testStsInCleartextConnectionWithDuration(self): + self._testStsInCleartextConnection('duration=42,port=6697') + + def testStsInCleartextConnectionWithoutDuration(self): + self._testStsInCleartextConnection('port=6697') + def testStsInCleartextConnectionInvalidDuration(self): # "Servers MAY send this key to all clients, but insecurely # connected clients MUST ignore it." + self._testStsInCleartextConnection('duration=foo,port=6697') + + def testStsInCleartextConnectionMissingPort(self): self.irc.driver.anyCertValidationEnabled.return_value = False - self.irc.driver.ssl = True + self.irc.driver.ssl = False self.irc.driver.currentServer = drivers.Server('irc.test', 6667, None, False) self.irc.feedMsg(ircmsgs.IrcMsg(command='CAP', - args=('*', 'LS', 'sts=duration=foo,port=6697'))) + args=('*', 'LS', 'sts=duration=42'))) self.assertEqual(ircdb.networks.getNetwork('test').stsPolicies, {}) - self.irc.driver.reconnect.assert_called_once_with( - server=drivers.Server('irc.test', 6697, None, True), - wait=True) - - def testStsInCleartextConnectionNoDuration(self): - # "Servers MAY send this key to all clients, but insecurely - # connected clients MUST ignore it." - self.irc.driver.anyCertValidationEnabled.return_value = False - self.irc.driver.ssl = True - self.irc.driver.currentServer = drivers.Server('irc.test', 6667, None, False) - self.irc.feedMsg(ircmsgs.IrcMsg(command='CAP', - args=('*', 'LS', 'sts=port=6697'))) - - self.assertEqual(ircdb.networks.getNetwork('test').stsPolicies, {}) - self.irc.driver.reconnect.assert_called_once_with( - server=drivers.Server('irc.test', 6697, None, True), - wait=True) + self.irc.driver.reconnect.assert_not_called() class IrcTestCase(SupyTestCase): def setUp(self): From b4bf877e778b7ad36194027f9622c0e5b2aaa30a Mon Sep 17 00:00:00 2001 From: James Lu Date: Fri, 30 Jun 2023 19:40:49 -0700 Subject: [PATCH 02/73] Network: accept empty args in 'command' and 'cmdall' Closes GH-1541 --- plugins/Network/plugin.py | 4 ++-- plugins/Network/test.py | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/plugins/Network/plugin.py b/plugins/Network/plugin.py index d1271e364..53d3eb908 100644 --- a/plugins/Network/plugin.py +++ b/plugins/Network/plugin.py @@ -150,7 +150,7 @@ class Network(callbacks.Plugin): Gives the bot (with its associated s) on . """ self.Proxy(otherIrc, msg, commandAndArgs, replyIrc=irc) - command = wrap(command, ['admin', ('networkIrc', True), many('something')]) + command = wrap(command, ['admin', ('networkIrc', True), many('anything')]) def cmdall(self, irc, msg, args, commandAndArgs): """ [ ...] @@ -160,7 +160,7 @@ class Network(callbacks.Plugin): ircs = world.ircs for ircd in ircs: self.Proxy(ircd, msg, commandAndArgs) - cmdall = wrap(cmdall, ['admin', many('something')]) + cmdall = wrap(cmdall, ['admin', many('anything')]) ### # whois command-related stuff. diff --git a/plugins/Network/test.py b/plugins/Network/test.py index 436bf5430..5c53e0252 100644 --- a/plugins/Network/test.py +++ b/plugins/Network/test.py @@ -38,6 +38,9 @@ class NetworkTestCase(PluginTestCase): def testCommand(self): self.assertResponse('network command %s echo 1' % self.irc.network, '1') + # empty args should be allowed, see + # https://github.com/progval/Limnoria/issues/1541 + self.assertResponse('network command %s len ""' % self.irc.network, '0') def testCommandRoutesBackToCaller(self): self.otherIrc = getTestIrc("testnet1") From b374418c81c9aa16413bd90e4d6d28ece02455fc Mon Sep 17 00:00:00 2001 From: James Lu Date: Fri, 30 Jun 2023 20:07:45 -0700 Subject: [PATCH 03/73] irclib: fix mismatched arguments when logging IRCv3 cap responses --- src/irclib.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/irclib.py b/src/irclib.py index b3ed60036..2ba3e2cce 100644 --- a/src/irclib.py +++ b/src/irclib.py @@ -2061,7 +2061,7 @@ class Irc(IrcCommandDispatcher, log.Firewalled): return caps = msg.args[2].split() assert caps, 'Empty list of capabilities' - log.debug('%s: Server acknowledged capabilities: %L', + log.debug('%s: Server acknowledged capabilities: %s', self.network, caps) self.state.capabilities_ack.update(caps) @@ -2074,7 +2074,7 @@ class Irc(IrcCommandDispatcher, log.Firewalled): caps = msg.args[2].split() assert caps, 'Empty list of capabilities' self.state.capabilities_nak.update(caps) - log.warning('%s: Server refused capabilities: %L', + log.warning('%s: Server refused capabilities: %s', self.network, caps) self.capUpkeep(msg) From 8d1d4b84eb0e9f678e68df5ae0430ebdd07752ff Mon Sep 17 00:00:00 2001 From: Valentin Lorentz Date: Sat, 8 Jul 2023 16:42:15 +0200 Subject: [PATCH 04/73] Fix error message on invalid 'supybot.language' value --- src/i18n.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/i18n.py b/src/i18n.py index 37bfb355b..0b26774ad 100644 --- a/src/i18n.py +++ b/src/i18n.py @@ -72,6 +72,8 @@ def import_conf(): conf = __import__('supybot.conf').conf class Languages(conf.registry.OnlySomeStrings): validStrings = ['de', 'en', 'es', 'fi', 'fr', 'it'] + errormsg = 'Value should be a supported language (%s), not %%r' % ( + ', '.join(validStrings)) conf.registerGlobalValue(conf.supybot, 'language', Languages(currentLocale, """Determines the bot's default language if translations exist. Currently supported are: %s""" From eb002a31e94878d49cbc57c27102f037963354ed Mon Sep 17 00:00:00 2001 From: Valentin Lorentz Date: Sat, 8 Jul 2023 16:46:13 +0200 Subject: [PATCH 05/73] wizard: Check language is supported Otherwise it may raise InvalidRegistryValue on first start --- src/i18n.py | 3 ++- src/scripts/limnoria_wizard.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/i18n.py b/src/i18n.py index 0b26774ad..da1bc6167 100644 --- a/src/i18n.py +++ b/src/i18n.py @@ -50,6 +50,7 @@ MSGSTR = 'msgstr "' FUZZY = '#, fuzzy' currentLocale = 'en' +SUPPORTED_LANGUAGES = ['de', 'en', 'es', 'fi', 'fr', 'it'] class PluginNotFound(Exception): pass @@ -71,7 +72,7 @@ def import_conf(): global conf conf = __import__('supybot.conf').conf class Languages(conf.registry.OnlySomeStrings): - validStrings = ['de', 'en', 'es', 'fi', 'fr', 'it'] + validStrings = SUPPORTED_LANGUAGES errormsg = 'Value should be a supported language (%s), not %%r' % ( ', '.join(validStrings)) conf.registerGlobalValue(conf.supybot, 'language', diff --git a/src/scripts/limnoria_wizard.py b/src/scripts/limnoria_wizard.py index 71f545248..073739148 100644 --- a/src/scripts/limnoria_wizard.py +++ b/src/scripts/limnoria_wizard.py @@ -253,7 +253,8 @@ def _main(): language. This can be changed at any time. You need to answer with a short id for the language, such as 'en', 'fr', 'it' (without the quotes). If you want to use English, just press enter.""") - language = something('What language do you want to use?', default='en') + language = expect('What language do you want to use?', + i18n.SUPPORTED_LANGUAGES, default='en') class Empty: """This is a hack to allow the i18n to get the current language, before From 054ee6e4106b771d72a973cdd84eda5d4507ad74 Mon Sep 17 00:00:00 2001 From: Valentin Lorentz Date: Tue, 11 Jul 2023 21:34:49 +0200 Subject: [PATCH 06/73] Disable generic error reply when supybot.replies.error is empty --- src/callbacks.py | 7 +++++-- src/conf.py | 5 +++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/callbacks.py b/src/callbacks.py index 0844cca8e..e254d0bc3 100644 --- a/src/callbacks.py +++ b/src/callbacks.py @@ -506,8 +506,11 @@ class RichReplyMethods(object): msg = kwargs['msg'] if ircdb.checkCapability(msg.prefix, 'owner'): v = self._getConfig(conf.supybot.replies.errorOwner) - s = self.__makeReply(v, s) - return self.reply(s, **kwargs) + if v: + s = self.__makeReply(v, s) + return self.reply(s, **kwargs) + else: + self.noReply() def _getTarget(self, to=None): """Compute the target according to self.to, the provided to, diff --git a/src/conf.py b/src/conf.py index 16c554451..186419931 100644 --- a/src/conf.py +++ b/src/conf.py @@ -676,8 +676,9 @@ registerChannelValue(supybot.replies, 'success', registerChannelValue(supybot.replies, 'error', registry.NormalizedString(_("""An error has occurred and has been logged. - Please contact this bot's administrator for more information."""), _(""" - Determines what error message the bot gives when it wants to be + Please contact this bot's administrator for more information. + If this configuration variable is empty, no generic error message will be sent."""), + _("""Determines what error message the bot gives when it wants to be ambiguous."""))) registerChannelValue(supybot.replies, 'errorOwner', From 2b4c5eb78fb685c530e815e150d4d088b2688a35 Mon Sep 17 00:00:00 2001 From: Valentin Lorentz Date: Tue, 18 Jul 2023 07:45:26 +0200 Subject: [PATCH 07/73] Fix crash when calling .reply(..., action=True) on ReplyIrcProxy instead of on NestedCommandIrcProxy. ReplyIrcProxy._sendReply expects action=True to imply noLengthCheck=True, but only NestedCommandIrcProxy.reply() enforces the latter, not ReplyIrcProxy.reply(). This crash was introduced in 3c1c4a69e9927bcc7265b1d77fd1ab49cb55090e by moving NestedCommandIrcProxy's .reply() to ReplyIrcProxy. --- src/callbacks.py | 3 +++ test/test_callbacks.py | 14 ++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/src/callbacks.py b/src/callbacks.py index e254d0bc3..28b1fbabf 100644 --- a/src/callbacks.py +++ b/src/callbacks.py @@ -733,6 +733,9 @@ class ReplyIrcProxy(RichReplyMethods): kwargs['target'] = kwargs.get('to', None) or msg.args[0] if 'prefixNick' not in kwargs: kwargs['prefixNick'] = self._defaultPrefixNick(msg) + if kwargs.get("action"): + kwargs["prefixNick"] = False + kwargs["noLengthCheck"] = True self._sendReply(s, msg=msg, **kwargs) def __getattr__(self, attr): diff --git a/test/test_callbacks.py b/test/test_callbacks.py index 8420a0263..33c0d561b 100644 --- a/test/test_callbacks.py +++ b/test/test_callbacks.py @@ -684,12 +684,26 @@ class PrivmsgTestCase(ChannelPluginTestCase): irc.reply('foo', action=True) irc.reply('bar') # We're going to check that this isn't an action. + def doNotice(self, irc, msg): + irc.reply('foo', action=True) + irc.reply('bar') # We're going to check that this isn't an action. + def testNotActionSecondReply(self): self.irc.addCallback(self.TwoRepliesFirstAction(self.irc)) self.assertAction('testactionreply', 'foo') m = self.getMsg(' ') self.assertFalse(m.args[1].startswith('\x01ACTION')) + def testNotActionSecondReplyNotCommand(self): + """Same as testNotActionSecondReply, but tests ReplyIrcProxy instead of + NestedCommandsIrcProxy.""" + self.irc.addCallback(self.TwoRepliesFirstAction(self.irc)) + self.irc.feedMsg(ircmsgs.notice(self.channel, 'test action reply', + prefix=self.prefix)) + self.assertAction(' ', 'foo') + m = self.getMsg(' ') + self.assertFalse(m.args[1].startswith('\x01ACTION')) + def testEmptyNest(self): try: conf.supybot.reply.whenNotCommand.set('True') From 8168c52939805a923b25510865016222b7a7f314 Mon Sep 17 00:00:00 2001 From: Valentin Lorentz Date: Wed, 26 Jul 2023 14:20:45 +0200 Subject: [PATCH 08/73] RSS: Fix traceback in tests with new feedparser versions --- plugins/RSS/test.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/plugins/RSS/test.py b/plugins/RSS/test.py index 9427d63dd..d94cf6717 100644 --- a/plugins/RSS/test.py +++ b/plugins/RSS/test.py @@ -69,6 +69,9 @@ class MockResponse: def close(self): pass + def geturl(self): + return url + def mock_urllib(f): mock = MockResponse() From bb3d456fdf46eae50fddb851c601e89b001b7141 Mon Sep 17 00:00:00 2001 From: Valentin Lorentz Date: Wed, 26 Jul 2023 14:21:30 +0200 Subject: [PATCH 09/73] RSS: Add support for feed attributes in template string See https://feedparser.readthedocs.io/en/latest/common-rss-elements.html#accessing-common-channel-elements --- plugins/RSS/config.py | 4 +++- plugins/RSS/plugin.py | 9 +++++---- plugins/RSS/test.py | 8 ++++++++ 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/plugins/RSS/config.py b/plugins/RSS/config.py index 7702ebd9d..272b8b31b 100644 --- a/plugins/RSS/config.py +++ b/plugins/RSS/config.py @@ -68,7 +68,9 @@ conf.registerChannelValue(RSS, 'format', will use for displaying headlines of a RSS feed that is triggered manually. In addition to fields defined by feedparser ($published (the entry date), $title, $link, $description, $id, etc.), the following - variables can be used: $feed_name, $date (parsed date, as defined in + variables can be used: $feed_name (the configured name) + $feed_title/$feed_subtitle/$feed_author/$feed_language/$feed_link, + $date (parsed date, as defined in supybot.reply.format.time)"""))) conf.registerChannelValue(RSS, 'announceFormat', registry.String(_('News from $feed_name: $title <$link>'), diff --git a/plugins/RSS/plugin.py b/plugins/RSS/plugin.py index 23090e1b4..6f136d928 100644 --- a/plugins/RSS/plugin.py +++ b/plugins/RSS/plugin.py @@ -493,10 +493,11 @@ class RSS(callbacks.Plugin): template = self.registryValue(key_name, channel, network) date = entry.get('published_parsed') date = utils.str.timestamp(date) - s = string.Template(template).safe_substitute( - entry, - feed_name=feed.name, - date=date) + kwargs = {"feed_%s" % k: v for (k, v) in feed.data.items() if + isinstance(v, str)} + kwargs["feed_name"] = feed.name + kwargs.update(entry) + s = string.Template(template).safe_substitute(entry, **kwargs, date=date) return self._normalize_entry(s) def announce_entry(self, irc, channel, feed, entry): diff --git a/plugins/RSS/test.py b/plugins/RSS/test.py index d94cf6717..a3e67a9ba 100644 --- a/plugins/RSS/test.py +++ b/plugins/RSS/test.py @@ -359,6 +359,14 @@ class RSSTestCase(ChannelPluginTestCase): self.assertRegexp('rss http://xkcd.com/rss.xml', 'On the other hand, the refractor\'s') + @mock_urllib + def testFeedAttribute(self, mock): + timeFastForward(1.1) + with conf.supybot.plugins.RSS.format.context('$feed_title: $title'): + mock._data = xkcd_new + self.assertRegexp('rss http://xkcd.com/rss.xml', + r'xkcd\.com: Telescopes') + @mock_urllib def testBadlyFormedFeedWithNoItems(self, mock): # This combination will cause the RSS command to show the last parser From 71ae97ef5e4f211c9c36033d1b2c8607de7e4f8d Mon Sep 17 00:00:00 2001 From: Valentin Lorentz Date: Wed, 2 Aug 2023 20:39:00 +0200 Subject: [PATCH 10/73] MessageParser: On syntax error, detail which action caused the error This can help users debug it. --- plugins/MessageParser/plugin.py | 11 ++++++----- plugins/MessageParser/test.py | 5 ++++- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/plugins/MessageParser/plugin.py b/plugins/MessageParser/plugin.py index 981f3a70b..baca48ad4 100644 --- a/plugins/MessageParser/plugin.py +++ b/plugins/MessageParser/plugin.py @@ -128,7 +128,7 @@ class MessageParser(callbacks.Plugin, plugins.ChannelDBHandler): cursor.execute("UPDATE triggers SET usage_count=? WHERE regexp=?", (old_count + 1, regexp,)) db.commit() - def _runCommandFunction(self, irc, msg, command): + def _runCommandFunction(self, irc, msg, command, action_name): """Run a command from message, as if command was sent over IRC.""" try: tokens = callbacks.tokenize(command, @@ -136,7 +136,8 @@ class MessageParser(callbacks.Plugin, plugins.ChannelDBHandler): except SyntaxError as e: # Emulate what callbacks.py does self.log.debug('Error return: %s', utils.exnToString(e)) - irc.error(str(e)) + irc.error(format('%s, in %r (triggered by %r)', + e, command, action_name)) try: self.Proxy(irc.irc, msg, tokens) except Exception as e: @@ -200,15 +201,15 @@ class MessageParser(callbacks.Plugin, plugins.ChannelDBHandler): # Need a lambda to prevent re.sub from # interpreting backslashes in the replacement thisaction = re.sub(r'\$' + str(i+1), lambda _: match.group(i+1), thisaction) - actions.append(thisaction) + actions.append((regexp, thisaction)) if max_triggers != 0 and max_triggers == len(actions): break if max_triggers != 0 and max_triggers == len(actions): break - for action in actions: - self._runCommandFunction(irc, msg, action) + for (regexp, action) in actions: + self._runCommandFunction(irc, msg, action, regexp) def doPrivmsg(self, irc, msg): if not callbacks.addressed(irc, msg): #message is not direct command diff --git a/plugins/MessageParser/test.py b/plugins/MessageParser/test.py index f65c7d0bd..6aeb4ddce 100644 --- a/plugins/MessageParser/test.py +++ b/plugins/MessageParser/test.py @@ -89,7 +89,10 @@ class MessageParserTestCase(ChannelPluginTestCase): def testSyntaxError(self): self.assertNotError(r'messageparser add "test" "echo foo \" bar"') self.feedMsg('test') - self.assertResponse(' ', 'Error: No closing quotation') + self.assertResponse( + ' ', + r"""Error: No closing quotation, in """ + r"""'echo foo " bar' (triggered by 'test')""") def testMatchedBackslashes(self): # Makes sure backslashes in matched arguments are not interpreted From 5357f50bed9a830994faf663416c4b05b21f00b0 Mon Sep 17 00:00:00 2001 From: Valentin Lorentz Date: Thu, 10 Aug 2023 08:02:50 +0200 Subject: [PATCH 11/73] Geography: Replace Canada/Newfoundland with America/St_Johns in tests https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=1040997 --- plugins/Geography/test.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/plugins/Geography/test.py b/plugins/Geography/test.py index cc88a47fd..0b496d2fa 100644 --- a/plugins/Geography/test.py +++ b/plugins/Geography/test.py @@ -79,11 +79,11 @@ class GeographyTimezoneTestCase(PluginTestCase): "timezone New York", r"America/New_York \(currently UTC-[45]\)" ) - tz = pytz.timezone("Canada/Newfoundland") + tz = pytz.timezone("America/St_Johns") with patch.object(wikidata, "timezone_from_uri", return_value=tz): self.assertRegexp( "timezone Newfoundland", - r"Canada/Newfoundland \(currently UTC-[23]:30\)", + r"America/St_Johns \(currently UTC-[23]:30\)", ) tz = pytz.timezone("Asia/Kolkata") @@ -107,11 +107,11 @@ class GeographyTimezoneTestCase(PluginTestCase): "timezone New York", r"America/New_York \(currently UTC-[45]\)" ) - tz = zoneinfo.ZoneInfo("Canada/Newfoundland") + tz = zoneinfo.ZoneInfo("America/St_Johns") with patch.object(wikidata, "timezone_from_uri", return_value=tz): self.assertRegexp( "timezone Newfoundland", - r"Canada/Newfoundland \(currently UTC-[23]:30\)", + r"America/St_Johns \(currently UTC-[23]:30\)", ) tz = zoneinfo.ZoneInfo("Asia/Kolkata") From acad80296a8a4f55557f23e0ef29fbc0533a6457 Mon Sep 17 00:00:00 2001 From: Valentin Lorentz Date: Tue, 29 Aug 2023 22:49:18 +0200 Subject: [PATCH 12/73] Services: Update 'identified' state using SASL status Otherwise features like auto-opping are permanently unavailable when using SASL instead of NickServ IDENTIFY --- plugins/Services/plugin.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/plugins/Services/plugin.py b/plugins/Services/plugin.py index 3087804b2..2ef33ebe0 100644 --- a/plugins/Services/plugin.py +++ b/plugins/Services/plugin.py @@ -372,6 +372,30 @@ class Services(callbacks.Plugin): self.log.info('Received notice from NickServ %s: %q.', on, ircutils.stripFormatting(msg.args[1])) + def do903(self, irc, msg): # RPL_SASLSUCCESS + if self.disabled(irc): + return + state = self._getState(irc) + state.identified = True + for channel in irc.state.channels.keys(): + self.checkPrivileges(irc, channel) + if irc.state.fsm in [irclib.IrcStateFsm.CONNECTED, + irclib.IrcStateFsm.CONNECTED_SASL]: + for channel in state.channels: + irc.queueMsg(networkGroup.channels.join(channel)) + waitingJoins = state.waitingJoins + state.waitingJoins = [] + for join in waitingJoins: + irc.sendMsg(join) + + do907 = do903 # ERR_SASLALREADY, just to be sure we didn't miss it + + def do901(self, irc, msg): # RPL_LOGGEDOUT + if self.disabled(irc): + return + state = self._getState(irc) + state.identified = False + def checkPrivileges(self, irc, channel): if self.disabled(irc): return From 8029e2b3900ffc1ed0693e37bc0abcad3c71a6f6 Mon Sep 17 00:00:00 2001 From: Valentin Lorentz Date: Mon, 4 Sep 2023 17:36:18 +0200 Subject: [PATCH 13/73] supybot-test: Ensure --clean doesn't leave 'backup' and 'test-logs' directories --- src/scripts/limnoria_test.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/scripts/limnoria_test.py b/src/scripts/limnoria_test.py index 7db6b9107..09ad8b9f8 100644 --- a/src/scripts/limnoria_test.py +++ b/src/scripts/limnoria_test.py @@ -49,8 +49,9 @@ if not os.path.exists('test-conf'): registryFilename = os.path.join('test-conf', 'test.conf') fd = open(registryFilename, 'w') fd.write(""" -supybot.directories.data: %(base_dir)s/test-data +supybot.directories.backup: /dev/null supybot.directories.conf: %(base_dir)s/test-conf +supybot.directories.data: %(base_dir)s/test-data supybot.directories.log: %(base_dir)s/test-logs supybot.reply.whenNotCommand: True supybot.log.stdout: False @@ -229,8 +230,8 @@ def main(): runner = unittest.TextTestRunner(verbosity=2) print('Testing began at %s (pid %s)' % (time.ctime(), os.getpid())) if options.clean: + log.setLevel(100) # don't log anything anymore shutil.rmtree(conf.supybot.directories.log()) - log._mkDirs() shutil.rmtree(conf.supybot.directories.conf()) shutil.rmtree(conf.supybot.directories.data()) result = runner.run(suite) From 6b778598bb49b8d269d6c3447cc8c0521f40350c Mon Sep 17 00:00:00 2001 From: Valentin Lorentz Date: Mon, 4 Sep 2023 18:24:16 +0200 Subject: [PATCH 14/73] --clean removes files before running tests, not after --- src/scripts/limnoria_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/scripts/limnoria_test.py b/src/scripts/limnoria_test.py index 09ad8b9f8..ac49b4718 100644 --- a/src/scripts/limnoria_test.py +++ b/src/scripts/limnoria_test.py @@ -230,8 +230,8 @@ def main(): runner = unittest.TextTestRunner(verbosity=2) print('Testing began at %s (pid %s)' % (time.ctime(), os.getpid())) if options.clean: - log.setLevel(100) # don't log anything anymore shutil.rmtree(conf.supybot.directories.log()) + log._mkDirs() shutil.rmtree(conf.supybot.directories.conf()) shutil.rmtree(conf.supybot.directories.data()) result = runner.run(suite) From f8dd8d764264ec3c0ef69555e438a2d1c0024ea0 Mon Sep 17 00:00:00 2001 From: Valentin Lorentz Date: Mon, 4 Sep 2023 20:05:55 +0200 Subject: [PATCH 15/73] supybot-test: Add --clean-after option --- src/scripts/limnoria_test.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/scripts/limnoria_test.py b/src/scripts/limnoria_test.py index ac49b4718..7c35d6ed5 100644 --- a/src/scripts/limnoria_test.py +++ b/src/scripts/limnoria_test.py @@ -132,6 +132,9 @@ def main(): parser.add_option('-c', '--clean', action='store_true', default=False, dest='clean', help='Cleans the various data/conf/logs' 'directories before running tests.') + parser.add_option('--clean-after', action='store_true', default=False, + dest='clean_after', help='Cleans the various data/conf/logs' + 'directories after running tests.') parser.add_option('-t', '--timeout', action='store', type='float', dest='timeout', help='Sets the timeout, in seconds, for tests to return ' @@ -239,6 +242,12 @@ def main(): if hasattr(unittest, 'asserts'): print('Total asserts: %s' % unittest.asserts) + if options.clean_after: + log.setLevel(100) # don't log anything anymore + shutil.rmtree(conf.supybot.directories.log()) + shutil.rmtree(conf.supybot.directories.conf()) + shutil.rmtree(conf.supybot.directories.data()) + if result.wasSuccessful(): sys.exit(0) else: From 81a5133c14472010d160f5f1f781badf4df33af1 Mon Sep 17 00:00:00 2001 From: Valentin Lorentz Date: Tue, 5 Sep 2023 08:28:44 +0200 Subject: [PATCH 16/73] SaslTestCase: Use tearDown() method instead of finally: blocks It's much cleaner --- test/test_irclib.py | 75 ++++++++++++++++++++------------------------- 1 file changed, 33 insertions(+), 42 deletions(-) diff --git a/test/test_irclib.py b/test/test_irclib.py index 230b1468b..b7d423884 100644 --- a/test/test_irclib.py +++ b/test/test_irclib.py @@ -1496,14 +1496,17 @@ class SaslTestCase(SupyTestCase, CapNegMixin): def setUp(self): pass + def tearDown(self): + conf.supybot.networks.test.sasl.username.setValue('') + conf.supybot.networks.test.sasl.password.setValue('') + conf.supybot.networks.test.certfile.setValue('') + def testPlain(self): - try: - conf.supybot.networks.test.sasl.username.setValue('jilles') - conf.supybot.networks.test.sasl.password.setValue('sesame') - self.irc = irclib.Irc('test') - finally: - conf.supybot.networks.test.sasl.username.setValue('') - conf.supybot.networks.test.sasl.password.setValue('') + conf.supybot.networks.test.sasl.username.setValue('jilles') + conf.supybot.networks.test.sasl.password.setValue('sesame') + + self.irc = irclib.Irc('test') + self.assertEqual(self.irc.sasl_current_mechanism, None) self.irc.sasl_next_mechanisms = ['scram-sha-256', 'plain'] @@ -1531,15 +1534,12 @@ class SaslTestCase(SupyTestCase, CapNegMixin): self.endCapNegociation() def testExternalFallbackToPlain(self): - try: - conf.supybot.networks.test.sasl.username.setValue('jilles') - conf.supybot.networks.test.sasl.password.setValue('sesame') - conf.supybot.networks.test.certfile.setValue('foo') - self.irc = irclib.Irc('test') - finally: - conf.supybot.networks.test.sasl.username.setValue('') - conf.supybot.networks.test.sasl.password.setValue('') - conf.supybot.networks.test.certfile.setValue('') + conf.supybot.networks.test.sasl.username.setValue('jilles') + conf.supybot.networks.test.sasl.password.setValue('sesame') + conf.supybot.networks.test.certfile.setValue('foo') + + self.irc = irclib.Irc('test') + self.assertEqual(self.irc.sasl_current_mechanism, None) self.irc.sasl_next_mechanisms = ['external', 'plain'] @@ -1567,15 +1567,12 @@ class SaslTestCase(SupyTestCase, CapNegMixin): self.endCapNegociation() def testFilter(self): - try: - conf.supybot.networks.test.sasl.username.setValue('jilles') - conf.supybot.networks.test.sasl.password.setValue('sesame') - conf.supybot.networks.test.certfile.setValue('foo') - self.irc = irclib.Irc('test') - finally: - conf.supybot.networks.test.sasl.username.setValue('') - conf.supybot.networks.test.sasl.password.setValue('') - conf.supybot.networks.test.certfile.setValue('') + conf.supybot.networks.test.sasl.username.setValue('jilles') + conf.supybot.networks.test.sasl.password.setValue('sesame') + conf.supybot.networks.test.certfile.setValue('foo') + + self.irc = irclib.Irc('test') + self.assertEqual(self.irc.sasl_current_mechanism, None) self.irc.sasl_next_mechanisms = ['external', 'plain'] @@ -1597,13 +1594,11 @@ class SaslTestCase(SupyTestCase, CapNegMixin): self.endCapNegociation() def testReauthenticate(self): - try: - conf.supybot.networks.test.sasl.username.setValue('jilles') - conf.supybot.networks.test.sasl.password.setValue('sesame') - self.irc = irclib.Irc('test') - finally: - conf.supybot.networks.test.sasl.username.setValue('') - conf.supybot.networks.test.sasl.password.setValue('') + conf.supybot.networks.test.sasl.username.setValue('jilles') + conf.supybot.networks.test.sasl.password.setValue('sesame') + + self.irc = irclib.Irc('test') + self.assertEqual(self.irc.sasl_current_mechanism, None) self.irc.sasl_next_mechanisms = ['plain'] @@ -1622,16 +1617,12 @@ class SaslTestCase(SupyTestCase, CapNegMixin): self.irc.takeMsg() # None. But even if it was CAP REQ sasl, it would be ok self.assertEqual(self.irc.takeMsg(), None) - try: - conf.supybot.networks.test.sasl.username.setValue('jilles') - conf.supybot.networks.test.sasl.password.setValue('sesame') - self.irc.feedMsg(ircmsgs.IrcMsg(command='CAP', - args=('*', 'DEL', 'sasl'))) - self.irc.feedMsg(ircmsgs.IrcMsg(command='CAP', - args=('*', 'NEW', 'sasl=PLAIN'))) - finally: - conf.supybot.networks.test.sasl.username.setValue('') - conf.supybot.networks.test.sasl.password.setValue('') + conf.supybot.networks.test.sasl.username.setValue('jilles') + conf.supybot.networks.test.sasl.password.setValue('sesame') + self.irc.feedMsg(ircmsgs.IrcMsg(command='CAP', + args=('*', 'DEL', 'sasl'))) + self.irc.feedMsg(ircmsgs.IrcMsg(command='CAP', + args=('*', 'NEW', 'sasl=PLAIN'))) m = self.irc.takeMsg() self.assertEqual(m.command, 'CAP', 'Expected CAP, got %r.' % m) From c66b973db02cc000617cec7e3725111bd5294036 Mon Sep 17 00:00:00 2001 From: Valentin Lorentz Date: Tue, 5 Sep 2023 08:29:56 +0200 Subject: [PATCH 17/73] SaslTestCase: Change config instead of messing with irc.sasl_next_mechanisms Changing the internal state will break in the next commit, which reorganizes SASL state initialization --- test/test_irclib.py | 43 ++++++++++++++++++++++++++++++++----------- 1 file changed, 32 insertions(+), 11 deletions(-) diff --git a/test/test_irclib.py b/test/test_irclib.py index b7d423884..91cc5faf6 100644 --- a/test/test_irclib.py +++ b/test/test_irclib.py @@ -1494,9 +1494,10 @@ class BatchTestCase(SupyTestCase): class SaslTestCase(SupyTestCase, CapNegMixin): def setUp(self): - pass + self._default_mechanisms = conf.supybot.networks.test.sasl.mechanisms() def tearDown(self): + conf.supybot.networks.test.sasl.mechanisms.setValue(self._default_mechanisms) conf.supybot.networks.test.sasl.username.setValue('') conf.supybot.networks.test.sasl.password.setValue('') conf.supybot.networks.test.certfile.setValue('') @@ -1504,19 +1505,30 @@ class SaslTestCase(SupyTestCase, CapNegMixin): def testPlain(self): conf.supybot.networks.test.sasl.username.setValue('jilles') conf.supybot.networks.test.sasl.password.setValue('sesame') + conf.supybot.networks.test.sasl.mechanisms.setValue( + ['scram-sha-256', 'plain']) self.irc = irclib.Irc('test') self.assertEqual(self.irc.sasl_current_mechanism, None) - self.irc.sasl_next_mechanisms = ['scram-sha-256', 'plain'] - self.startCapNegociation() + if irclib.scram: + self.assertEqual(self.irc.sasl_next_mechanisms, + ['scram-sha-256', 'plain']) + + self.startCapNegociation() + + m = self.irc.takeMsg() + self.assertEqual(m, ircmsgs.IrcMsg(command='AUTHENTICATE', + args=('SCRAM-SHA-256',))) + self.irc.feedMsg(ircmsgs.IrcMsg(command='904', + args=('mechanism not available',))) + else: + self.assertEqual(self.irc.sasl_next_mechanisms, + ['plain']) + + self.startCapNegociation() - m = self.irc.takeMsg() - self.assertEqual(m, ircmsgs.IrcMsg(command='AUTHENTICATE', - args=('SCRAM-SHA-256',))) - self.irc.feedMsg(ircmsgs.IrcMsg(command='904', - args=('mechanism not available',))) m = self.irc.takeMsg() self.assertEqual(m, ircmsgs.IrcMsg(command='AUTHENTICATE', @@ -1537,11 +1549,14 @@ class SaslTestCase(SupyTestCase, CapNegMixin): conf.supybot.networks.test.sasl.username.setValue('jilles') conf.supybot.networks.test.sasl.password.setValue('sesame') conf.supybot.networks.test.certfile.setValue('foo') + conf.supybot.networks.test.sasl.mechanisms.setValue( + ['external', 'plain']) self.irc = irclib.Irc('test') self.assertEqual(self.irc.sasl_current_mechanism, None) - self.irc.sasl_next_mechanisms = ['external', 'plain'] + self.assertEqual(self.irc.sasl_next_mechanisms, + ['external', 'plain']) self.startCapNegociation() @@ -1570,11 +1585,14 @@ class SaslTestCase(SupyTestCase, CapNegMixin): conf.supybot.networks.test.sasl.username.setValue('jilles') conf.supybot.networks.test.sasl.password.setValue('sesame') conf.supybot.networks.test.certfile.setValue('foo') + conf.supybot.networks.test.sasl.mechanisms.setValue( + ['external', 'plain']) self.irc = irclib.Irc('test') self.assertEqual(self.irc.sasl_current_mechanism, None) - self.irc.sasl_next_mechanisms = ['external', 'plain'] + self.assertEqual(self.irc.sasl_next_mechanisms, + ['external', 'plain']) self.startCapNegociation(caps='sasl=foo,plain,bar') @@ -1596,11 +1614,14 @@ class SaslTestCase(SupyTestCase, CapNegMixin): def testReauthenticate(self): conf.supybot.networks.test.sasl.username.setValue('jilles') conf.supybot.networks.test.sasl.password.setValue('sesame') + conf.supybot.networks.test.sasl.mechanisms.setValue( + ['external', 'plain']) self.irc = irclib.Irc('test') self.assertEqual(self.irc.sasl_current_mechanism, None) - self.irc.sasl_next_mechanisms = ['plain'] + self.assertEqual(self.irc.sasl_next_mechanisms, + ['plain']) self.startCapNegociation(caps='') From 9e82e3f16cd7029077907f3ea8e491dcc3618198 Mon Sep 17 00:00:00 2001 From: Eric Mertens Date: Sat, 2 Sep 2023 21:06:54 -0700 Subject: [PATCH 18/73] Add command to manually initiate SASL --- plugins/Network/plugin.py | 11 +++++++++++ src/irclib.py | 39 +++++++++++++++++++-------------------- 2 files changed, 30 insertions(+), 20 deletions(-) diff --git a/plugins/Network/plugin.py b/plugins/Network/plugin.py index 53d3eb908..ee7e72fe4 100644 --- a/plugins/Network/plugin.py +++ b/plugins/Network/plugin.py @@ -306,6 +306,17 @@ class Network(callbacks.Plugin): irc.reply(format("%L", sorted(otherIrc.state.capabilities_ls))) capabilities = wrap(capabilities, ['networkIrc']) + def authenticate(self, irc, msg, args): + """takes no arguments + + Manually initiate SASL authentication. + """ + if 'sasl' in irc.state.capabilities_ack: + irc.startSasl(msg) + irc.replySuccess() + else: + irc.error(_('SASL not supported')) + authenticate = wrap(authenticate) Class = Network diff --git a/src/irclib.py b/src/irclib.py index 2ba3e2cce..233f86f97 100644 --- a/src/irclib.py +++ b/src/irclib.py @@ -559,14 +559,14 @@ class IrcStateFsm(object): self.States.UNINITIALIZED, ]) - def on_sasl_cap(self, irc, msg): - '''Whenever we see the 'sasl' capability in a CAP LS response''' + def on_sasl_start(self, irc, msg): + '''Whenever we initiate a SASL transaction.''' if self.state == self.States.INIT_CAP_NEGOTIATION: self._transition(irc, msg, self.States.INIT_SASL) elif self.state == self.States.CONNECTED: self._transition(irc, msg, self.States.CONNECTED_SASL) else: - raise ValueError('Got sasl cap while in state %s' % self.state) + raise ValueError('Started SASL while in state %s' % self.state) def on_sasl_auth_finished(self, irc, msg): '''When sasl auth either succeeded or failed.''' @@ -1729,7 +1729,6 @@ class Irc(IrcCommandDispatcher, log.Firewalled): self.authenticate_decoder = None self.sasl_next_mechanisms = [] self.sasl_current_mechanism = None - for mechanism in network_config.sasl.mechanisms(): if mechanism == 'ecdsa-nist256p-challenge': if not crypto: @@ -1767,17 +1766,13 @@ class Irc(IrcCommandDispatcher, log.Firewalled): else: self.sasl_next_mechanisms.append(mechanism) - if self.sasl_next_mechanisms: - 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', 'echo-message', - 'standard-replies']) + 'sasl', 'standard-replies']) """IRCv3 capabilities requested when they are available. echo-message is special-cased to be requested only with labeled-response. @@ -1901,17 +1896,21 @@ class Irc(IrcCommandDispatcher, log.Firewalled): def _maybeStartSasl(self, msg): if not self.sasl_authenticated and \ 'sasl' in self.state.capabilities_ack: - self.state.fsm.on_sasl_cap(self, msg) - assert 'sasl' in self.state.capabilities_ls, ( - 'Got "CAP ACK sasl" without receiving "CAP LS sasl" or ' - '"CAP NEW sasl" first.') - s = self.state.capabilities_ls['sasl'] - if s is not None: - available = set(map(str.lower, s.split(','))) - self.sasl_next_mechanisms = [ - x for x in self.sasl_next_mechanisms - if x.lower() in available] - self.tryNextSaslMechanism(msg) + self.startSasl(msg) + + def startSasl(self, msg): + self.state.fsm.on_sasl_start(self, msg) + assert 'sasl' in self.state.capabilities_ls, ( + 'Starting SASL without receiving "CAP LS sasl" or ' + '"CAP NEW sasl" first.') + self.resetSasl() + s = self.state.capabilities_ls['sasl'] + if s is not None: + available = set(map(str.lower, s.split(','))) + self.sasl_next_mechanisms = [ + x for x in self.sasl_next_mechanisms + if x.lower() in available] + self.tryNextSaslMechanism(msg) def doAuthenticate(self, msg): self.state.fsm.expect_state([ From f905036d7a506cb29f95677ba6142656846ea57e Mon Sep 17 00:00:00 2001 From: Valentin Lorentz Date: Fri, 8 Sep 2023 22:47:54 +0200 Subject: [PATCH 19/73] Services: Add missing import It's needed since acad80296a8a4f55557f23e0ef29fbc0533a6457 --- plugins/Services/plugin.py | 1 + 1 file changed, 1 insertion(+) diff --git a/plugins/Services/plugin.py b/plugins/Services/plugin.py index 2ef33ebe0..c34608aa4 100644 --- a/plugins/Services/plugin.py +++ b/plugins/Services/plugin.py @@ -37,6 +37,7 @@ from . import config import supybot.conf as conf import supybot.utils as utils from supybot.commands import * +import supybot.irclib as irclib import supybot.ircmsgs as ircmsgs import supybot.ircutils as ircutils import supybot.callbacks as callbacks From 5ab7c8a7499fe1997f7d25a40d6b615f95edd3b9 Mon Sep 17 00:00:00 2001 From: Valentin Lorentz Date: Fri, 8 Sep 2023 23:54:47 +0200 Subject: [PATCH 20/73] Services: I still didn't test that code --- plugins/Services/plugin.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/Services/plugin.py b/plugins/Services/plugin.py index c34608aa4..b3efd785d 100644 --- a/plugins/Services/plugin.py +++ b/plugins/Services/plugin.py @@ -380,8 +380,8 @@ class Services(callbacks.Plugin): state.identified = True for channel in irc.state.channels.keys(): self.checkPrivileges(irc, channel) - if irc.state.fsm in [irclib.IrcStateFsm.CONNECTED, - irclib.IrcStateFsm.CONNECTED_SASL]: + if irc.state.fsm in [irclib.IrcStateFsm.States.CONNECTED, + irclib.IrcStateFsm.States.CONNECTED_SASL]: for channel in state.channels: irc.queueMsg(networkGroup.channels.join(channel)) waitingJoins = state.waitingJoins From 91accc0458d970d324d8b3d4830b6cc2dbe2cbbd Mon Sep 17 00:00:00 2001 From: famfo <44938471+famfo@users.noreply.github.com> Date: Tue, 19 Sep 2023 15:56:36 +0000 Subject: [PATCH 21/73] SedRegex: Implement changing of sed response per channel (#1556) Fixes #1433 Co-authored-by: Val Lorentz Co-authored-by: James Lu --- plugins/SedRegex/config.py | 11 +++++++++++ plugins/SedRegex/plugin.py | 15 +++++++++------ plugins/SedRegex/test.py | 17 +++++++++++++++++ 3 files changed, 37 insertions(+), 6 deletions(-) diff --git a/plugins/SedRegex/config.py b/plugins/SedRegex/config.py index 42b53c978..0d06e5150 100644 --- a/plugins/SedRegex/config.py +++ b/plugins/SedRegex/config.py @@ -57,6 +57,17 @@ conf.registerChannelValue(SedRegex, 'enable', conf.registerChannelValue(SedRegex, 'ignoreRegex', registry.Boolean(True, _("""Should Perl/sed regex replacing ignore messages which look like valid regex?"""))) +conf.registerChannelValue(SedRegex, 'format', + registry.String(_('$nick meant to say: $replacement'), _("""Sets the format + string for a message edited by the original + author. Required fields: $nick (nick of the + author), $replacement (edited message)"""))) +conf.registerChannelValue(SedRegex.format, 'other', + registry.String(_('$otherNick thinks $nick meant to say: $replacement'), _(""" + Sets the format string for a message edited by + another author. Required fields: $nick (nick + of the original author), $otherNick (nick of + the editor), $replacement (edited message)"""))) conf.registerGlobalValue(SedRegex, 'processTimeout', registry.PositiveFloat(0.5, _("""Sets the timeout when processing a single regexp. The default should be adequate unless diff --git a/plugins/SedRegex/plugin.py b/plugins/SedRegex/plugin.py index 79062f908..a34e6a413 100644 --- a/plugins/SedRegex/plugin.py +++ b/plugins/SedRegex/plugin.py @@ -222,10 +222,6 @@ class SedRegex(callbacks.PluginRegexp): if self.registryValue('ignoreRegex', msg.channel, irc.network) and m.tagged(TAG_IS_REGEX): self.log.debug("Skipping message %s because it is tagged as isRegex", m.args[1]) continue - if m.nick == msg.nick: - messageprefix = msg.nick - else: - messageprefix = '%s thinks %s' % (msg.nick, m.nick) try: replace_result = pattern.search(text) @@ -239,8 +235,15 @@ class SedRegex(callbacks.PluginRegexp): subst = axe_spaces(subst) - return _("%s meant to say: %s") % \ - (messageprefix, subst) + if m.nick == msg.nick: + fmt = self.registryValue('format', msg.channel, irc.network) + env = {'replacement': subst} + else: + fmt = self.registryValue('format.other', msg.channel, irc.network) + env = {'otherNick': msg.nick, 'replacement': subst} + + return ircutils.standardSubstitute(irc, m, fmt, env) + except Exception as e: self.log.warning(_("SedRegex error: %s"), e, exc_info=True) raise diff --git a/plugins/SedRegex/test.py b/plugins/SedRegex/test.py index aa5134e8d..78c6eb202 100644 --- a/plugins/SedRegex/test.py +++ b/plugins/SedRegex/test.py @@ -279,6 +279,23 @@ class SedRegexTestCase(ChannelPluginTestCase): with conf.supybot.protocols.irc.strictRfc.context(True): self.assertSnarfNoResponse('%s: s/123/321/' % ircutils.nickFromHostmask(frm), frm=self.__class__.other2) + def testFmtString(self): + fmt = "<$nick>: $replacement" + with conf.supybot.plugins.sedregex.format.context(fmt): + self.feedMsg('frog') + self.feedMsg('s/frog/frogged/') + m = self.getMsg(' ') + self.assertIn('<%s>: frogged' % self.nick, str(m)) + + def testFmtStringOtherPerson(self): + fmt = "(edited by $otherNick) <$nick>: $replacement" + with conf.supybot.plugins.sedregex.format.other.context(fmt): + self.feedMsg('frog', frm=self.__class__.other) + self.feedMsg('s/frog/frogged/', frm=self.__class__.other2) + m = self.getMsg(' ') + self.assertIn('(edited by %s) <%s>: frogged' % (ircutils.nickFromHostmask(self.__class__.other2), + ircutils.nickFromHostmask(self.__class__.other)), str(m)) + # TODO: test ignores # vim:set shiftwidth=4 tabstop=4 expandtab textwidth=79: From 850b4c3f6981deca9f552e8b5b51ef6797b09ad5 Mon Sep 17 00:00:00 2001 From: Valentin Lorentz Date: Fri, 22 Sep 2023 13:30:08 +0200 Subject: [PATCH 22/73] MessageParser: Log and skip current regexp on error --- plugins/MessageParser/plugin.py | 31 +++++++++++++++++-------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/plugins/MessageParser/plugin.py b/plugins/MessageParser/plugin.py index baca48ad4..062e07916 100644 --- a/plugins/MessageParser/plugin.py +++ b/plugins/MessageParser/plugin.py @@ -192,20 +192,23 @@ class MessageParser(callbacks.Plugin, plugins.ChannelDBHandler): return max_triggers = self.registryValue('maxTriggers', channel, irc.network) for (channel, regexp, action) in results: - for match in re.finditer(regexp, msg.args[1]): - if match is not None: - thisaction = action - self._updateRank(irc.network, channel, regexp) - for (i, j) in enumerate(match.groups()): - if match.group(i+1) is not None: - # Need a lambda to prevent re.sub from - # interpreting backslashes in the replacement - thisaction = re.sub(r'\$' + str(i+1), lambda _: match.group(i+1), thisaction) - actions.append((regexp, thisaction)) - if max_triggers != 0 and max_triggers == len(actions): - break - if max_triggers != 0 and max_triggers == len(actions): - break + try: + for match in re.finditer(regexp, msg.args[1]): + if match is not None: + thisaction = action + self._updateRank(irc.network, channel, regexp) + for (i, j) in enumerate(match.groups()): + if match.group(i+1) is not None: + # Need a lambda to prevent re.sub from + # interpreting backslashes in the replacement + thisaction = re.sub(r'\$' + str(i+1), lambda _: match.group(i+1), thisaction) + actions.append((regexp, thisaction)) + if max_triggers != 0 and max_triggers == len(actions): + break + if max_triggers != 0 and max_triggers == len(actions): + break + except Exception: + self.log.exception('Error while handling %r', regexp) for (regexp, action) in actions: From fa01b019ed1b629be79790272907cb27ae32bf11 Mon Sep 17 00:00:00 2001 From: Matias Wilkman Date: Sun, 24 Sep 2023 04:08:10 +0300 Subject: [PATCH 23/73] added a new repo for plugindownloader --- plugins/PluginDownloader/plugin.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/plugins/PluginDownloader/plugin.py b/plugins/PluginDownloader/plugin.py index 35755b0fd..ed846dc51 100644 --- a/plugins/PluginDownloader/plugin.py +++ b/plugins/PluginDownloader/plugin.py @@ -227,6 +227,9 @@ repositories = utils.InsensitivePreservingDict({ 'oddluck', 'limnoria-plugins', ), + 'appas': GithubRepository( + 'matiasw', + 'my-limnoria-plugins', }) class PluginDownloader(callbacks.Plugin): From b1657a87352632c874a09bed1c72c89ce15480d1 Mon Sep 17 00:00:00 2001 From: Valentin Lorentz Date: Sun, 24 Sep 2023 19:55:44 +0200 Subject: [PATCH 24/73] Skip irctest on Python 3.7 It's no longer supported --- .github/workflows/test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 695ad1f18..9bbc8dd0e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -84,9 +84,9 @@ jobs: supybot-test test -v --plugins-dir=./plugins/ --no-network - name: Test with irctest - if: "${{ matrix.with-opt-deps && matrix.python-version != 'pypy-3.7' && matrix.python-version != 'pypy-3.9' }}" + if: "${{ matrix.with-opt-deps && matrix.python-version != '3.7' && matrix.python-version != 'pypy-3.7' && matrix.python-version != 'pypy-3.9' }}" run: | - git clone https://github.com/ProgVal/irctest.git + git clone https://github.com/progval/irctest.git cd irctest pip3 install -r requirements.txt make limnoria PYTEST_ARGS=-vs From 119a93a7449da1a10cf2df0d0c481f8fa4a13ec4 Mon Sep 17 00:00:00 2001 From: Valentin Lorentz Date: Sun, 24 Sep 2023 19:58:10 +0200 Subject: [PATCH 25/73] PluginDownloader: Fix typo --- plugins/PluginDownloader/plugin.py | 1 + 1 file changed, 1 insertion(+) diff --git a/plugins/PluginDownloader/plugin.py b/plugins/PluginDownloader/plugin.py index ed846dc51..cd2434021 100644 --- a/plugins/PluginDownloader/plugin.py +++ b/plugins/PluginDownloader/plugin.py @@ -230,6 +230,7 @@ repositories = utils.InsensitivePreservingDict({ 'appas': GithubRepository( 'matiasw', 'my-limnoria-plugins', + ), }) class PluginDownloader(callbacks.Plugin): From cf4c4ca5b873ae9adbc6979f3e3d4e988b40056d Mon Sep 17 00:00:00 2001 From: Aminda Suomalainen Date: Fri, 29 Sep 2023 11:23:19 +0300 Subject: [PATCH 26/73] requirements.txt: add ddate as an optional dependency for Time.ddate --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index 7fbd151c4..4690c6d54 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,3 +15,4 @@ cryptography # required to load the Fediverse plugin (used to imp feedparser # required to load the RSS plugin pytz;python_version<'3.9' # enables timezone manipulation in the Time and Geography plugins. On Python >=3.9, the standard library is used instead python-dateutil # enable fancy time string parsing in the Time plugin +ddate # required for the ddate command in the Time plugin From 7581525495e941d490e60b8840ddab18f9ae2673 Mon Sep 17 00:00:00 2001 From: Aminda Suomalainen Date: Fri, 29 Sep 2023 11:48:13 +0300 Subject: [PATCH 27/73] .gitattributes: enable EOL normalization --- .gitattributes | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitattributes b/.gitattributes index 430d0ec8b..a91905dca 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,3 @@ +* text=auto sandbox export-ignore .git* export-ignore From ecd0c926ea2bf6d7961d77e02decdfe02f2eb0fc Mon Sep 17 00:00:00 2001 From: Aminda Suomalainen Date: Fri, 29 Sep 2023 11:49:17 +0300 Subject: [PATCH 28/73] .editorconfig: configure text editors for Limnoria style guide --- .editorconfig | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .editorconfig diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 000000000..955025fbb --- /dev/null +++ b/.editorconfig @@ -0,0 +1,7 @@ +root = true + +[*] +insert_final_newline = true +indent_style = space +indent_size = 4 +max_line_length = 79 From a46a0733afc81c1c23b412b8793a0df4cd46a088 Mon Sep 17 00:00:00 2001 From: Aminda Suomalainen Date: Fri, 29 Sep 2023 12:32:15 +0300 Subject: [PATCH 29/73] .editorconfig: only apply indent_size and line_length for *.py --- .editorconfig | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.editorconfig b/.editorconfig index 955025fbb..655090d66 100644 --- a/.editorconfig +++ b/.editorconfig @@ -3,5 +3,7 @@ root = true [*] insert_final_newline = true indent_style = space + +[*.py] indent_size = 4 max_line_length = 79 From 58287207d7df846ed97c8e6ba9a0a0cb5dfb27e0 Mon Sep 17 00:00:00 2001 From: Valentin Lorentz Date: Fri, 29 Sep 2023 15:25:30 +0200 Subject: [PATCH 30/73] Socket: Fix hanging while TLS socket buffer is non-empty --- src/drivers/Socket.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/drivers/Socket.py b/src/drivers/Socket.py index 3e73ac655..23d242153 100644 --- a/src/drivers/Socket.py +++ b/src/drivers/Socket.py @@ -200,6 +200,13 @@ class SocketDriver(drivers.IrcDriver, drivers.ServersMixin): """Called by _select() when we can read data.""" try: new_data = self.conn.recv(1024) + if hasattr(self.conn, "pending") and self.conn.pending(): + # This is a TLS socket and there are decrypted bytes in the + # buffer. We need to read them now, or we would not get them + # until the next time select() returns this socket (which may + # be in a very long time, as select() does not know recv() on + # the TLS wrapper would not block). + new_data += self.conn.recv(self.conn.pending()) if not new_data: # Socket was closed self._handleSocketError(None) From 5ccc0350216a739f1c643a3dfae52934786c3949 Mon Sep 17 00:00:00 2001 From: Matias Wilkman Date: Thu, 5 Oct 2023 22:13:55 +0300 Subject: [PATCH 31/73] report channel counts and modes in status (#1562) --- plugins/Status/plugin.py | 50 ++++++++++++++++++++++++++++++++++------ 1 file changed, 43 insertions(+), 7 deletions(-) diff --git a/plugins/Status/plugin.py b/plugins/Status/plugin.py index f86fde641..8a67be887 100644 --- a/plugins/Status/plugin.py +++ b/plugins/Status/plugin.py @@ -76,17 +76,53 @@ class Status(callbacks.Plugin): Returns the status of the bot. """ + # Initialize dictionaries + nicks = {} networks = {} + # Iterate through each IRC network for Irc in world.ircs: - networks.setdefault(Irc.network, []).append(Irc.nick) - networks = sorted(networks.items()) - networks = [format(_('%s as %L'), net, nicks) for (net,nicks) in networks] - L = [format(_('I am connected to %L.'), networks)] + network_name = Irc.network + channels = Irc.state.channels + + # Initialize counts for this network + channel_counts = len(channels) + op_counts = sum(1 for channel in channels.values() if Irc.nick in channel.ops) + halfop_counts = sum(1 for channel in channels.values() if Irc.nick in channel.halfops) + voice_counts = sum(1 for channel in channels.values() if Irc.nick in channel.voices) + normal_counts = sum(1 for channel in channels.values() if Irc.nick in channel.users) + + # Store the counts in dictionaries + nicks[network_name] = Irc.nick + networks[network_name] = { + 'Channels': channel_counts, + 'Ops': op_counts, + 'Half-Ops': halfop_counts, + 'Voiced': voice_counts, + 'Regular': normal_counts + } + + # Prepare the response + response_lines = [] + for network_name, counts in networks.items(): + response_lines.append( + format( + _('I am connected to %s as %s: Channels: %s, Ops: %s, Half-Ops: %s, Voiced: %s, Regular: %s'), + network_name, + nicks[network_name], + counts['Channels'], + counts['Ops'], + counts['Half-Ops'], + counts['Voiced'], + counts['Regular'] + ) + ) + if world.profiling: - L.append(_('I am currently in code profiling mode.')) - irc.reply(' '.join(L)) + response_lines.append(_('I am currently in code profiling mode.')) + response = format(_("%L"), response_lines) + irc.reply(response) status = wrap(status) - + @internationalizeDocstring def threads(self, irc, msg, args): """takes no arguments From ec9e731fa52f345467d43e2d9d99c2366fdc639c Mon Sep 17 00:00:00 2001 From: Matias Wilkman Date: Sun, 8 Oct 2023 20:07:08 +0300 Subject: [PATCH 32/73] Ignore trailing whitespace when addressing the bot by nick at end (#1563) --- src/callbacks.py | 4 ++-- test/test_callbacks.py | 6 ++++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/callbacks.py b/src/callbacks.py index 28b1fbabf..e9428c872 100644 --- a/src/callbacks.py +++ b/src/callbacks.py @@ -136,8 +136,8 @@ def _addressed(irc, msg, prefixChars=None, nicks=None, continue except ValueError: # split didn't work. continue - elif whenAddressedByNickAtEnd and lowered.endswith(nick): - rest = payload[:-len(nick)] + elif whenAddressedByNickAtEnd and lowered.rstrip().endswith(nick): + rest = payload.rstrip()[:-len(nick)] possiblePayload = rest.rstrip(' \t,;') if possiblePayload != rest: # There should be some separator between the nick and the diff --git a/test/test_callbacks.py b/test/test_callbacks.py index 33c0d561b..6ce9c1569 100644 --- a/test/test_callbacks.py +++ b/test/test_callbacks.py @@ -272,6 +272,12 @@ class FunctionsTestCase(SupyTestCase): self.assertEqual(callbacks.addressed('bar', msg, whenAddressedByNickAtEnd=True), 'baz') + # Test that it still works with trailing whitespace: + msg = ircmsgs.privmsg('#foo', 'baz, bar \t') + self.assertEqual(callbacks.addressed('bar', msg, + whenAddressedByNickAtEnd=True), + 'baz') + def testAddressedPrefixCharsTakePrecedenceOverNickAtEnd(self): irc = getTestIrc() From 7cd700b4ae7cf4d7b4f5a19581ee19d3a91abdea Mon Sep 17 00:00:00 2001 From: Matias Wilkman Date: Mon, 9 Oct 2023 20:31:50 +0300 Subject: [PATCH 33/73] Seen: show when the target is currently in the channel (#1559) --- plugins/Seen/plugin.py | 49 +++++++++++++++++++++++++++++++----------- plugins/Seen/test.py | 28 ++++++++++++++++++++---- 2 files changed, 61 insertions(+), 16 deletions(-) diff --git a/plugins/Seen/plugin.py b/plugins/Seen/plugin.py index cf789c63b..9435fe21a 100644 --- a/plugins/Seen/plugin.py +++ b/plugins/Seen/plugin.py @@ -195,9 +195,14 @@ class Seen(callbacks.Plugin): if len(results) == 1: (nick, info) = results[0] (when, said) = info - reply = format(_('%s was last seen in %s %s ago'), - nick, channel, - utils.timeElapsed(time.time()-when)) + if nick in irc.state.channels[channel].users: + reply = format(_('%s was last seen in %s %s ago, and is in the channel now'), + nick, channel, + utils.timeElapsed(time.time()-when)) + else: + reply = format(_('%s was last seen in %s %s ago'), + nick, channel, + utils.timeElapsed(time.time()-when)) if self.registryValue('showLastMessage', channel, irc.network): if minisix.PY2: said = said.decode('utf8') @@ -207,13 +212,20 @@ class Seen(callbacks.Plugin): L = [] for (nick, info) in results: (when, said) = info - L.append(format(_('%s (%s ago)'), nick, - utils.timeElapsed(time.time()-when))) + if nick in irc.state.channels[channel].users: + L.append(format(_('%s (%s ago, and is in the channel now)'), nick, + utils.timeElapsed(time.time()-when))) + else: + L.append(format(_('%s (%s ago)'), nick, + utils.timeElapsed(time.time()-when))) irc.reply(format(_('%s could be %L'), name, (L, _('or')))) else: irc.reply(format(_('I haven\'t seen anyone matching %s.'), name)) except KeyError: - irc.reply(format(_('I have not seen %s.'), name)) + if name in irc.state.channels[channel].users: + irc.reply(format(_("%s is in the channel right now."), name)) + else: + irc.reply(format(_('I have not seen %s.'), name)) def _checkChannelPresence(self, irc, channel, target, you): if channel not in irc.state.channels: @@ -277,8 +289,13 @@ class Seen(callbacks.Plugin): db = self.db try: (when, said) = db.seen(channel, '') - reply = format(_('Someone was last seen in %s %s ago'), - channel, utils.timeElapsed(time.time()-when)) + pattern = r'<(.*?)>' + match = re.search(pattern, said) + if not match: + irc.error(format(_('I couldn\'t parse the nick of the speaker of the last line.')), Raise=True) + nick = match.group(1) + reply = format(_('Last seen in %s was %s, %s ago'), + channel, nick, utils.timeElapsed(time.time()-when)) if self.registryValue('showLastMessage', channel, irc.network): reply = _('%s: %s') % (reply, said) irc.reply(reply) @@ -303,14 +320,22 @@ class Seen(callbacks.Plugin): db = self.db try: (when, said) = db.seen(channel, user.id) - reply = format(_('%s was last seen in %s %s ago'), - user.name, channel, - utils.timeElapsed(time.time()-when)) + if user.name in irc.state.channels[channel].users: + reply = format(_('%s was last seen in %s %s ago and is in the channel now'), + user.name, channel, + utils.timeElapsed(time.time()-when)) + else: + reply = format(_('%s was last seen in %s %s ago'), + user.name, channel, + utils.timeElapsed(time.time()-when)) if self.registryValue('showLastMessage', channel, irc.network): reply = _('%s: %s') % (reply, said) irc.reply(reply) except KeyError: - irc.reply(format(_('I have not seen %s.'), user.name)) + if user.name in irc.state.channels[channel].users: + irc.reply(format(_("%s is in the channel right now."), user.name)) + else: + irc.reply(format(_('I have not seen %s.'), user.name)) @internationalizeDocstring def user(self, irc, msg, args, channel, user): diff --git a/plugins/Seen/test.py b/plugins/Seen/test.py index 8a45b15b9..79be570e7 100644 --- a/plugins/Seen/test.py +++ b/plugins/Seen/test.py @@ -83,12 +83,10 @@ class ChannelDBTestCase(ChannelPluginTestCase): self.assertNotError('seen last') self.assertNotError('list') self.assertNotError('config plugins.Seen.minimumNonWildcard 2') - self.assertError('seen *') - self.assertNotError('seen %s' % self.nick) - m = self.assertNotError('seen %s' % self.nick.upper()) - self.assertIn(self.nick.upper(), m.args[1]) self.assertRegexp('seen user %s' % self.nick, '^%s was last seen' % self.nick) + self.assertError('seen *') + self.assertNotError('seen %s' % self.nick) self.assertNotError('config plugins.Seen.minimumNonWildcard 0') orig = conf.supybot.protocols.irc.strictRfc() try: @@ -101,6 +99,28 @@ class ChannelDBTestCase(ChannelPluginTestCase): finally: conf.supybot.protocols.irc.strictRfc.setValue(orig) + + def testSeenNickInChannel(self): + # Test case: 'seen' with a nick (user in channel) + self.irc.feedMsg(ircmsgs.join(self.channel, self.irc.nick, + prefix=self.prefix)) + self.assertRegexp('seen %s' % self.nick, 'is in the channel right now') + m = self.assertNotError('seen %s' % self.nick.upper()) + self.assertIn(self.nick.upper(), m.args[1]) + + def testSeenUserInChannel(self): + # Test case: 'seen' with a user (user in channel) + self.irc.feedMsg(ircmsgs.join(self.channel, self.irc.nick, + prefix=self.prefix)) + self.assertRegexp('seen user %s' % self.nick, 'is in the channel right now') + + def testSeenNickNotInChannel(self): + # Test case: 'seen' with a nick (user not in channel) + testnick = "user123" + self.irc.feedMsg(ircmsgs.join(self.channel, testnick, "user123!baz")) + self.irc.feedMsg(ircmsgs.part(self.channel, prefix="user123!baz")) + self.assertNotRegexp("seen %s" % testnick, "is in the channel right now") + def testSeenNoUser(self): self.irc.feedMsg(ircmsgs.join(self.channel, self.irc.nick, prefix=self.prefix)) From 4ed318d06fc1af264e61a5e1cd4928f466802361 Mon Sep 17 00:00:00 2001 From: Aminda Suomalainen Date: Mon, 16 Oct 2023 22:03:55 +0300 Subject: [PATCH 34/73] NickCapture: fix typo thus -> this --- plugins/NickCapture/README.rst | 2 +- plugins/NickCapture/locales/de.po | 2 +- plugins/NickCapture/locales/fi.po | 2 +- plugins/NickCapture/locales/fr.po | 2 +- plugins/NickCapture/locales/it.po | 2 +- plugins/NickCapture/messages.pot | 2 +- plugins/NickCapture/plugin.py | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/plugins/NickCapture/README.rst b/plugins/NickCapture/README.rst index 4ec4f6112..5fac19726 100644 --- a/plugins/NickCapture/README.rst +++ b/plugins/NickCapture/README.rst @@ -13,7 +13,7 @@ Usage ----- This plugin constantly tries to take whatever nick is configured as -supybot.nick. Just make sure that's set appropriately, and thus plugin +supybot.nick. Just make sure that's set appropriately, and this plugin will do the rest. .. _conf-NickCapture: diff --git a/plugins/NickCapture/locales/de.po b/plugins/NickCapture/locales/de.po index 420452fc5..c2fba5267 100644 --- a/plugins/NickCapture/locales/de.po +++ b/plugins/NickCapture/locales/de.po @@ -31,7 +31,7 @@ msgstr "" #: plugin.py:41 msgid "" "This plugin constantly tries to take whatever nick is configured as\n" -" supybot.nick. Just make sure that's set appropriately, and thus plugin\n" +" supybot.nick. Just make sure that's set appropriately, and this plugin\n" " will do the rest." msgstr "" "Dieses Plugin versucht dauernd den Nick der in supybot.nick konfiguriert ist " diff --git a/plugins/NickCapture/locales/fi.po b/plugins/NickCapture/locales/fi.po index 441ae1f58..ed22c3476 100644 --- a/plugins/NickCapture/locales/fi.po +++ b/plugins/NickCapture/locales/fi.po @@ -34,7 +34,7 @@ msgstr "" #: plugin.py:41 msgid "" "This plugin constantly tries to take whatever nick is configured as\n" -" supybot.nick. Just make sure that's set appropriately, and thus plugin\n" +" supybot.nick. Just make sure that's set appropriately, and this plugin\n" " will do the rest." msgstr "" "Tämä lisäosa yrittää jatkuvasti ottaa sen nimimerkin, joka on määritetty\n" diff --git a/plugins/NickCapture/locales/fr.po b/plugins/NickCapture/locales/fr.po index 4fcd3d137..827b8d17e 100644 --- a/plugins/NickCapture/locales/fr.po +++ b/plugins/NickCapture/locales/fr.po @@ -32,7 +32,7 @@ msgstr "" #: plugin.py:41 msgid "" "This plugin constantly tries to take whatever nick is configured as\n" -" supybot.nick. Just make sure that's set appropriately, and thus plugin\n" +" supybot.nick. Just make sure that's set appropriately, and this plugin\n" " will do the rest." msgstr "" "Ce plugin essaye constament de récupérer le nick configuré dans supybot." diff --git a/plugins/NickCapture/locales/it.po b/plugins/NickCapture/locales/it.po index af9f52380..2a10dc296 100644 --- a/plugins/NickCapture/locales/it.po +++ b/plugins/NickCapture/locales/it.po @@ -28,7 +28,7 @@ msgstr "" #: plugin.py:41 msgid "" "This plugin constantly tries to take whatever nick is configured as\n" -" supybot.nick. Just make sure that's set appropriately, and thus plugin\n" +" supybot.nick. Just make sure that's set appropriately, and this plugin\n" " will do the rest." msgstr "" "Questo plugin cerca costantemente di ottenere qualsiasi nick sia impostato\n" diff --git a/plugins/NickCapture/messages.pot b/plugins/NickCapture/messages.pot index b96fa3035..5ce21f985 100644 --- a/plugins/NickCapture/messages.pot +++ b/plugins/NickCapture/messages.pot @@ -31,7 +31,7 @@ msgstr "" #, docstring msgid "" "This plugin constantly tries to take whatever nick is configured as\n" -" supybot.nick. Just make sure that's set appropriately, and thus plugin\n" +" supybot.nick. Just make sure that's set appropriately, and this plugin\n" " will do the rest." msgstr "" diff --git a/plugins/NickCapture/plugin.py b/plugins/NickCapture/plugin.py index d6b170ac4..6ea442796 100644 --- a/plugins/NickCapture/plugin.py +++ b/plugins/NickCapture/plugin.py @@ -39,7 +39,7 @@ _ = PluginInternationalization('NickCapture') class NickCapture(callbacks.Plugin): """This plugin constantly tries to take whatever nick is configured as - supybot.nick. Just make sure that's set appropriately, and thus plugin + supybot.nick. Just make sure that's set appropriately, and this plugin will do the rest.""" public = False def __init__(self, irc): From e7824213aebc890f6186d5736c880eeb8f9c95ce Mon Sep 17 00:00:00 2001 From: Valentin Lorentz Date: Tue, 17 Oct 2023 18:59:16 +0200 Subject: [PATCH 35/73] Debug: Remove useless shebang --- plugins/Debug/plugin.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/plugins/Debug/plugin.py b/plugins/Debug/plugin.py index 8330d14f6..7b68596ca 100644 --- a/plugins/Debug/plugin.py +++ b/plugins/Debug/plugin.py @@ -1,5 +1,3 @@ -#!/usr/bin/python - ### # Copyright (c) 2002-2005, Jeremiah Fincher # Copyright (c) 2010-2021, Valentin Lorentz From edb13f65dfbd886fc46b23116981a6f410f4b88f Mon Sep 17 00:00:00 2001 From: Valentin Lorentz Date: Tue, 17 Oct 2023 18:59:46 +0200 Subject: [PATCH 36/73] httpserver: Fix incorrect path joining --- src/conf.py | 2 +- src/httpserver.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/conf.py b/src/conf.py index 186419931..7e0fc35ab 100644 --- a/src/conf.py +++ b/src/conf.py @@ -941,7 +941,7 @@ class Directory(registry.String): if os.path.isabs(filename): filename = os.path.abspath(filename) selfAbs = os.path.abspath(myself) - commonPrefix = os.path.commonprefix([selfAbs, filename]) + commonPrefix = os.path.commonpath([selfAbs, filename]) filename = filename[len(commonPrefix):] elif not os.path.isabs(myself): if filename.startswith(myself): diff --git a/src/httpserver.py b/src/httpserver.py index 3b150fc11..6f81953fb 100644 --- a/src/httpserver.py +++ b/src/httpserver.py @@ -337,7 +337,7 @@ class Static(SupyHTTPServerCallback): super(Static, self).__init__() self._mimetype = mimetype def doGetOrHead(self, handler, path, write_content): - response = get_template(path) + response = get_template(path[1:]) # strip leading / if minisix.PY3: response = response.encode() handler.send_response(200) From 04f0d70113e9f5d894100bb8807cd61f8abc5d36 Mon Sep 17 00:00:00 2001 From: Valentin Lorentz Date: Tue, 17 Oct 2023 19:00:03 +0200 Subject: [PATCH 37/73] RSS: Add support for $content/$summary_detail/$title_detail --- plugins/RSS/plugin.py | 29 ++++++++++++++ plugins/RSS/test.py | 93 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 121 insertions(+), 1 deletion(-) diff --git a/plugins/RSS/plugin.py b/plugins/RSS/plugin.py index 6f136d928..9685241ee 100644 --- a/plugins/RSS/plugin.py +++ b/plugins/RSS/plugin.py @@ -497,6 +497,35 @@ class RSS(callbacks.Plugin): isinstance(v, str)} kwargs["feed_name"] = feed.name kwargs.update(entry) + for (key, value) in list(kwargs.items()): + # First look for plain text + if isinstance(value, list): + for item in value: + if isinstance(item, dict) and 'value' in item and \ + item.get('type') == 'text/plain': + value = item['value'] + break + # Then look for HTML text or URL + if isinstance(value, list): + for item in value: + if isinstance(item, dict) and item.get('type') in \ + ('text/html', 'application/xhtml+xml'): + if 'value' in item: + value = utils.web.htmlToText(item['value']) + elif 'href' in item: + value = item['href'] + # Then fall back to any URL + if isinstance(value, list): + for item in value: + if isinstance(item, dict) and 'href' in item: + value = item['href'] + break + # Finally, as a last resort, use the value as-is + if isinstance(value, list): + for item in value: + if isinstance(item, dict) and 'value' in item: + value = item['value'] + kwargs[key] = value s = string.Template(template).safe_substitute(entry, **kwargs, date=date) return self._normalize_entry(s) diff --git a/plugins/RSS/test.py b/plugins/RSS/test.py index a3e67a9ba..d5cdc9f6b 100644 --- a/plugins/RSS/test.py +++ b/plugins/RSS/test.py @@ -59,7 +59,6 @@ not_well_formed = """ """ - class MockResponse: headers = {} url = '' @@ -359,6 +358,98 @@ class RSSTestCase(ChannelPluginTestCase): self.assertRegexp('rss http://xkcd.com/rss.xml', 'On the other hand, the refractor\'s') + @mock_urllib + def testContentHtmlOnly(self, mock): + timeFastForward(1.1) + with conf.supybot.plugins.RSS.format.context('$content'): + mock._data = """ + + + Recent Commits to anope:2.0 + 2023-10-04T16:14:39Z + + title with <pre>HTML<pre> + 2023-10-04T16:14:39Z + + content with <pre>HTML<pre> + + +""" + self.assertRegexp('rss https://example.org', + 'content with HTML') + + @mock_urllib + def testContentXhtmlOnly(self, mock): + timeFastForward(1.1) + with conf.supybot.plugins.RSS.format.context('$content'): + mock._data = """ + + + Recent Commits to anope:2.0 + 2023-10-04T16:14:39Z + + title with <pre>HTML<pre> + 2023-10-04T16:14:39Z + +
+ content with
XHTML
+      
+
+
+
""" + self.assertRegexp('rss https://example.org', + 'content with XHTML') + + @mock_urllib + def testContentHtmlAndPlaintext(self, mock): + timeFastForward(1.1) + with conf.supybot.plugins.RSS.format.context('$content'): + mock._data = """ + + + Recent Commits to anope:2.0 + 2023-10-04T16:14:39Z + + title with <pre>HTML<pre> + 2023-10-04T16:14:39Z + + + content with <pre>HTML<pre> + + + content with plaintext + + +""" + self.assertRegexp('rss https://example.org', + 'content with plaintext') + + @mock_urllib + def testContentPlaintextAndHtml(self, mock): + timeFastForward(1.1) + with conf.supybot.plugins.RSS.format.context('$content'): + mock._data = """ + + + Recent Commits to anope:2.0 + 2023-10-04T16:14:39Z + + title with <pre>HTML<pre> + 2023-10-04T16:14:39Z + + + content with plaintext + + + content with <pre>HTML<pre> + + +""" + self.assertRegexp('rss https://example.org', + 'content with plaintext') + @mock_urllib def testFeedAttribute(self, mock): timeFastForward(1.1) From 2008088a07dc08f5d58f2287ae70610da0f6c1ad Mon Sep 17 00:00:00 2001 From: Valentin Lorentz Date: Tue, 17 Oct 2023 19:57:29 +0200 Subject: [PATCH 38/73] RSS: Copy $summary to $description on Atom feeds Otherwise $description would remain feedparser's default, which is unescaped ; but $description is the only usable one on RSS feeds. --- plugins/RSS/plugin.py | 13 ++++++++ plugins/RSS/test.py | 72 +++++++++++++++++++++++++++++++------------ 2 files changed, 65 insertions(+), 20 deletions(-) diff --git a/plugins/RSS/plugin.py b/plugins/RSS/plugin.py index 9685241ee..aed17a2a0 100644 --- a/plugins/RSS/plugin.py +++ b/plugins/RSS/plugin.py @@ -526,6 +526,19 @@ class RSS(callbacks.Plugin): if isinstance(item, dict) and 'value' in item: value = item['value'] kwargs[key] = value + + for key in ('summary', 'title'): + detail = kwargs.get('%s_detail' % key) + if isinstance(detail, dict) and detail.get('type') in \ + ('text/html', 'application/xhtml+xml'): + kwargs[key] = utils.web.htmlToText(detail['value']) + + if 'description' not in kwargs and kwargs[key]: + kwargs['description'] = kwargs[key] + + if 'description' not in kwargs and kwargs.get('content'): + kwargs['description'] = kwargs['content'] + s = string.Template(template).safe_substitute(entry, **kwargs, date=date) return self._normalize_entry(s) diff --git a/plugins/RSS/test.py b/plugins/RSS/test.py index d5cdc9f6b..71c9d7dc5 100644 --- a/plugins/RSS/test.py +++ b/plugins/RSS/test.py @@ -359,83 +359,91 @@ class RSSTestCase(ChannelPluginTestCase): 'On the other hand, the refractor\'s') @mock_urllib - def testContentHtmlOnly(self, mock): + def testAtomContentHtmlOnly(self, mock): timeFastForward(1.1) - with conf.supybot.plugins.RSS.format.context('$content'): - mock._data = """ + mock._data = """ Recent Commits to anope:2.0 2023-10-04T16:14:39Z - title with <pre>HTML<pre> + title with <pre>HTML</pre> 2023-10-04T16:14:39Z - content with <pre>HTML<pre> + content with <pre>HTML</pre> """ + with conf.supybot.plugins.RSS.format.context('$content'): + self.assertRegexp('rss https://example.org', + 'content with HTML') + with conf.supybot.plugins.RSS.format.context('$description'): self.assertRegexp('rss https://example.org', 'content with HTML') @mock_urllib - def testContentXhtmlOnly(self, mock): + def testAtomContentXhtmlOnly(self, mock): timeFastForward(1.1) - with conf.supybot.plugins.RSS.format.context('$content'): - mock._data = """ + mock._data = """ Recent Commits to anope:2.0 2023-10-04T16:14:39Z - title with <pre>HTML<pre> + title with <pre>HTML</pre> 2023-10-04T16:14:39Z
- content with
XHTML
+        content with 
XHTML
""" + with conf.supybot.plugins.RSS.format.context('$content'): + self.assertRegexp('rss https://example.org', + 'content with XHTML') + with conf.supybot.plugins.RSS.format.context('$description'): self.assertRegexp('rss https://example.org', 'content with XHTML') @mock_urllib - def testContentHtmlAndPlaintext(self, mock): + def testAtomContentHtmlAndPlaintext(self, mock): timeFastForward(1.1) - with conf.supybot.plugins.RSS.format.context('$content'): - mock._data = """ + mock._data = """ Recent Commits to anope:2.0 2023-10-04T16:14:39Z - title with <pre>HTML<pre> + title with <pre>HTML</pre> 2023-10-04T16:14:39Z - content with <pre>HTML<pre> + content with <pre>HTML</pre> content with plaintext """ + with conf.supybot.plugins.RSS.format.context('$content'): + self.assertRegexp('rss https://example.org', + 'content with plaintext') + with conf.supybot.plugins.RSS.format.context('$description'): self.assertRegexp('rss https://example.org', 'content with plaintext') @mock_urllib - def testContentPlaintextAndHtml(self, mock): + def testAtomContentPlaintextAndHtml(self, mock): timeFastForward(1.1) - with conf.supybot.plugins.RSS.format.context('$content'): - mock._data = """ + mock._data = """ Recent Commits to anope:2.0 2023-10-04T16:14:39Z - title with <pre>HTML<pre> + title with <pre>HTML</pre> 2023-10-04T16:14:39Z @@ -443,12 +451,36 @@ class RSSTestCase(ChannelPluginTestCase): content with plaintext
- content with <pre>HTML<pre> + content with <pre>HTML</pre> """ + with conf.supybot.plugins.RSS.format.context('$content'): self.assertRegexp('rss https://example.org', 'content with plaintext') + with conf.supybot.plugins.RSS.format.context('$description'): + self.assertRegexp('rss https://example.org', + 'content with plaintext') + + @mock_urllib + def testRssDescriptionHtml(self, mock): + timeFastForward(1.1) + mock._data = """ + + + + feed title + + en + + title with <pre>HTML</pre> + description with <pre>HTML</pre> + + +""" + with conf.supybot.plugins.RSS.format.context('$description'): + self.assertRegexp('rss https://example.org', + 'description with HTML') @mock_urllib def testFeedAttribute(self, mock): From 15009caeff65892fb01653605b5e5da3ed9b505c Mon Sep 17 00:00:00 2001 From: Valentin Lorentz Date: Tue, 17 Oct 2023 20:04:42 +0200 Subject: [PATCH 39/73] Remove requirement for supybot.directories.data.web to be a subdir of supybot.directories.data --- src/conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/conf.py b/src/conf.py index 7e0fc35ab..0a02ecaac 100644 --- a/src/conf.py +++ b/src/conf.py @@ -984,7 +984,7 @@ registerGlobalValue(supybot.directories.data, 'tmp', DataFilenameDirectory('tmp', _("""Determines what directory temporary files are put into."""))) registerGlobalValue(supybot.directories.data, 'web', - DataFilenameDirectory('web', _("""Determines what directory files of the + Directory('web', _("""Determines what directory files of the web server (templates, custom images, ...) are put into."""))) def _update_tmp(): From 18699b0cf227f9090f504e3092866c0718a44efc Mon Sep 17 00:00:00 2001 From: Valentin Lorentz Date: Tue, 17 Oct 2023 20:13:56 +0200 Subject: [PATCH 40/73] Fix breakage of supybot.directories.data.web when it's a relative directory (the default) --- src/conf.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/conf.py b/src/conf.py index 0a02ecaac..c29f04c87 100644 --- a/src/conf.py +++ b/src/conf.py @@ -954,7 +954,7 @@ class DataFilename(registry.String): def __call__(self): v = super(DataFilename, self).__call__() dataDir = supybot.directories.data() - if not v.startswith(dataDir): + if not v.startswith("/") and not v.startswith(dataDir): v = os.path.basename(v) v = os.path.join(dataDir, v) self.setValue(v) @@ -984,7 +984,7 @@ registerGlobalValue(supybot.directories.data, 'tmp', DataFilenameDirectory('tmp', _("""Determines what directory temporary files are put into."""))) registerGlobalValue(supybot.directories.data, 'web', - Directory('web', _("""Determines what directory files of the + DataFilenameDirectory('web', _("""Determines what directory files of the web server (templates, custom images, ...) are put into."""))) def _update_tmp(): From 1fb0bbd1c033a2746799dbc8eb7960d3effb2786 Mon Sep 17 00:00:00 2001 From: James Lu Date: Tue, 24 Oct 2023 20:05:18 -0700 Subject: [PATCH 41/73] Fix recursive loop in limnoria_reset_password Closes GH-1565 --- src/scripts/limnoria_reset_password.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/scripts/limnoria_reset_password.py b/src/scripts/limnoria_reset_password.py index 08f0a037e..4e1daefed 100644 --- a/src/scripts/limnoria_reset_password.py +++ b/src/scripts/limnoria_reset_password.py @@ -104,7 +104,7 @@ def _main(): def main(): try: - main() + _main() except KeyboardInterrupt: pass From faa6474271e4623b40d11153630b29e63eb30655 Mon Sep 17 00:00:00 2001 From: Valentin Lorentz Date: Fri, 27 Oct 2023 11:30:57 +0200 Subject: [PATCH 42/73] Geography: Add support for OSM node ids --- plugins/Geography/test.py | 8 +++++++- plugins/Geography/wikidata.py | 9 ++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/plugins/Geography/test.py b/plugins/Geography/test.py index 0b496d2fa..4dbfb8538 100644 --- a/plugins/Geography/test.py +++ b/plugins/Geography/test.py @@ -187,7 +187,7 @@ class GeographyLocaltimeTestCase(PluginTestCase): class GeographyWikidataTestCase(SupyTestCase): @skipIf(not network, "Network test") - def testOsmidToTimezone(self): + def testRelationOsmidToTimezone(self): self.assertEqual( wikidata.uri_from_osmid(450381), "http://www.wikidata.org/entity/Q22690", @@ -196,6 +196,12 @@ class GeographyWikidataTestCase(SupyTestCase): wikidata.uri_from_osmid(192468), "http://www.wikidata.org/entity/Q47045", ) + @skipIf(not network, "Network test") + def testNodeOsmidToTimezone(self): + self.assertEqual( + wikidata.uri_from_osmid(436012592), + "http://www.wikidata.org/entity/Q933", + ) @skipIf(not network, "Network test") def testDirect(self): diff --git a/plugins/Geography/wikidata.py b/plugins/Geography/wikidata.py index 2256691ee..d07c0ebb4 100644 --- a/plugins/Geography/wikidata.py +++ b/plugins/Geography/wikidata.py @@ -115,7 +115,14 @@ LIMIT 1 OSMID_QUERY = string.Template( """ SELECT ?item WHERE { - ?item wdt:P402 "$osmid". + { + ?item wdt:P402 "$osmid". # OSM relation ID + } + UNION + { + ?item wdt:P11693 "$osmid". # OSM node ID + } + } LIMIT 1 """ From 3f9ab4b89c71612b11b8130a332ae83c1f87f5b5 Mon Sep 17 00:00:00 2001 From: Valentin Lorentz Date: Sat, 28 Oct 2023 09:47:55 +0200 Subject: [PATCH 43/73] Web: Fix crash on trailing ';' in Content-Type --- plugins/Web/plugin.py | 9 +++++++-- plugins/Web/test.py | 6 ++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/plugins/Web/plugin.py b/plugins/Web/plugin.py index a3e258e4d..9deb19fda 100644 --- a/plugins/Web/plugin.py +++ b/plugins/Web/plugin.py @@ -186,9 +186,14 @@ class Web(callbacks.PluginRegexp): encoding = None if 'Content-Type' in fd.headers: - mime_params = [p.split('=', 1) + # using p.partition('=') instead of 'p.split('=', 1)' because, + # unlike RFC 7231, RFC 9110 allows an empty parameter list + # after ';': + # * https://www.rfc-editor.org/rfc/rfc9110.html#name-media-type + # * https://www.rfc-editor.org/rfc/rfc9110.html#parameter + mime_params = [p.partition('=') for p in fd.headers['Content-Type'].split(';')[1:]] - mime_params = {k.strip(): v.strip() for (k, v) in mime_params} + mime_params = {k.strip(): v.strip() for (k, sep, v) in mime_params} if mime_params.get('charset'): encoding = mime_params['charset'] diff --git a/plugins/Web/test.py b/plugins/Web/test.py index 88fd10cac..e8ecdff33 100644 --- a/plugins/Web/test.py +++ b/plugins/Web/test.py @@ -85,6 +85,12 @@ class WebTestCase(ChannelPluginTestCase): 'title https://www.reddit.com/r/irc/', 'Internet Relay Chat') + def testTitleMarcinfo(self): + # Checks that we don't crash on 'Content-Type: text/html;' + self.assertResponse( + 'title https://marc.info/?l=openbsd-tech&m=169841790407370&w=2', + "'Removing syscall(2) from libc and kernel' - MARC") + def testTitleSnarfer(self): try: conf.supybot.plugins.Web.titleSnarfer.setValue(True) From 689c633e92e09a4c8b8bd19c234b072bd104d10f Mon Sep 17 00:00:00 2001 From: Valentin Lorentz Date: Sun, 29 Oct 2023 12:32:33 +0100 Subject: [PATCH 44/73] Web: Fix crash on socket.timeout on snarfed URLs --- plugins/Web/plugin.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugins/Web/plugin.py b/plugins/Web/plugin.py index 9deb19fda..b8146aeb7 100644 --- a/plugins/Web/plugin.py +++ b/plugins/Web/plugin.py @@ -173,8 +173,9 @@ class Web(callbacks.PluginRegexp): if raiseErrors: irc.error(_('Connection to %s timed out') % url, Raise=True) else: - selg.log.info('Web plugins TitleSnarfer: URL <%s> timed out', + self.log.info('Web plugins TitleSnarfer: URL <%s> timed out', url) + return except Exception as e: if raiseErrors: irc.error(_('That URL raised <' + str(e)) + '>', From fffdd82571689577db86849ec633588e6532dab3 Mon Sep 17 00:00:00 2001 From: Valentin Lorentz Date: Sun, 29 Oct 2023 12:40:48 +0100 Subject: [PATCH 45/73] Fediverse: Catch URLErrors raised when checking webfinger support --- plugins/Fediverse/plugin.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/plugins/Fediverse/plugin.py b/plugins/Fediverse/plugin.py index ab8607193..4c2157863 100644 --- a/plugins/Fediverse/plugin.py +++ b/plugins/Fediverse/plugin.py @@ -177,9 +177,15 @@ class Fediverse(callbacks.PluginRegexp): def _has_webfinger_support(self, hostname): if hostname not in self._webfinger_support_cache: - self._webfinger_support_cache[hostname] = ap.has_webfinger_support( - hostname - ) + try: + self._webfinger_support_cache[hostname] = ap.has_webfinger_support( + hostname + ) + except Exception as e: + self.log.error( + "Checking Webfinger support for %s raised %s", hostname, e + ) + return False return self._webfinger_support_cache[hostname] def _get_actor(self, irc, username): From 06c88581ec5b6547de0012a75dfed6316ceda011 Mon Sep 17 00:00:00 2001 From: Valentin Lorentz Date: Sat, 18 Nov 2023 22:02:36 +0100 Subject: [PATCH 46/73] Services: Improve error on missing password or NickServ nick --- plugins/Services/plugin.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/plugins/Services/plugin.py b/plugins/Services/plugin.py index b3efd785d..b07ba088e 100644 --- a/plugins/Services/plugin.py +++ b/plugins/Services/plugin.py @@ -124,9 +124,11 @@ class Services(callbacks.Plugin): return nickserv = self.registryValue('NickServ', network=irc.network) password = self._getNickServPassword(nick, irc.network) - if not nickserv or not password: - s = 'Tried to identify without a NickServ or password set.' - self.log.warning(s) + if not nickserv: + self.log.warning('Tried to identify without a NickServ set.') + return + if not password: + self.log.warning('Tried to identify without a password set.') return assert ircutils.strEqual(irc.nick, nick), \ 'Identifying with not normal nick.' @@ -150,16 +152,15 @@ class Services(callbacks.Plugin): ghostDelay = self.registryValue('ghostDelay', network=irc.network) if not ghostDelay: return - if not nickserv or not password: - s = 'Tried to ghost without a NickServ or password set.' - self.log.warning(s) + if not nickserv: + self.log.warning('Tried to ghost without a NickServ set.') + return + if not password: + self.log.warning('Tried to ghost without a password set.') return if state.sentGhost and time.time() < (state.sentGhost + ghostDelay): self.log.warning('Refusing to send GHOST more than once every ' '%s seconds.' % ghostDelay) - elif not password: - self.log.warning('Not ghosting: no password set.') - return else: self.log.info('Sending ghost (current nick: %s; ghosting: %s)', irc.nick, nick) From 5ca0fcd87ceda665aee7240fa89a01e0316ce332 Mon Sep 17 00:00:00 2001 From: Stathis Xantinidis Date: Fri, 15 Dec 2023 10:58:13 +0200 Subject: [PATCH 47/73] Changed whois provider domain to whois.iana.org The previous was giving timeouts --- plugins/Internet/plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/Internet/plugin.py b/plugins/Internet/plugin.py index c9822dd08..d9993db1b 100644 --- a/plugins/Internet/plugin.py +++ b/plugins/Internet/plugin.py @@ -158,7 +158,7 @@ class Internet(callbacks.Plugin): if not status: status = 'unknown' try: - t = telnetlib.Telnet('whois.pir.org', 43) + t = telnetlib.Telnet('whois.iana.org', 43) except socket.error as e: irc.error(str(e)) return From d55a08c63e1d700a6b8bc258bf25630f4d7c4c94 Mon Sep 17 00:00:00 2001 From: Valentin Lorentz Date: Wed, 3 Jan 2024 18:36:47 +0100 Subject: [PATCH 48/73] Regenerate plugin READMEs --- plugins/Network/README.rst | 5 +++++ plugins/RSS/README.rst | 2 +- plugins/SedRegex/README.rst | 16 ++++++++++++++++ plugins/Unix/README.rst | 6 +++--- 4 files changed, 25 insertions(+), 4 deletions(-) diff --git a/plugins/Network/README.rst b/plugins/Network/README.rst index 1cfe359f2..5b41d2b71 100644 --- a/plugins/Network/README.rst +++ b/plugins/Network/README.rst @@ -21,6 +21,11 @@ and checking latency to the server. Commands -------- +.. _command-network-authenticate: + +authenticate takes no arguments + Manually initiate SASL authentication. + .. _command-network-capabilities: capabilities [] diff --git a/plugins/RSS/README.rst b/plugins/RSS/README.rst index eaf505e44..65520cb0c 100644 --- a/plugins/RSS/README.rst +++ b/plugins/RSS/README.rst @@ -140,7 +140,7 @@ supybot.plugins.RSS.feeds supybot.plugins.RSS.format This config variable defaults to "$date: $title <$link>", is network-specific, and is channel-specific. - The format the bot will use for displaying headlines of a RSS feed that is triggered manually. In addition to fields defined by feedparser ($published (the entry date), $title, $link, $description, $id, etc.), the following variables can be used: $feed_name, $date (parsed date, as defined in supybot.reply.format.time) + The format the bot will use for displaying headlines of a RSS feed that is triggered manually. In addition to fields defined by feedparser ($published (the entry date), $title, $link, $description, $id, etc.), the following variables can be used: $feed_name (the configured name) $feed_title/$feed_subtitle/$feed_author/$feed_language/$feed_link, $date (parsed date, as defined in supybot.reply.format.time) .. _conf-supybot.plugins.RSS.headlineSeparator: diff --git a/plugins/SedRegex/README.rst b/plugins/SedRegex/README.rst index 4f7477153..13ae21e95 100644 --- a/plugins/SedRegex/README.rst +++ b/plugins/SedRegex/README.rst @@ -67,6 +67,22 @@ supybot.plugins.SedRegex.enable Should Perl/sed-style regex replacing work in this channel? +.. _conf-supybot.plugins.SedRegex.format: + + +supybot.plugins.SedRegex.format + This config variable defaults to "$nick meant to say: $replacement", is network-specific, and is channel-specific. + + Sets the format string for a message edited by the original author. Required fields: $nick (nick of the author), $replacement (edited message) + + .. _conf-supybot.plugins.SedRegex.format.other: + + + supybot.plugins.SedRegex.format.other + This config variable defaults to "$otherNick thinks $nick meant to say: $replacement", is network-specific, and is channel-specific. + + Sets the format string for a message edited by another author. Required fields: $nick (nick of the original author), $otherNick (nick of the editor), $replacement (edited message) + .. _conf-supybot.plugins.SedRegex.ignoreRegex: diff --git a/plugins/Unix/README.rst b/plugins/Unix/README.rst index 207367b77..9fdb10437 100644 --- a/plugins/Unix/README.rst +++ b/plugins/Unix/README.rst @@ -144,7 +144,7 @@ supybot.plugins.Unix.ping supybot.plugins.Unix.ping.command - This config variable defaults to "/bin/ping", is not network-specific, and is not channel-specific. + This config variable defaults to "/usr/bin/ping", is not network-specific, and is not channel-specific. Determines what command will be called for the ping command. @@ -166,7 +166,7 @@ supybot.plugins.Unix.ping6 supybot.plugins.Unix.ping6.command - This config variable defaults to "/bin/ping6", is not network-specific, and is not channel-specific. + This config variable defaults to "/usr/bin/ping6", is not network-specific, and is not channel-specific. Determines what command will be called for the ping6 command. @@ -210,7 +210,7 @@ supybot.plugins.Unix.sysuname supybot.plugins.Unix.sysuname.command - This config variable defaults to "/bin/uname", is not network-specific, and is not channel-specific. + This config variable defaults to "/usr/bin/uname", is not network-specific, and is not channel-specific. Determines what command will be called for the uname command. From a2e55ca1f6fe9bcd6000417ddf77af17ca26f0d1 Mon Sep 17 00:00:00 2001 From: Valentin Lorentz Date: Wed, 3 Jan 2024 18:36:58 +0100 Subject: [PATCH 49/73] RSS: Update link to feedparser --- plugins/RSS/README.rst | 5 ++--- plugins/RSS/__init__.py | 5 ++--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/plugins/RSS/README.rst b/plugins/RSS/README.rst index 65520cb0c..1aca99b19 100644 --- a/plugins/RSS/README.rst +++ b/plugins/RSS/README.rst @@ -8,9 +8,8 @@ Purpose Provides basic functionality for handling RSS/RDF feeds, and allows announcing them periodically to channels. -In order to use this plugin you must have the following modules -installed: -* feedparser: http://feedparser.org/ +In order to use this plugin you must have `python3-feedparser +`_ installed. Usage ----- diff --git a/plugins/RSS/__init__.py b/plugins/RSS/__init__.py index a8b9623d2..21a1fae20 100644 --- a/plugins/RSS/__init__.py +++ b/plugins/RSS/__init__.py @@ -31,9 +31,8 @@ """ Provides basic functionality for handling RSS/RDF feeds, and allows announcing them periodically to channels. -In order to use this plugin you must have the following modules -installed: -* feedparser: http://feedparser.org/ +In order to use this plugin you must have `python3-feedparser +`_ installed. """ import supybot From 3e5291f6d29499c9b85620581aec43d0b51a8da7 Mon Sep 17 00:00:00 2001 From: James Lu Date: Sat, 12 Aug 2023 14:50:33 -0700 Subject: [PATCH 50/73] ircdb.checkIgnored: return False for messages from servers These do not pass the `ircutils.isUserHostmask` check despite being a valid msg.prefix. We should probably return gracefully here instead of forcing plugins to deal with such a case themselves. Closes GH-1548 --- src/ircdb.py | 7 ++++++- test/test_ircdb.py | 17 +++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/ircdb.py b/src/ircdb.py index e3124fd07..71d5900f5 100644 --- a/src/ircdb.py +++ b/src/ircdb.py @@ -468,7 +468,12 @@ class IrcChannel(object): return True if world.testing: return False - assert ircutils.isUserHostmask(hostmask), 'got %s' % hostmask + if not ircutils.isUserHostmask(hostmask): + # Treat messages from a server (e.g. snomasks) as not ignored, as + # the ignores system doesn't understand them + if '.' not in hostmask: + raise ValueError("Expected full prefix, got %r" % hostmask) + return False if self.checkBan(hostmask): return True if self.ignores.match(hostmask): diff --git a/test/test_ircdb.py b/test/test_ircdb.py index f8ba766b8..72ee8beac 100644 --- a/test/test_ircdb.py +++ b/test/test_ircdb.py @@ -350,6 +350,23 @@ class IrcChannelTestCase(IrcdbTestCase): c.removeBan(banmask) self.assertFalse(c.checkIgnored(prefix)) + # Only full n!u@h is accepted here + self.assertRaises(ValueError, c.checkIgnored, 'foo') + + def testIgnoredServerNames(self): + c = ircdb.IrcChannel() + # Server names are not handled by the ignores system, so this is false + self.assertFalse(c.checkIgnored('irc.example.com')) + # But we should treat full prefixes that match nick!user@host normally, + # even if they include "." like a server name + prefix = 'irc.example.com!bar@baz' + banmask = ircutils.banmask(prefix) + self.assertFalse(c.checkIgnored(prefix)) + c.addIgnore(banmask) + self.assertTrue(c.checkIgnored(prefix)) + c.removeIgnore(banmask) + self.assertFalse(c.checkIgnored(prefix)) + class IrcNetworkTestCase(IrcdbTestCase): def testDefaults(self): n = ircdb.IrcNetwork() From ca8565b6d8b4488325b384c3100274e1dfb02666 Mon Sep 17 00:00:00 2001 From: Valentin Lorentz Date: Sat, 9 Mar 2024 11:47:08 +0100 Subject: [PATCH 51/73] RSS: Don't log tracebacks for HTTP errors --- plugins/RSS/plugin.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/plugins/RSS/plugin.py b/plugins/RSS/plugin.py index aed17a2a0..7b68f7e35 100644 --- a/plugins/RSS/plugin.py +++ b/plugins/RSS/plugin.py @@ -364,6 +364,11 @@ class RSS(callbacks.Plugin): feed.url, e) feed.last_exception = e return + except http.client.HTTPException as e: + self.log.warning("HTTP error while fetching <%s>: %s", + feed.url, e) + feed.last_exception = e + return except Exception as e: self.log.error("Failed to fetch <%s>: %s", feed.url, e) raise # reraise so @log.firewall prints the traceback From 03a37771297d25b3455209898eaad5618f4f6345 Mon Sep 17 00:00:00 2001 From: GMDSantana <6341823+GMDSantana@users.noreply.github.com> Date: Fri, 12 Apr 2024 17:06:30 +0800 Subject: [PATCH 52/73] Create temporary files in a temporary directory But keep it if tests fail. Closes #1061 --- src/scripts/limnoria_test.py | 30 ++++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/src/scripts/limnoria_test.py b/src/scripts/limnoria_test.py index 7c35d6ed5..81b5a6ef1 100644 --- a/src/scripts/limnoria_test.py +++ b/src/scripts/limnoria_test.py @@ -36,6 +36,7 @@ import sys import time import shutil import fnmatch +from tempfile import TemporaryDirectory started = time.time() import supybot @@ -43,16 +44,19 @@ import logging import traceback # We need to do this before we import conf. -if not os.path.exists('test-conf'): - os.mkdir('test-conf') +main_temp_dir = TemporaryDirectory() -registryFilename = os.path.join('test-conf', 'test.conf') -fd = open(registryFilename, 'w') -fd.write(""" +os.makedirs(os.path.join(main_temp_dir.name, 'conf')) +os.makedirs(os.path.join(main_temp_dir.name, 'data')) +os.makedirs(os.path.join(main_temp_dir.name, 'logs')) + +registryFilename = os.path.join(main_temp_dir.name, 'conf', 'test.conf') +with open(registryFilename, 'w') as fd: + fd.write(""" supybot.directories.backup: /dev/null -supybot.directories.conf: %(base_dir)s/test-conf -supybot.directories.data: %(base_dir)s/test-data -supybot.directories.log: %(base_dir)s/test-logs +supybot.directories.conf: {temp_conf} +supybot.directories.data: {temp_data} +supybot.directories.log: {temp_logs} supybot.reply.whenNotCommand: True supybot.log.stdout: False supybot.log.stdout.level: ERROR @@ -67,8 +71,11 @@ supybot.networks.testnet2.server: should.not.need.this supybot.networks.testnet3.server: should.not.need.this supybot.nick: test supybot.databases.users.allowUnregistration: True -""" % {'base_dir': os.getcwd()}) -fd.close() +""".format( + temp_conf=os.path.join(main_temp_dir.name, 'conf'), + temp_data=os.path.join(main_temp_dir.name, 'data'), + temp_logs=os.path.join(main_temp_dir.name, 'logs') + )) import supybot.registry as registry registry.open_registry(registryFilename) @@ -251,6 +258,9 @@ def main(): if result.wasSuccessful(): sys.exit(0) else: + # Deactivate autocleaning for the temporary directiories to allow inspection. + main_temp_dir._finalizer.detach() + print(f"Temporary directory path: {main_temp_dir.name}") sys.exit(1) From 03c638705ffa59c06878dfd4d9c8aa3b83a2b9eb Mon Sep 17 00:00:00 2001 From: Valentin Lorentz Date: Fri, 12 Apr 2024 19:14:57 +0200 Subject: [PATCH 53/73] Channel: Fix error in @part when channel is configured but not joined This typically happens when banned from the channel, and returning an error gives bot admins the impression @part did not remove the channel from the auto-join list --- plugins/Channel/plugin.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/plugins/Channel/plugin.py b/plugins/Channel/plugin.py index 26ad62501..1f3f97fed 100644 --- a/plugins/Channel/plugin.py +++ b/plugins/Channel/plugin.py @@ -991,9 +991,14 @@ class Channel(callbacks.Plugin): network = conf.supybot.networks.get(irc.network) network.channels().remove(channel) except KeyError: - pass - if channel not in irc.state.channels: - irc.error(_('I\'m not in %s.') % channel, Raise=True) + if channel not in irc.state.channels: + # Not configured AND not in the channel + irc.error(_('I\'m not in %s.') % channel, Raise=True) + else: + if channel not in irc.state.channels: + # Configured, but not in the channel + irc.reply(_('%s removed from configured join list.') % channel) + return reason = (reason or self.registryValue("partMsg", channel, irc.network)) reason = ircutils.standardSubstitute(irc, msg, reason) irc.queueMsg(ircmsgs.part(channel, reason)) From c8030be71aa0fa9427c37d326fc86ef1283dd8c2 Mon Sep 17 00:00:00 2001 From: Valentin Lorentz Date: Thu, 18 Apr 2024 19:33:55 +0200 Subject: [PATCH 54/73] Web: Need to download even more Javascript from Youtube --- plugins/Web/plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/Web/plugin.py b/plugins/Web/plugin.py index b8146aeb7..76a8b04e1 100644 --- a/plugins/Web/plugin.py +++ b/plugins/Web/plugin.py @@ -154,7 +154,7 @@ class Web(callbacks.PluginRegexp): if parsed_url.netloc == 'youtube.com' \ or parsed_url.netloc.endswith(('.youtube.com')): # there is a lot of Javascript before the - size = max(409600, size) + size = max(819200, size) if parsed_url.netloc in ('reddit.com', 'www.reddit.com', 'new.reddit.com'): # Since 2022-03, New Reddit has 'Reddit - Dive into anything' as # <title> on every page. From 943f39745dd23ffca9ec5a45eaf25b2efd4625e5 Mon Sep 17 00:00:00 2001 From: Valentin Lorentz <progval+git@progval.net> Date: Thu, 18 Apr 2024 19:47:22 +0200 Subject: [PATCH 55/73] Admin: Fix leftover state change in testPart it affects Channel's testPart --- plugins/Admin/test.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/plugins/Admin/test.py b/plugins/Admin/test.py index 92ae557c2..afc08153a 100644 --- a/plugins/Admin/test.py +++ b/plugins/Admin/test.py @@ -87,13 +87,16 @@ class AdminTestCase(PluginTestCase): ircdb.users.delUser(u.id) def testJoin(self): - m = self.getMsg('join #foo') - self.assertEqual(m.command, 'JOIN') - self.assertEqual(m.args[0], '#foo') - m = self.getMsg('join #foo key') - self.assertEqual(m.command, 'JOIN') - self.assertEqual(m.args[0], '#foo') - self.assertEqual(m.args[1], 'key') + try: + m = self.getMsg('join #foo') + self.assertEqual(m.command, 'JOIN') + self.assertEqual(m.args[0], '#foo') + m = self.getMsg('join #foo key') + self.assertEqual(m.command, 'JOIN') + self.assertEqual(m.args[0], '#foo') + self.assertEqual(m.args[1], 'key') + finally: + self.getMsg('part #foo') def testNick(self): try: From 6758c003637aeb8c8ff85f82b21e73a4a2622c11 Mon Sep 17 00:00:00 2001 From: Valentin Lorentz <progval+git@progval.net> Date: Fri, 26 Apr 2024 08:57:49 +0200 Subject: [PATCH 56/73] limnoria-test: Fix log config Since 03a37771297d25b3455209898eaad5618f4f6345 we use .format() instead of % for substitution, so these should not be escaped anymore. --- src/scripts/limnoria_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/scripts/limnoria_test.py b/src/scripts/limnoria_test.py index 81b5a6ef1..b370f9bac 100644 --- a/src/scripts/limnoria_test.py +++ b/src/scripts/limnoria_test.py @@ -61,7 +61,7 @@ supybot.reply.whenNotCommand: True supybot.log.stdout: False supybot.log.stdout.level: ERROR supybot.log.level: DEBUG -supybot.log.format: %%(levelname)s %%(message)s +supybot.log.format: %(levelname)s %(message)s supybot.log.plugins.individualLogfiles: False supybot.protocols.irc.throttleTime: 0 supybot.reply.whenAddressedBy.chars: @ From d435442b39f167509e64b21a9cd1cf3f71e33033 Mon Sep 17 00:00:00 2001 From: Valentin Lorentz <progval+git@progval.net> Date: Fri, 26 Apr 2024 09:04:45 +0200 Subject: [PATCH 57/73] Admin: Actually clean up test channel from configuration 943f39745dd23ffca9ec5a45eaf25b2efd4625e5 did not actually because: 1. the 'part' command is not available (it's in the Channel plugin) so it just didn't do anything 2. one of the tests was missing the cleanup --- plugins/Admin/test.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/plugins/Admin/test.py b/plugins/Admin/test.py index afc08153a..5f3aabd59 100644 --- a/plugins/Admin/test.py +++ b/plugins/Admin/test.py @@ -50,6 +50,7 @@ class AdminTestCase(PluginTestCase): self.irc.feedMsg(ircmsgs.join('#Baz', prefix=self.prefix)) getAfterJoinMessages() self.assertRegexp('channels', '#bar, #Baz, and #foo') + self.assertNotRegexp('config networks.test.channels', '.*#foo.*') def testIgnoreAddRemove(self): self.assertNotError('admin ignore add foo!bar@baz') @@ -96,7 +97,7 @@ class AdminTestCase(PluginTestCase): self.assertEqual(m.args[0], '#foo') self.assertEqual(m.args[1], 'key') finally: - self.getMsg('part #foo') + conf.supybot.networks.test.channels.setValue('') def testNick(self): try: @@ -110,10 +111,13 @@ class AdminTestCase(PluginTestCase): self.assertError('admin capability add %s owner' % self.nick) def testJoinOnOwnerInvite(self): - self.irc.feedMsg(ircmsgs.invite(conf.supybot.nick(), '#foo', prefix=self.prefix)) - m = self.getMsg(' ') - self.assertEqual(m.command, 'JOIN') - self.assertEqual(m.args[0], '#foo') + try: + self.irc.feedMsg(ircmsgs.invite(conf.supybot.nick(), '#foo', prefix=self.prefix)) + m = self.getMsg(' ') + self.assertEqual(m.command, 'JOIN') + self.assertEqual(m.args[0], '#foo') + finally: + conf.supybot.networks.test.channels.setValue('') def testNoJoinOnUnprivilegedInvite(self): try: @@ -124,6 +128,7 @@ class AdminTestCase(PluginTestCase): 'Error: "somecommand" is not a valid command.') finally: world.testing = True + self.assertNotRegexp('config networks.test.channels', '.*#foo.*') def testAcmd(self): self.irc.feedMsg(ircmsgs.join('#foo', prefix=self.prefix)) From 07834620f341ac89e2509e0c7158efe5318aff4c Mon Sep 17 00:00:00 2001 From: Valentin Lorentz <progval+git@progval.net> Date: Sun, 5 May 2024 17:56:00 +0200 Subject: [PATCH 58/73] CONTRIBUTING.md: Update documentation URLs --- CONTRIBUTING.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 259195eaf..99badc092 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,7 +15,7 @@ Last rule: you shouldn't add a mandatory dependency. Limnoria does not come with any (besides Python), so please try to keep all dependencies optional. -[Style Guidelines]:https://limnoria.readthedocs.io/en/latest/develop/style.html +[Style Guidelines]:https://docs.limnoria.net/develop/style.html ## Sending patches @@ -32,6 +32,6 @@ is very appreciated. See also [Contributing to Limnoria] at [Limnoria documentation]. -[Contributing to Limnoria]:https://limnoria.readthedocs.io/en/latest/contribute/index.html +[Contributing to Limnoria]:https://docs.limnoria.net/contribute/index.html -[Limnoria documentation]:https://limnoria.readthedocs.io/ +[Limnoria documentation]:https://docs.limnoria.net/ From f65089af86b3a889f3b727c0a79d997ef5ebfc34 Mon Sep 17 00:00:00 2001 From: Valentin Lorentz <progval+git@progval.net> Date: Sun, 5 May 2024 17:56:48 +0200 Subject: [PATCH 59/73] CONTRIBUTING.md: Remove the bit about the testing branch We're going to commit directly to master from now one. The 'testing' policy predates PyPI releases and Git master was the primary mean of distributing Limnoria back then, but it does not make sense anymore. --- CONTRIBUTING.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 99badc092..4cb2f98b9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -19,10 +19,6 @@ optional. ## Sending patches -When you send a pull request, **send it to the testing branch**. -It will be merged to master when it's considered to be stable enough to be -supported. - Don't fear that you spam Limnoria by sending many pull requests. According to @ProgVal, it's easier for them to accept pull requests than to cherry-pick everything manually. From 9bcb21389adc62dd099afd0665990aa0128a7ad3 Mon Sep 17 00:00:00 2001 From: Valentin Lorentz <progval+git@progval.net> Date: Sun, 5 May 2024 19:02:41 +0200 Subject: [PATCH 60/73] Fix SyntaxWarning on Python 3.12 --- plugins/DDG/test.py | 4 ++-- plugins/Dict/local/dictclient.py | 2 +- plugins/Fediverse/utils.py | 10 +++++----- plugins/Math/local/convertcore.py | 8 ++++---- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/plugins/DDG/test.py b/plugins/DDG/test.py index c10ba9955..a3b96a407 100644 --- a/plugins/DDG/test.py +++ b/plugins/DDG/test.py @@ -39,7 +39,7 @@ class DDGTestCase(PluginTestCase): def testSearch(self): self.assertRegexp( - 'ddg search wikipedia', 'Wikipedia.*? - .*?https?\:\/\/') + r'ddg search wikipedia', 'Wikipedia.*? - .*?https?\:\/\/') self.assertRegexp( 'ddg search en.wikipedia.org', 'Wikipedia, the free encyclopedia\x02 - ' @@ -47,6 +47,6 @@ class DDGTestCase(PluginTestCase): with conf.supybot.plugins.DDG.region.context('fr-fr'): self.assertRegexp( 'ddg search wikipedia', - 'Wikipédia, l\'encyclopédie libre - .*?https?\:\/\/') + r'Wikipédia, l\'encyclopédie libre - .*?https?\:\/\/') # vim:set shiftwidth=4 tabstop=4 expandtab textwidth=79: diff --git a/plugins/Dict/local/dictclient.py b/plugins/Dict/local/dictclient.py index 04251250a..683e6e8e1 100644 --- a/plugins/Dict/local/dictclient.py +++ b/plugins/Dict/local/dictclient.py @@ -194,7 +194,7 @@ class Connection: if code != 151 or code is None: break - resultword, resultdb = re.search('^"(.+)" (\S+)', text).groups() + resultword, resultdb = re.search(r'^"(.+)" (\S+)', text).groups() defstr = self.get100block() retval.append(Definition(self, self.getdbobj(resultdb), resultword, defstr)) diff --git a/plugins/Fediverse/utils.py b/plugins/Fediverse/utils.py index c55b1c42a..fda125d40 100644 --- a/plugins/Fediverse/utils.py +++ b/plugins/Fediverse/utils.py @@ -33,11 +33,11 @@ import datetime # Credits for the regexp and function: https://stackoverflow.com/a/2765366/539465 _XSD_DURATION_RE = re.compile( - "(?P<sign>-?)P" - "(?:(?P<years>\d+)Y)?" - "(?:(?P<months>\d+)M)?" - "(?:(?P<days>\d+)D)?" - "(?:T(?:(?P<hours>\d+)H)?(?:(?P<minutes>\d+)M)?(?:(?P<seconds>\d+)S)?)?" + r"(?P<sign>-?)P" + r"(?:(?P<years>\d+)Y)?" + r"(?:(?P<months>\d+)M)?" + r"(?:(?P<days>\d+)D)?" + r"(?:T(?:(?P<hours>\d+)H)?(?:(?P<minutes>\d+)M)?(?:(?P<seconds>\d+)S)?)?" ) diff --git a/plugins/Math/local/convertcore.py b/plugins/Math/local/convertcore.py index 42948c791..53003a4fd 100644 --- a/plugins/Math/local/convertcore.py +++ b/plugins/Math/local/convertcore.py @@ -849,7 +849,7 @@ class UnitGroup: def updateCurrentUnit(self, text, cursorPos): "Set current unit number" - self.currentNum = len(re.findall('[\*/]', text[:cursorPos])) + self.currentNum = len(re.findall(r'[\*/]', text[:cursorPos])) def currentUnit(self): "Return current unit if its a full match, o/w None" @@ -925,7 +925,7 @@ class UnitGroup: def parseGroup(self, text): "Return list of units from text string" unitList = [] - parts = [part.strip() for part in re.split('([\*/])', text)] + parts = [part.strip() for part in re.split(r'([\*/])', text)] numerator = 1 while parts: unit = self.parseUnit(parts.pop(0)) @@ -1180,7 +1180,7 @@ class Unit: self.equiv = unitList[0].strip() if self.equiv[0] == '[': # used only for non-linear units try: - self.equiv, self.fromEqn = re.match('\[(.*?)\](.*)', \ + self.equiv, self.fromEqn = re.match(r'\[(.*?)\](.*)', \ self.equiv).groups() if ';' in self.fromEqn: self.fromEqn, self.toEqn = self.fromEqn.split(';', 1) @@ -1190,7 +1190,7 @@ class Unit: raise UnitDataError('Bad equation for "%s"' % self.name) else: # split factor and equiv unit for linear parts = self.equiv.split(None, 1) - if len(parts) > 1 and re.search('[^\d\.eE\+\-\*/]', parts[0]) \ + if len(parts) > 1 and re.search(r'[^\d\.eE\+\-\*/]', parts[0]) \ == None: # only allowed digits and operators try: self.factor = float(eval(parts[0])) From 0ad61f57916822296396350890b30964780b4e64 Mon Sep 17 00:00:00 2001 From: Valentin Lorentz <progval+git@progval.net> Date: Sun, 5 May 2024 20:26:09 +0200 Subject: [PATCH 61/73] httpserver: Rewrite without the cgi module It is removed in Python 3.13 --- src/httpserver.py | 123 +++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 115 insertions(+), 8 deletions(-) diff --git a/src/httpserver.py b/src/httpserver.py index 6f81953fb..c2faf8ba8 100644 --- a/src/httpserver.py +++ b/src/httpserver.py @@ -1,5 +1,5 @@ ### -# Copyright (c) 2011-2021, Valentin Lorentz +# Copyright (c) 2011-2024, Valentin Lorentz # All rights reserved. # # Redistribution and use in source and binary forms, with or without @@ -32,8 +32,8 @@ An embedded and centralized HTTP server for Supybot's plugins. """ import os -import cgi import socket +import urllib.parse from threading import Thread import supybot.log as log @@ -164,6 +164,114 @@ def get_template(filename): with open(path + '.example', 'r') as fd: return fd.read() +class HttpHeader: + __slots__ = ('name', 'value') + + def __init__(self, name, value): + self.name = name + self.value = value + + def __repr__(self): + """Return printable representation.""" + return "HttpHeader(%r, %r)" % (self.name, self.value) + +class HttpHeaders: + """Copy of `cgi.FieldStorage + <https://github.com/python/cpython/blob/v3.12.3/Lib/cgi.py#L512-L594>` + before it was removed from the stdlib. + """ + __slots__ = ('list',) + def __init__(self, headers): + self.list = headers + + def __repr__(self): + return 'HttpHeaders(%r)' % self.list + + def __iter__(self): + return iter(self.keys()) + + def __getattr__(self, name): + if name != 'value': + raise AttributeError(name) + if self.file: + self.file.seek(0) + value = self.file.read() + self.file.seek(0) + elif self.list is not None: + value = self.list + else: + value = None + return value + + def __getitem__(self, key): + """Dictionary style indexing.""" + if self.list is None: + raise TypeError("not indexable") + found = [] + for item in self.list: + if item.name == key: found.append(item) + if not found: + raise KeyError(key) + if len(found) == 1: + return found[0] + else: + return found + + def getvalue(self, key, default=None): + """Dictionary style get() method, including 'value' lookup.""" + if key in self: + value = self[key] + if isinstance(value, list): + return [x.value for x in value] + else: + return value.value + else: + return default + + def getfirst(self, key, default=None): + """ Return the first value received.""" + if key in self: + value = self[key] + if isinstance(value, list): + return value[0].value + else: + return value.value + else: + return default + + def getlist(self, key): + """ Return list of received values.""" + if key in self: + value = self[key] + if isinstance(value, list): + return [x.value for x in value] + else: + return [value.value] + else: + return [] + + def keys(self): + """Dictionary style keys() method.""" + if self.list is None: + raise TypeError("not indexable") + return list(set(item.name for item in self.list)) + + def __contains__(self, key): + """Dictionary style __contains__ method.""" + if self.list is None: + raise TypeError("not indexable") + return any(item.name == key for item in self.list) + + def __len__(self): + """Dictionary style len(x) support.""" + return len(self.keys()) + + def __bool__(self): + if self.list is None: + raise TypeError("Cannot be converted to bool.") + return bool(self.list) + + class SupyHTTPRequestHandler(BaseHTTPRequestHandler): def do_X(self, callbackMethod, *args, **kwargs): if self.path == '/': @@ -199,12 +307,11 @@ class SupyHTTPRequestHandler(BaseHTTPRequestHandler): if 'Content-Type' not in self.headers: self.headers['Content-Type'] = 'application/x-www-form-urlencoded' if self.headers['Content-Type'] == 'application/x-www-form-urlencoded': - form = cgi.FieldStorage( - fp=self.rfile, - headers=self.headers, - environ={'REQUEST_METHOD':'POST', - 'CONTENT_TYPE':self.headers['Content-Type'], - }) + length = min(100000, int(self.headers.get('Content-Length', '100000'))) + qs = self.rfile.read(length).decode() + form = HttpHeaders([ + HttpHeader(k, v) for (k, v) in urllib.parse.parse_qsl(qs) + ]) else: content_length = int(self.headers.get('Content-Length', '0')) form = self.rfile.read(content_length) From e18332efde702bf37d5b29d056f7c94de8279a5d Mon Sep 17 00:00:00 2001 From: Valentin Lorentz <progval+git@progval.net> Date: Sun, 5 May 2024 20:36:58 +0200 Subject: [PATCH 62/73] Internet: Use socket directly instead of telnetlib We don't actually need telnetlib here; and it will be removed in Python 3.11 --- plugins/Internet/plugin.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/plugins/Internet/plugin.py b/plugins/Internet/plugin.py index d9993db1b..410e6d9a3 100644 --- a/plugins/Internet/plugin.py +++ b/plugins/Internet/plugin.py @@ -31,7 +31,6 @@ import time import socket -import telnetlib import supybot.conf as conf import supybot.utils as utils @@ -158,14 +157,14 @@ class Internet(callbacks.Plugin): if not status: status = 'unknown' try: - t = telnetlib.Telnet('whois.iana.org', 43) + sock = socket.create_connection(('whois.iana.org', 43)) except socket.error as e: irc.error(str(e)) return - t.write(b'registrar ') - t.write(registrar.split('(')[0].strip().encode('ascii')) - t.write(b'\n') - s = t.read_all() + sock.sendall(b'registrar ') + sock.sendall(registrar.split('(')[0].strip().encode('ascii')) + sock.sendall(b'\n') + s = sock.recv(100000) url = '' for line in s.splitlines(): line = line.decode('ascii').strip() From 9ae76904844866cb21e278474f3c6522f76e18ca Mon Sep 17 00:00:00 2001 From: Valentin Lorentz <progval+git@progval.net> Date: Sun, 5 May 2024 20:41:57 +0200 Subject: [PATCH 63/73] Unix: Disable 'crypt' command on Python >= 3.13 The module is not available anymore --- plugins/Internet/test.py | 1 - plugins/Unix/plugin.py | 44 +++++++++++++++++++++++----------------- plugins/Unix/test.py | 10 +++++++-- 3 files changed, 33 insertions(+), 22 deletions(-) diff --git a/plugins/Internet/test.py b/plugins/Internet/test.py index 019cc80f8..4d5f5cb32 100644 --- a/plugins/Internet/test.py +++ b/plugins/Internet/test.py @@ -40,7 +40,6 @@ class InternetTestCase(PluginTestCase): 'Host not found.') def testWhois(self): - self.assertNotError('internet whois ohio-state.edu') self.assertNotError('internet whois microsoft.com') self.assertNotError('internet whois inria.fr') self.assertNotError('internet whois slime.com.au') diff --git a/plugins/Unix/plugin.py b/plugins/Unix/plugin.py index b55338035..b559ec060 100644 --- a/plugins/Unix/plugin.py +++ b/plugins/Unix/plugin.py @@ -33,7 +33,6 @@ import os import re import pwd import sys -import crypt import errno import random import select @@ -41,6 +40,12 @@ import struct import subprocess import shlex +try: + import crypt +except ImportError: + # Python >= 3.13 + crypt = None + import supybot.conf as conf import supybot.utils as utils from supybot.commands import * @@ -119,25 +124,26 @@ class Unix(callbacks.Plugin): irc.reply(format('%i', os.getpid()), private=True) pid = wrap(pid, [('checkCapability', 'owner')]) - _cryptre = re.compile(b'[./0-9A-Za-z]') - @internationalizeDocstring - def crypt(self, irc, msg, args, password, salt): - """<password> [<salt>] + if crypt is not None: # Python < 3.13 + _cryptre = re.compile(b'[./0-9A-Za-z]') + @internationalizeDocstring + def crypt(self, irc, msg, args, password, salt): + """<password> [<salt>] - Returns the resulting of doing a crypt() on <password>. If <salt> is - not given, uses a random salt. If running on a glibc2 system, - prepending '$1$' to your salt will cause crypt to return an MD5sum - based crypt rather than the standard DES based crypt. - """ - def makeSalt(): - s = b'\x00' - while self._cryptre.sub(b'', s) != b'': - s = struct.pack('<h', random.randrange(-(2**15), 2**15)) - return s - if not salt: - salt = makeSalt().decode() - irc.reply(crypt.crypt(password, salt)) - crypt = wrap(crypt, ['something', additional('something')]) + Returns the resulting of doing a crypt() on <password>. If <salt> is + not given, uses a random salt. If running on a glibc2 system, + prepending '$1$' to your salt will cause crypt to return an MD5sum + based crypt rather than the standard DES based crypt. + """ + def makeSalt(): + s = b'\x00' + while self._cryptre.sub(b'', s) != b'': + s = struct.pack('<h', random.randrange(-(2**15), 2**15)) + return s + if not salt: + salt = makeSalt().decode() + irc.reply(crypt.crypt(password, salt)) + crypt = wrap(crypt, ['something', additional('something')]) @internationalizeDocstring def spell(self, irc, msg, args, word): diff --git a/plugins/Unix/test.py b/plugins/Unix/test.py index 59a096bab..ccba7ea07 100644 --- a/plugins/Unix/test.py +++ b/plugins/Unix/test.py @@ -31,6 +31,11 @@ import os import socket +try: + import crypt +except ImportError: + crypt = None + from supybot.test import * try: @@ -106,8 +111,9 @@ if os.name == 'posix': def testProgstats(self): self.assertNotError('progstats') - def testCrypt(self): - self.assertNotError('crypt jemfinch') + if crypt is not None: # Python < 3.13 + def testCrypt(self): + self.assertNotError('crypt jemfinch') @skipUnlessFortune def testFortune(self): From b1ba8ecb2a951580da6b5ea8f5a28caa1cfcbca1 Mon Sep 17 00:00:00 2001 From: Valentin Lorentz <progval+git@progval.net> Date: Sun, 5 May 2024 19:04:12 +0200 Subject: [PATCH 64/73] ci: Test on Python 3.13 alpha --- .github/workflows/test.yml | 8 ++++++-- setup.py | 6 ++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9bbc8dd0e..97d73356f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,7 +15,11 @@ jobs: strategy: matrix: include: - - python-version: "3.12.0-alpha.7" + - python-version: "3.13.0-alpha.6" + with-opt-deps: false # https://github.com/pyca/cryptography/issues/10806 + runs-on: ubuntu-22.04 + + - python-version: "3.12.0" with-opt-deps: true runs-on: ubuntu-22.04 @@ -67,7 +71,7 @@ jobs: - name: Upgrade pip run: | - python3 -m pip install --upgrade pip + python3 -m pip install --upgrade pip setuptools - name: Install optional dependencies if: ${{ matrix.with-opt-deps }} diff --git a/setup.py b/setup.py index ea655aae9..8f3b8f3c7 100644 --- a/setup.py +++ b/setup.py @@ -49,10 +49,12 @@ except ImportError: install. This package is pretty standard, and often installed alongside Python, but it is missing on your system. Try installing it with your package manager, it is usually called - 'python3-setuptools'. If that does not work, try installing python3-pip + 'python3-setuptools'; or with '%s -m pip install setuptools'. + If that does not work, try installing python3-pip instead, either with your package manager or by following these instructions: https://pip.pypa.io/en/stable/installation/ (replace - 'python' with 'python3' in all the commands)""") + 'python' with 'python3' in all the commands)""" + % sys.executable) sys.stderr.write(os.linesep*2) sys.stderr.write(textwrap.fill(s)) sys.stderr.write(os.linesep*2) From 4898926f2017ccee746b41110ff5ca08dd7cf923 Mon Sep 17 00:00:00 2001 From: Valentin Lorentz <progval+git@progval.net> Date: Sun, 12 May 2024 16:34:36 +0200 Subject: [PATCH 65/73] RSS: Fix error when re-creating a feed with a different name Closes GH-1547 --- plugins/RSS/plugin.py | 2 +- plugins/RSS/test.py | 23 ++++++++++++++++++++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/plugins/RSS/plugin.py b/plugins/RSS/plugin.py index 7b68f7e35..29e0edd0e 100644 --- a/plugins/RSS/plugin.py +++ b/plugins/RSS/plugin.py @@ -280,7 +280,7 @@ class RSS(callbacks.Plugin): raise callbacks.Error(s) if url: feed = self.feeds.get(url) - if feed and feed.name != feed.url: + if feed and feed.name != feed.url and feed.name in self.feed_names: s = format(_('I already have a feed with that URL named %s.'), feed.name) raise callbacks.Error(s) diff --git a/plugins/RSS/test.py b/plugins/RSS/test.py index 71c9d7dc5..494b1e9b0 100644 --- a/plugins/RSS/test.py +++ b/plugins/RSS/test.py @@ -84,7 +84,7 @@ def mock_urllib(f): url = 'http://www.advogato.org/rss/articles.xml' class RSSTestCase(ChannelPluginTestCase): - plugins = ('RSS','Plugin') + plugins = ('RSS', 'Plugin') timeout = 1 @@ -121,6 +121,27 @@ class RSSTestCase(ChannelPluginTestCase): self.assertEqual(self.irc.getCallback('RSS').feed_names, {}) self.assertTrue(self.irc.getCallback('RSS').get_feed('http://xkcd.com/rss.xml')) + @mock_urllib + def testChangeUrl(self, mock): + try: + self.assertNotError('rss add xkcd http://xkcd.com/rss.xml') + self.assertNotError('rss remove xkcd') + self.assertNotError('rss add xkcd https://xkcd.com/rss.xml') + self.assertRegexp('help xkcd', 'https://') + finally: + self._feedMsg('rss remove xkcd') + + @mock_urllib + def testChangeName(self, mock): + try: + self.assertNotError('rss add xkcd http://xkcd.com/rss.xml') + self.assertNotError('rss remove xkcd') + self.assertNotError('rss add xkcd2 http://xkcd.com/rss.xml') + self.assertRegexp('help xkcd2', 'http://xkcd.com') + finally: + self._feedMsg('rss remove xkcd') + self._feedMsg('rss remove xkcd2') + @mock_urllib def testInitialAnnounceNewest(self, mock): mock._data = xkcd_new From 5b2b38ab37fcc891651f43f63c0c13755cd8694f Mon Sep 17 00:00:00 2001 From: Valentin Lorentz <progval+git@progval.net> Date: Tue, 21 May 2024 21:19:14 +0200 Subject: [PATCH 66/73] Add per-network 'vhost' and 'vhostv6' config variables --- src/conf.py | 9 +++++++++ src/drivers/Socket.py | 6 ++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/conf.py b/src/conf.py index c29f04c87..0544613ab 100644 --- a/src/conf.py +++ b/src/conf.py @@ -419,6 +419,15 @@ def registerNetwork(name, password='', ssl=True, sasl_username='', registry.String('', _("""Determines what user modes the bot will request from the server when it first connects. If empty, defaults to supybot.protocols.irc.umodes"""))) + registerGlobalValue(network, 'vhost', + registry.String('', _("""Determines what vhost the bot will bind to before + connecting a server (IRC, HTTP, ...) via IPv4. If empty, defaults to + supybot.protocols.irc.vhost"""))) + registerGlobalValue(network, 'vhostv6', + registry.String('', _("""Determines what vhost the bot will bind to before + connecting a server (IRC, HTTP, ...) via IPv6. If empty, defaults to + supybot.protocols.irc.vhostv6"""))) + sasl = registerGroup(network, 'sasl') registerGlobalValue(sasl, 'username', registry.String(sasl_username, _("""Determines what SASL username will be used on %s. This should diff --git a/src/drivers/Socket.py b/src/drivers/Socket.py index 23d242153..7b7f1df98 100644 --- a/src/drivers/Socket.py +++ b/src/drivers/Socket.py @@ -312,8 +312,10 @@ class SocketDriver(drivers.IrcDriver, drivers.ServersMixin): address, port=self.currentServer.port, socks_proxy=socks_proxy, - vhost=conf.supybot.protocols.irc.vhost(), - vhostv6=conf.supybot.protocols.irc.vhostv6(), + vhost=self.networkGroup.get('vhost')() + or conf.supybot.protocols.irc.vhost(), + vhostv6=self.networkGroup.get('vhostv6')() + or conf.supybot.protocols.irc.vhostv6(), ) except socket.error as e: drivers.log.connectError(self.currentServer, e) From dcd95d3a77d52d053f8b0ba56bda3b34cb82aa38 Mon Sep 17 00:00:00 2001 From: Valentin Lorentz <progval+git@progval.net> Date: Wed, 29 May 2024 07:26:34 +0200 Subject: [PATCH 67/73] DDG: Fix regexp escape in test 9bcb21389adc62dd099afd0665990aa0128a7ad3 added it to the wrong string --- plugins/DDG/test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/DDG/test.py b/plugins/DDG/test.py index a3b96a407..b5f7961ae 100644 --- a/plugins/DDG/test.py +++ b/plugins/DDG/test.py @@ -39,7 +39,7 @@ class DDGTestCase(PluginTestCase): def testSearch(self): self.assertRegexp( - r'ddg search wikipedia', 'Wikipedia.*? - .*?https?\:\/\/') + 'ddg search wikipedia', r'Wikipedia.*? - .*?https?\:\/\/') self.assertRegexp( 'ddg search en.wikipedia.org', 'Wikipedia, the free encyclopedia\x02 - ' From 9a4dca80544cd8a64f963e775e0207b4fd9eb61d Mon Sep 17 00:00:00 2001 From: Valentin Lorentz <progval+git@progval.net> Date: Wed, 29 May 2024 21:49:23 +0200 Subject: [PATCH 68/73] Misc: update version fetching to the new branches master is now used for main development, so PyPI has to be used instead to get the latest release --- plugins/Misc/plugin.py | 40 +++++++++++++++++++++++++--------------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/plugins/Misc/plugin.py b/plugins/Misc/plugin.py index 73f32e00d..8fb1d80be 100644 --- a/plugins/Misc/plugin.py +++ b/plugins/Misc/plugin.py @@ -342,21 +342,31 @@ class Misc(callbacks.Plugin): Returns the version of the current bot. """ try: - newestUrl = 'https://api.github.com/repos/progval/Limnoria/' + \ - 'commits/%s' - versions = {} - for branch in ('master', 'testing'): - data = json.loads(utils.web.getUrl(newestUrl % branch) - .decode('utf8')) - version = data['commit']['committer']['date'] - # Strip the last 'Z': - version = version.rsplit('T', 1)[0].replace('-', '.') - if minisix.PY2 and isinstance(version, unicode): - version = version.encode('utf8') - versions[branch] = version - newest = _('The newest versions available online are %s.') % \ - ', '.join([_('%s (in %s)') % (y,x) - for x,y in versions.items()]) + versions = [] + + # fetch from PyPI + data = json.loads(utils.web.getUrl( + 'https://pypi.org/pypi/limnoria/json' + ).decode('utf8')) + release_version = data['info']['version'] + # zero-left-pad months and days + release_version = re.sub( + r'\.([0-9])\b', lambda m: '.0' + m.group(1), release_version + ) + + # fetch from Git + data = json.loads(utils.web.getUrl( + 'https://api.github.com/repos/progval/Limnoria/' + 'commits/master' + ).decode('utf8')) + git_version = data['commit']['committer']['date'] + # Strip the last 'Z': + git_version = git_version.rsplit('T', 1)[0].replace('-', '.') + + newest = _( + 'The newest version available online is %(release_version)s, ' + 'or %(git_version)s in Git' + ) % {'release_version': release_version, 'git_version': git_version} except utils.web.Error as e: self.log.info('Couldn\'t get website version: %s', e) newest = _('I couldn\'t fetch the newest version ' From f5302f0bfc6a1fcc0a2162505c4f865e68975401 Mon Sep 17 00:00:00 2001 From: Valentin Lorentz <progval+git@progval.net> Date: Wed, 29 May 2024 07:37:39 +0200 Subject: [PATCH 69/73] safeEval: Fix support for Python 3.14 --- src/utils/gen.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/utils/gen.py b/src/utils/gen.py index 4e1e1b253..d8c58d83b 100644 --- a/src/utils/gen.py +++ b/src/utils/gen.py @@ -167,7 +167,9 @@ def saltHash(password, salt=None, hash='sha'): hasher = crypt.md5 return '|'.join([salt, hasher((salt + password).encode('utf8')).hexdigest()]) -_astStr2 = ast.Str if minisix.PY2 else ast.Bytes +_OLD_AST = sys.version_info[0:2] < (3, 8) +"""Whether the AST classes predate the python 3.8 API changes""" + def safeEval(s, namespace=None): """Evaluates s, safely. Useful for turning strings into tuples/lists/etc. without unsafely using eval().""" @@ -175,12 +177,11 @@ def safeEval(s, namespace=None): node = ast.parse(s, mode='eval').body except SyntaxError as e: raise ValueError('Invalid string: %s.' % e) + def checkNode(node): if node.__class__ is ast.Expr: node = node.value - if node.__class__ in (ast.Num, - ast.Str, - _astStr2): + if not _OLD_AST and node.__class__ is ast.Constant: return True elif node.__class__ in (ast.List, ast.Tuple): @@ -196,10 +197,12 @@ def safeEval(s, namespace=None): return True else: return False - elif node.__class__ is ast.NameConstant: + elif _OLD_AST and node.__class__ in (ast.Num, ast.Str, ast.Bytes): + # ast.Num, ast.Str, ast.Bytes are deprecated since Python 3.8 + # and removed since Python 3.14; replaced by ast.Constant. return True - elif sys.version_info[0:2] >= (3, 8) and \ - node.__class__ is ast.Constant: + elif _OLD_AST and node.__class__ is ast.NameConstant: + # ditto return True else: return False From bd4a85ba08e9841c125f28ae393761a589b58682 Mon Sep 17 00:00:00 2001 From: ssdaniel24 <107036969+ssdaniel24@users.noreply.github.com> Date: Wed, 12 Jun 2024 23:44:48 +0300 Subject: [PATCH 70/73] Aka, Anonymous, PluginDownloader, Seen, Todo: Add russian locale --- plugins/Aka/locales/ru.po | 415 +++++++++++++++++++++++++ plugins/Anonymous/locales/ru.po | 249 +++++++++++++++ plugins/PluginDownloader/locales/ru.po | 205 ++++++++++++ plugins/Seen/locales/ru.po | 201 ++++++++++++ plugins/Todo/locales/ru.po | 176 +++++++++++ src/i18n.py | 2 +- 6 files changed, 1247 insertions(+), 1 deletion(-) create mode 100644 plugins/Aka/locales/ru.po create mode 100644 plugins/Anonymous/locales/ru.po create mode 100644 plugins/PluginDownloader/locales/ru.po create mode 100644 plugins/Seen/locales/ru.po create mode 100644 plugins/Todo/locales/ru.po diff --git a/plugins/Aka/locales/ru.po b/plugins/Aka/locales/ru.po new file mode 100644 index 000000000..ee86bc44c --- /dev/null +++ b/plugins/Aka/locales/ru.po @@ -0,0 +1,415 @@ +# Aka plugin for Limnoria +# Copyright (C) 2024 Limnoria +# ssdaniel24 <bo7oaonteg2m__at__mailDOTru>, 2024. +msgid "" +msgstr "" +"Project-Id-Version: \n" +"POT-Creation-Date: 2022-02-06 00:12+0100\n" +"PO-Revision-Date: 2024-06-12 21:50+0300\n" +"Last-Translator: ssdaniel24 <bo7oaonteg2m__at__mailDOTru>\n" +"Language-Team: \n" +"Language: ru\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: pygettext.py 1.5\n" +"X-Generator: Poedit 3.4.2\n" + +#: config.py:55 +msgid "" +"The maximum number of words allowed in a\n" +" command name. Setting this to an high value may slow down your bot\n" +" on long commands." +msgstr "" +"Максимальное количество слов, которые могут быть в имени команды.\n" +"Выставление большого значения может замедлить работу вашего бота на длинных " +"командах." + +#: config.py:61 +msgid "" +"Determines whether the Akas will be\n" +" browsable through the HTTP server." +msgstr "Определяет, где псевдонимы могут быть просмотрены через HTTP-сервер." + +#: plugin.py:141 plugin.py:274 plugin.py:732 +msgid "This Aka already exists." +msgstr "Этот псевдоним уже существует." + +#: plugin.py:170 plugin.py:182 plugin.py:196 plugin.py:301 plugin.py:318 +#: plugin.py:335 plugin.py:912 +msgid "This Aka does not exist." +msgstr "Этот псевдоним не существует." + +#: plugin.py:303 +msgid "This Aka is already locked." +msgstr "Этот псевдоним уже заблокирован." + +#: plugin.py:320 +msgid "This Aka is already unlocked." +msgstr "Этот псевдоним уже разблокирован." + +#: plugin.py:465 +msgid "By %s at %s" +msgstr "%s %s" + +#: plugin.py:501 +msgid "" +"\n" +" This plugin allows users to define aliases to commands and " +"combinations\n" +" of commands (via nesting).\n" +"\n" +" Importing from Alias\n" +" ^^^^^^^^^^^^^^^^^^^^\n" +"\n" +" Add an aka, Alias, which eases the transitioning to Aka from Alias.\n" +"\n" +" First we will load Alias and Aka::\n" +"\n" +" <jamessan> @load Alias\n" +" <bot> jamessan: The operation succeeded.\n" +" <jamessan> @load Aka\n" +" <bot> jamessan: The operation succeeded.\n" +"\n" +" Then we import the Alias database to Aka in case it exists and unload\n" +" Alias::\n" +"\n" +" <jamessan> @importaliasdatabase\n" +" <bot> jamessan: The operation succeeded.\n" +" <jamessan> @unload Alias\n" +" <bot> jamessan: The operation succeeded.\n" +"\n" +" And now we will finally add the Aka ``alias`` itself::\n" +"\n" +" <jamessan> @aka add \"alias\" \"aka $1 $*\"\n" +" <bot> jamessan: The operation succeeded.\n" +"\n" +" Now you can use Aka as you used Alias before.\n" +"\n" +" Trout\n" +" ^^^^^\n" +"\n" +" Add an aka, trout, which expects a word as an argument::\n" +"\n" +" <jamessan> @aka add trout \"reply action slaps $1 with a large " +"trout\"\n" +" <bot> jamessan: The operation succeeded.\n" +" <jamessan> @trout me\n" +" * bot slaps me with a large trout\n" +"\n" +" This ``trout`` aka requires the plugin ``Reply`` to be loaded since it\n" +" provides the ``action`` command.\n" +"\n" +" LastFM\n" +" ^^^^^^\n" +"\n" +" Add an aka, ``lastfm``, which expects a last.fm username and replies " +"with\n" +" their most recently played item::\n" +"\n" +" @aka add lastfm \"rss [format concat http://ws.audioscrobbler." +"com/1.0/user/ [format concat [web urlquote $1] /recenttracks.rss]]\"\n" +"\n" +" This ``lastfm`` aka requires the following plugins to be loaded: " +"``RSS``,\n" +" ``Format`` and ``Web``.\n" +"\n" +" ``RSS`` provides ``rss``, ``Format`` provides ``concat`` and ``Web`` " +"provides\n" +" ``urlquote``.\n" +"\n" +" Note that if the nested commands being aliased hadn't been quoted, " +"then\n" +" those commands would have been run immediately, and ``@lastfm`` would " +"always\n" +" reply with the same information, the result of those commands.\n" +" " +msgstr "" +"\n" +"Этот плагин позволяет пользователям создавать собственные псевдонимы к " +"командам и комбинациями команд (вложенные команды).\n" +"\n" +"Импорт из Alias\n" +"^^^^^^^^^^^^^^^\n" +"\n" +"Переходим к использованию плагина Aka от плагина Alias.\n" +"\n" +"Во-первых, загрузим Alias и Aka:\n" +"\n" +" <jamessan> @load Alias\n" +" <bot> jamessan: The operation succeeded.\n" +" <jamessan> @load Aka\n" +" <bot> jamessan: The operation succeeded.\n" +"\n" +"После этого импортируем базу данных плагина Alias в Aka, если та " +"существует, и отключим плагин Alias::\n" +"\n" +"<jamessan> @importaliasdatabase\n" +"<bot> jamessan: The operation succeeded.\n" +"<jamessan> @unload Alias\n" +"<bot> jamessan: The operation succeeded.\n" +"\n" +"И наконец добавим псевдоним команды из плагина Alias, чтобы оставить " +"обратную совместимость:\n" +"\n" +"<jamessan> @aka add \"alias\" \"aka $1 $*\"\n" +"<bot> jamessan: The operation succeeded.\n" +"\n" +"Теперь вы можете использовать плагин Aka как вы использовали до этого " +"плагин Alias.\n" +"\n" +"Дать леща\n" +"^^^^^^^^^\n" +"\n" +"Добавляем псевдоним (чтобы дать кому-то леща), который принимает одно слово " +"как аргумент::\n" +"\n" +"<jamessan> @aka add trout \"reply action с размаху даёт леща $1\"\n" +"<bot> jamessan: The operation succeeded.\n" +"<jamessan> @trout мне\n" +"* bot с размаху даёт леща мне\n" +"\n" +"LastFM\n" +"^^^^^^\n" +"\n" +"Добавляем псевдоним - ``lastfm``, принимает аргументом имя пользователя и " +"отвечает с последней песней, которая у него играла.\n" +"\n" +"@aka add lastfm \"rss [format concat http://ws.audioscrobbler.com/1.0/user/ " +"[format concat [web urlquote $1] /recenttracks.rss]]\"\n" +"\n" +"Этот псевдоним ``lastfm`` требует для работы следующие плагины: ``RSS``, " +"``Format`` и ``Web``.\n" +"\n" +"``RSS`` предоставляет ``rss``, ``Format`` предоставляет ``concat`` и " +"``Web`` предоставляет ``urlquote``.\n" +"\n" +"Обратите внимание, что если бы вложенные команды не были бы обозначены " +"кавычками, то они были бы запущены немедленно, и псевдоним ``@lastfm`` " +"показывал бы всегда одну и ту же информацию - результат выполнения этих " +"команд." + +#: plugin.py:699 +msgid "You've attempted more nesting than is currently allowed on this bot." +msgstr "Вы попробовали больше вложений, чем сейчас это разрешено в боте." + +#: plugin.py:703 +msgid " at least" +msgstr " хотя бы" + +#: plugin.py:712 +msgid "Locked by %s at %s" +msgstr "Заблокировано %s %s" + +#: plugin.py:717 +msgid "" +"<a global alias,%s %n>\n" +"\n" +"Alias for %q.%s" +msgstr "" +"<глобальный псевдоним, %s %n>\n" +"\n" +"Псевдоним для %q.%s" + +#: plugin.py:718 plugin.py:722 +msgid "argument" +msgstr "аргумент" + +#: plugin.py:721 +msgid "" +"<an alias on %s,%s %n>\n" +"\n" +"Alias for %q.%s" +msgstr "" +"<псевдоним %s,%s %n>\n" +"\n" +"Псевдоним для %q.%s" + +#: plugin.py:729 +msgid "You can't overwrite commands in this plugin." +msgstr "Вы не можете перезаписывать команды в этом плагине." + +#: plugin.py:734 +msgid "This Aka has too many spaces in its name." +msgstr "Этот псевдоним содержит слишком много пробелов в имени." + +#: plugin.py:739 +msgid "Can't mix $* and optional args (@1, etc.)" +msgstr "Нельзя перемешивать $* и необязательные аргументы (@1 и др.)" + +#: plugin.py:746 +msgid "This Aka is locked." +msgstr "Этот псевдоним заблокирован." + +#: plugin.py:750 +msgid "" +"[--channel <#channel>] <name> <command>\n" +"\n" +" Defines an alias <name> that executes <command>. The <command>\n" +" should be in the standard \"command argument [nestedcommand " +"argument]\"\n" +" arguments to the alias; they'll be filled with the first, second, " +"etc.\n" +" arguments. $1, $2, etc. can be used for required arguments. @1, " +"@2,\n" +" etc. can be used for optional arguments. $* simply means \"all\n" +" arguments that have not replaced $1, $2, etc.\", ie. it will also\n" +" include optional arguments.\n" +" " +msgstr "" +"[--channel <#канал>] <название> <команда>\n" +"\n" +"Определяет псевдоним c <названием>, который запускает <команду>. <команда> " +"должна быть вида: \"<команда> <аргумент> [<вложенная команда> <аргумент>] " +"<аргументы к псевдониму>\". Аргументы к псевдониму по порядку: $1, $2 и тд. " +"используются для обязательных аргументов; @1, @2 и т.д. используются для " +"необязательных аргументов; $* означает \"все аргументы, которые не были " +"заменены с помощью $1, $2 и т.д.\", это включает в себя и необязательные " +"аргументы тоже." + +#: plugin.py:764 plugin.py:796 plugin.py:827 plugin.py:859 plugin.py:882 +#: plugin.py:905 plugin.py:951 plugin.py:994 +msgid "%r is not a valid channel." +msgstr "%r не является допустимым каналом." + +#: plugin.py:782 +msgid "" +"[--channel <#channel>] <name> <command>\n" +"\n" +" Overwrites an existing alias <name> to execute <command> instead. " +"The\n" +" <command> should be in the standard \"command argument " +"[nestedcommand\n" +" argument]\" arguments to the alias; they'll be filled with the " +"first,\n" +" second, etc. arguments. $1, $2, etc. can be used for required\n" +" arguments. @1, @2, etc. can be used for optional arguments. $* " +"simply\n" +" means \"all arguments that have not replaced $1, $2, etc.\", ie. it " +"will\n" +" also include optional arguments.\n" +" " +msgstr "" +"[--channel <#канал>] <название> <команда>\n" +"\n" +"Перезаписывает существующий псевдоним с <названием>, чтобы он запускал " +"вместо предыдущей данную <команду>. <команда> должна быть вида: \"<команда> " +"<аргумент> [<вложенная команда> <аргумент>] <аргументы к псевдониму>\". " +"Аргументы к псевдониму по порядку: $1, $2 и тд. используются для " +"обязательных аргументов; @1, @2 и т.д. используются для необязательных " +"аргументов; $* означает \"все аргументы, которые не были заменены с помощью " +"$1, $2 и т.д.\", это включает в себя и необязательные аргументы тоже." + +#: plugin.py:819 +msgid "" +"[--channel <#channel>] <name>\n" +"\n" +" Removes the given alias, if unlocked.\n" +" " +msgstr "" +"[--channel <#канал>] <название>\n" +"\n" +"Удаляет данный псевдоним, если тот не заблокирован." + +#: plugin.py:841 +msgid "" +"Check if the user has any of the required capabilities to manage\n" +" the regexp database." +msgstr "" +"Проверяет, есть ли у пользователя необходимые привилегии для управления " +"базой данных регулярных выражений." + +#: plugin.py:851 +msgid "" +"[--channel <#channel>] <alias>\n" +"\n" +" Locks an alias so that no one else can change it.\n" +" " +msgstr "" +"[--channel <#канал>] <псевдоним>\n" +"\n" +"Блокирует данный псевдоним, чтобы никто не мог его изменить." + +#: plugin.py:874 +msgid "" +"[--channel <#channel>] <alias>\n" +"\n" +" Unlocks an alias so that people can define new aliases over it.\n" +" " +msgstr "" +"[--channel <#канал>] <псевдоним>\n" +"\n" +"Разблокирует данный псевдоним, чтобы люди могли определять новые псевдонимы " +"поверх этого." + +#: plugin.py:897 +msgid "" +"[--channel <#channel>] <alias>\n" +"\n" +" This command shows the content of an Aka.\n" +" " +msgstr "" +"[--channel <#канал>] <псевдоним>\n" +"\n" +"Эта команда показывает содержание данного псевдонима." + +#: plugin.py:917 +msgid "" +"takes no arguments\n" +"\n" +" Imports the Alias database into Aka's, and clean the former." +msgstr "" +"не принимает аргументов\n" +"\n" +"Импортирует базу данных Alias в Aka, и очищает первую." + +#: plugin.py:922 +msgid "Alias plugin is not loaded." +msgstr "Плагин Alias не загружен." + +#: plugin.py:933 +msgid "Error occured when importing the %n: %L" +msgstr "Произошла ошибка при импорте %n: %L" + +#: plugin.py:941 +msgid "" +"[--channel <#channel>] [--keys] [--unlocked|--locked]\n" +"\n" +" Lists all Akas defined for <channel>. If <channel> is not " +"specified,\n" +" lists all global Akas. If --keys is given, lists only the Aka " +"names\n" +" and not their commands." +msgstr "" +"[--channel <#канал>] [--keys] [--unlocked|--locked]\n" +"\n" +"Показывает все псевдонимы, определённые для данного <канала>. Если <канал> " +"не дан в аргументах, то показывает все глобальные псевдонимы. Если дан --" +"keys, то показывает только названия псевдонимов без соответствующих им " +"команд." + +#: plugin.py:960 +msgid "--locked and --unlocked are incompatible options." +msgstr "--locked и --unlocked – несовместимые параметры." + +#: plugin.py:980 +msgid "No Akas found." +msgstr "Не найдено ни одного псевдонима." + +#: plugin.py:985 +msgid "" +"[--channel <#channel>] <query>\n" +"\n" +" Searches Akas defined for <channel>. If <channel> is not " +"specified,\n" +" searches all global Akas." +msgstr "" +"[--channel <#канал>] <запрос>\n" +"\n" +"Производит поиск среди псевдонимов, определённых в данном <канале>, по " +"данному <запросу>. Если <канал> не дан в аргументах, то поиск производится " +"по всем глобальным псевдонимам." + +#: plugin.py:1004 +msgid "No matching Akas were found." +msgstr "Не найдено ни одного совпадающего псевдонима." diff --git a/plugins/Anonymous/locales/ru.po b/plugins/Anonymous/locales/ru.po new file mode 100644 index 000000000..b55ca654e --- /dev/null +++ b/plugins/Anonymous/locales/ru.po @@ -0,0 +1,249 @@ +# Anonymous plugin for Limnoria +# Copyright (C) 2024 Limnoria +# ssdaniel24 <bo7oaonteg2m__at__mailDOTru>, 2024. +# +msgid "" +msgstr "" +"Project-Id-Version: \n" +"POT-Creation-Date: 2022-02-06 00:12+0100\n" +"PO-Revision-Date: 2024-06-12 22:04+0300\n" +"Last-Translator: ssdaniel24 <bo7oaonteg2m__at__mailDOTru>\n" +"Language-Team: \n" +"Language: ru\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: pygettext.py 1.5\n" +"X-Generator: Poedit 3.4.2\n" + +#: config.py:50 +msgid "" +"Determines whether\n" +" the bot should require people trying to use this plugin to be in the\n" +" channel they wish to anonymously send to." +msgstr "" +"Определяет, должен ли бот требовать, чтобы люди, использующие этот плагин, " +"находились в канале, куда они хотят написать анонимно." + +#: config.py:54 +msgid "" +"Determines whether the bot should require\n" +" people trying to use this plugin to be registered." +msgstr "" +"Определяет, должен ли бот требовать регистрации у людей, которые " +"используют этот плагин." + +#: config.py:57 +msgid "" +"Determines what capability (if any) the bot should\n" +" require people trying to use this plugin to have." +msgstr "" +"Определяет какие привилегии (если таковые имеются) должен проверять бот у " +"людей, которые используют этот плагин." + +#: config.py:60 +msgid "" +"Determines whether the bot will allow the\n" +" \"tell\" command to be used. If true, the bot will allow the \"tell\"\n" +" command to send private messages to other users." +msgstr "" +"Определяет разрешение на использование команды \"tell\". Если значение " +"установлено в true, то бот позволит использовать команду \"tell\" для " +"отправки личных сообщений другим пользователям." + +#: plugin.py:45 +msgid "" +"\n" +" This plugin allows users to act through the bot anonymously. The " +"'do'\n" +" command has the bot perform an anonymous action in a given channel, " +"and\n" +" the 'say' command allows other people to speak through the bot. " +"Since\n" +" this can be fairly well abused, you might want to set\n" +" supybot.plugins.Anonymous.requireCapability so only users with that\n" +" capability can use this plugin. For extra security, you can require " +"that\n" +" the user be *in* the channel they are trying to address anonymously " +"with\n" +" supybot.plugins.Anonymous.requirePresenceInChannel, or you can " +"require\n" +" that the user be registered by setting\n" +" supybot.plugins.Anonymous.requireRegistration.\n" +"\n" +" Example: Proving that you are the owner\n" +" ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n" +"\n" +" When you ask for cloak/vhost for your bot, the network operators will\n" +" often ask you to prove that you own the bot. You can do this for " +"example\n" +" with the following method::\n" +"\n" +" @load Anonymous\n" +" @config plugins.anonymous.requirecapability owner\n" +" @config plugins.anonymous.allowprivatetarget True\n" +" @anonymous say <operator nick> Hi, my owner is <your nick> :)\n" +"\n" +" This\n" +" * Loads the plugin.\n" +" * Makes the plugin require that you are the owner\n" +"\n" +" * If anyone could send private messages as the bot, they could also\n" +" access network services.\n" +"\n" +" * Allows sending private messages\n" +" * Sends message ``Hi, my owner is <your nick> :)`` to ``operator " +"nick``.\n" +"\n" +" * Note that you won't see the messages that are sent to the bot.\n" +"\n" +" " +msgstr "" +"Этот плагин позволяет пользователям анонимно взаимодействовать через бота. " +"Команда 'do' позволяет выполнить некоторое анонимное действие через бота в " +"данном канале, и команда 'say' позволяет другим пользователям общаться " +"через бота. Этим плагином можно легко злоупотреблять, поэтому возможно вы " +"захотите настройку supybot.plugins.Anonymous.requireCapability, чтобы " +"только пользователи с данной привилегией могли могли использовать этот " +"плагин. Для повышенной безопасности, вы можете требовать, чтобы " +"пользователь был в канале, где они хотят взаимодействовать через бота " +"анонимно, с помощью настроки supybot.plugins.Anonymous." +"requirePresenceInChannel. Или вы можете требовать, чтобы пользователь был " +"зарегистрирован с помощью настройки supybot.plugins.Anonymous." +"requireRegistration.\n" +"\n" +"Пример: доказательство того, что вы являетесь владельцем\n" +"^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n" +"\n" +"Когда вы просите cloak или vhost для своего бота, операторы сети часто " +"могут просить вас доказать, что являетесь владельцем бота. Для примера вы " +"можете сделать это так:\n" +"\n" +"@load Anonymous\n" +"@config plugins.anonymous.requirecapability owner\n" +"@config plugins.anonymous.allowprivatetarget True\n" +"@anonymous say <ник оператора>: Привет, мой хозяин это <ваш ник> :)\n" +"\n" +"Комбинация команд выше\n" +"* Загружает плагин.\n" +"* Выставляет в настройках плагина, чтобы бот требовал привилегии " +"владельца.\n" +"\n" +" * Если кто-нибудь может посылать сообщения от имени бота, то они в том " +"числе могут запрашивать сервисы сети.\n" +"\n" +"* Разрешает отправку личных сообщений.\n" +"* Отправляет сообщение ``Привет, мой хозяин это <ваш ник> :)`` в адрес " +"``<ника оператора>``.\n" +"\n" +" Примечание: вы не сможете получать сообщения, которые отправлены боту." + +#: plugin.py:98 +msgid "You must be in %s to %q in there." +msgstr "Вы должны быть в %s, чтобы %q там." + +#: plugin.py:102 +msgid "I'm lobotomized in %s." +msgstr "Мне сделали лоботомию в %s." + +#: plugin.py:105 +msgid "" +"That channel has set its capabilities so as to disallow the use of this " +"plugin." +msgstr "Этот канал настроен на запрет использования этого плагина." + +#: plugin.py:108 +msgid "" +"This command is disabled (supybot.plugins.Anonymous.allowPrivateTarget is " +"False)." +msgstr "" +"Эта команда отключена (настройка supybot.plugins.Anonymous." +"allowPrivateTarget установлена в False)." + +#: plugin.py:112 +msgid "" +"<channel> <text>\n" +"\n" +" Sends <text> to <channel>.\n" +" " +msgstr "" +"<канал> <текст>\n" +"\n" +"Отправляет <текст> в <канал>." + +#: plugin.py:124 +msgid "" +"<nick> <text>\n" +"\n" +" Sends <text> to <nick>. Can only be used if\n" +" supybot.plugins.Anonymous.allowPrivateTarget is True.\n" +" " +msgstr "" +"<ник> <текст>\n" +"\n" +"Отправляет <текст> в адрес <ника>. Команда может быть использована, только " +"если настройка supybot.plugins.Anonymous.allowPrivateTarget установлена в " +"True." + +#: plugin.py:137 +msgid "" +"<channel> <action>\n" +"\n" +" Performs <action> in <channel>.\n" +" " +msgstr "" +"<канал> <действие>\n" +"\n" +"Выполняет <действие> в <канале>." + +#: plugin.py:148 +msgid "" +"<channel> <reaction> <nick>\n" +"\n" +" Sends the <reaction> to <nick>'s last message.\n" +" <reaction> is typically a smiley or an emoji.\n" +"\n" +" This may not be supported on the current network, as this\n" +" command depends on IRCv3 features.\n" +" This is also not supported if\n" +" supybot.protocols.irc.experimentalExtensions disabled\n" +" (don't enable it unless you know what you are doing).\n" +" " +msgstr "" +"<канал> <реакция> <ник>\n" +"\n" +"Отправляет <реакцию> в ответ на последнее сообщение <ника>. <реакция> это " +"обычно смайлик или эмодзи.\n" +"\n" +"Текущая сеть может не поддерживать эту команду, так как команда зависит от " +"возможностей IRCv3. Она также не поддерживается, если в плагине отключена " +"настройка supybot.protocols.irc.experimentalExtensions (не переключайте, " +"если вы не знаете, что вы делаете)." + +#: plugin.py:162 +msgid "" +"Unable to react, supybot.protocols.irc.experimentalExtensions is disabled." +msgstr "" +"Не удаётся отправить реакцию, настройка supybot.protocols.irc." +"experimentalExtensions отключена." + +#: plugin.py:167 +msgid "Unable to react, the network does not support message-tags." +msgstr "" +"Не удаётся отправить реакцию, данная сеть не поддерживает message-tags." + +#: plugin.py:172 +msgid "" +"Unable to react, the network does not allow draft/reply and/or draft/react." +msgstr "" +"Не удаётся отправить реакцию, данная сеть не позволяет использовать" +"draft/reply или draft/react." + +#: plugin.py:181 +msgid "I couldn't find a message from %s in my history of %s messages." +msgstr "Не могу найти сообщение от %s в моей истории сообщений (%s)." + +#: plugin.py:189 +msgid "Unable to react, %s's last message does not have a message id." +msgstr "" +"Не удаётся отправить реакцию, последнее сообщение %s не имеет id сообщения." diff --git a/plugins/PluginDownloader/locales/ru.po b/plugins/PluginDownloader/locales/ru.po new file mode 100644 index 000000000..53ebbc6f7 --- /dev/null +++ b/plugins/PluginDownloader/locales/ru.po @@ -0,0 +1,205 @@ +# PluginDownloader plugin for Limnoria +# Copyright (C) 2024 Limnoria +# ssdaniel24 <bo7oaonteg2m__at__mailDOTru>, 2024. +# +msgid "" +msgstr "" +"Project-Id-Version: \n" +"POT-Creation-Date: 2022-02-06 00:12+0100\n" +"PO-Revision-Date: 2024-06-12 22:10+0300\n" +"Last-Translator: ssdaniel24 <bo7oaonteg2m__at__mailDOTru>\n" +"Language-Team: \n" +"Language: ru\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: pygettext.py 1.5\n" +"X-Generator: Poedit 3.4.2\n" + +#: plugin.py:167 +msgid "" +"Plugin is probably not compatible with your Python version (3.x) and could " +"not be converted because 2to3 is not installed." +msgstr "" +"Плагин возможно несовместим с вашей версией Python (3.x) и не может быть " +"конвертирован, так как 2to3 не установлен." + +#: plugin.py:174 +msgid "" +"Plugin was designed for Python 2, but an attempt to convert it to Python 3 " +"has been made. There is no guarantee it will work, though." +msgstr "" +"Плагин был разработан на Python 2, но была сделана попытка конвертировать " +"его на Python 3, однако не гарантируется, что плагин будет работать." + +#: plugin.py:178 +msgid "Plugin successfully installed." +msgstr "Плагин успешно установлен." + +#: plugin.py:323 +msgid "" +"\n" +" This plugin allows you to install unofficial plugins from\n" +" multiple repositories easily. Use the \"repolist\" command to see list " +"of\n" +" available repositories and \"repolist <repository>\" to list plugins,\n" +" which are available in that repository. When you want to install a " +"plugin,\n" +" just run command \"install <repository> <plugin>\".\n" +"\n" +" First start by using the `plugindownloader repolist` command to see the\n" +" available repositories.\n" +"\n" +" To see the plugins inside repository, use command\n" +" `plugindownloader repolist <repository>`\n" +"\n" +" When you see anything interesting, you can use\n" +" `plugindownloader info <repository> <plugin>` to see what the plugin is\n" +" for.\n" +"\n" +" And finally to install the plugin,\n" +" `plugindownloader install <repository> <plugin>`.\n" +"\n" +" Examples\n" +" ^^^^^^^^\n" +"\n" +" ::\n" +"\n" +" < Mikaela> @load PluginDownloader\n" +" < Limnoria> Ok.\n" +" < Mikaela> @plugindownloader repolist\n" +" < Limnoria> Antibody, jlu5, Hoaas, Iota, progval, SpiderDave, " +"boombot, code4lib, code4lib-edsu, code4lib-snapshot, doorbot, frumious, " +"jonimoose, mailed-notifier, mtughan-weather, nanotube-bitcoin, nyuszika7h, " +"nyuszika7h-old, pingdom, quantumlemur, resistivecorpse, scrum, skgsergio, " +"stepnem\n" +" < Mikaela> @plugindownloader repolist progval\n" +" < Limnoria> AttackProtector, AutoTrans, Biography, Brainfuck, " +"ChannelStatus, Cleverbot, Coffee, Coinpan, Debian, ERepublik, Eureka, " +"Fortune, GUI, GitHub, Glob2Chan, GoodFrench, I18nPlaceholder, IMDb, " +"IgnoreNonVoice, Iwant, Kickme, LimnoriaChan, LinkRelay, ListEmpty, Listener, " +"Markovgen, MegaHAL, MilleBornes, NoLatin1, NoisyKarma, OEIS, PPP, PingTime, " +"Pinglist, RateLimit, Rbls, Redmine, Scheme, Seeks, (1 more message)\n" +" < Mikaela> more\n" +" < Limnoria> SilencePlugin, StdoutCapture, Sudo, SupyML, SupySandbox, " +"TWSS, Trigger, Trivia, Twitter, TwitterStream, Untiny, Variables, WebDoc, " +"WebLogs, WebStats, Website, WikiTrans, Wikipedia, WunderWeather\n" +" < Mikaela> @plugindownloader info progval Wikipedia\n" +" < Limnoria> Grabs data from Wikipedia.\n" +" < Mikaela> @plugindownloader install progval Wikipedia\n" +" < Limnoria> Ok.\n" +" < Mikaela> @load Wikipedia\n" +" < Limnoria> Ok.\n" +" " +msgstr "" +"Этот плагин позволяет вам с легкостью устанавливать неофициальные плагины из " +"различных репозиториев. Используйте команду \"repolist\", чтобы увидеть " +"список доступных репозиториев, и команду \"repolist <репозиторий>\", чтобы " +"увидеть список плагинов, доступные в данном репозитории. Когда вы захотите " +"установить плагин, просто запустите команду \"install <репозиторий> " +"<плагин>\".\n" +"\n" +"Для начала используйте команду `plugindownloader repolist`, чтобы увидеть " +"доступные репозитории.\n" +"\n" +"Чтобы увидеть плагины в репозитории, используйте команду `plugindownloader " +"repolist <репозиторий>`\n" +"\n" +"Когда вы найдёте что-нибудь интересное, вы можете использовать команду " +"`plugindownloader info <репозиторий> <плагин>`, чтобы увидеть для чего этот " +"плагин нужен.\n" +"\n" +"И наконец, для установки плагина используйте `plugindownloader install " +"<репозиторий> <плагин>`.\n" +"\n" +"Примеры\n" +"^^^^^^^\n" +"\n" +"< Mikaela> @load PluginDownloader\n" +"< Limnoria> Ok.\n" +"< Mikaela> @plugindownloader repolist\n" +"< Limnoria> Antibody, jlu5, Hoaas, Iota, progval, SpiderDave, boombot, " +"code4lib, code4lib-edsu, code4lib-snapshot, doorbot, frumious, jonimoose, " +"mailed-notifier, mtughan-weather, nanotube-bitcoin, nyuszika7h, nyuszika7h-" +"old, pingdom, quantumlemur, resistivecorpse, scrum, skgsergio, stepnem\n" +"< Mikaela> @plugindownloader repolist progval\n" +"< Limnoria> AttackProtector, AutoTrans, Biography, Brainfuck, ChannelStatus, " +"Cleverbot, Coffee, Coinpan, Debian, ERepublik, Eureka, Fortune, GUI, GitHub, " +"Glob2Chan, GoodFrench, I18nPlaceholder, IMDb, IgnoreNonVoice, Iwant, Kickme, " +"LimnoriaChan, LinkRelay, ListEmpty, Listener, Markovgen, MegaHAL, " +"MilleBornes, NoLatin1, NoisyKarma, OEIS, PPP, PingTime, Pinglist, RateLimit, " +"Rbls, Redmine, Scheme, Seeks, (1 more message)\n" +"< Mikaela> more\n" +"< Limnoria> SilencePlugin, StdoutCapture, Sudo, SupyML, SupySandbox, TWSS, " +"Trigger, Trivia, Twitter, TwitterStream, Untiny, Variables, WebDoc, WebLogs, " +"WebStats, Website, WikiTrans, Wikipedia, WunderWeather\n" +"< Mikaela> @plugindownloader info progval Wikipedia\n" +"< Limnoria> Grabs data from Wikipedia.\n" +"< Mikaela> @plugindownloader install progval Wikipedia\n" +"< Limnoria> Ok.\n" +"< Mikaela> @load Wikipedia\n" +"< Limnoria> Ok." + +#: plugin.py:368 +msgid "" +"[<repository>]\n" +"\n" +" Displays the list of plugins in the <repository>.\n" +" If <repository> is not given, returns a list of available\n" +" repositories." +msgstr "" +"[<репозиторий>]\n" +"\n" +"Показывает список плагинов в данном <репозитории>. Если <репозиторий> не дан " +"аргументом, то показывает список доступных репозиториев." + +#: plugin.py:376 plugin.py:387 +msgid ", " +msgstr "" + +#: plugin.py:378 plugin.py:400 plugin.py:425 +msgid "This repository does not exist or is not known by this bot." +msgstr "Этот репозиторий не существует или неизвестен боту." + +#: plugin.py:385 +msgid "No plugin found in this repository." +msgstr "В этом репозитории не найдено ни одного плагина." + +#: plugin.py:392 +msgid "" +"<repository> <plugin>\n" +"\n" +" Downloads and installs the <plugin> from the <repository>." +msgstr "" +"<репозиторий> <плагин>\n" +"\n" +"Скачивает и устанавливает данный <плагин> из данного <репозитория>." + +#: plugin.py:396 +msgid "" +"This command is not available, because supybot.commands.allowShell is False." +msgstr "" +"Эта команда недоступна, так как настройка supybot.command.allowShell " +"установлена в False." + +#: plugin.py:405 plugin.py:430 +msgid "This plugin does not exist in this repository." +msgstr "Этого плагина нет в данном репозитории." + +#: plugin.py:420 +msgid "" +"<repository> <plugin>\n" +"\n" +" Displays informations on the <plugin> in the <repository>." +msgstr "" +"<репозиторий> <плагин>\n" +"\n" +"Показывает информацию о данном <плагине> в этом <репозитории>." + +#: plugin.py:434 +msgid "No README found for this plugin." +msgstr "В этом плагине не найдено файла README." + +#: plugin.py:437 +msgid "This plugin has no description." +msgstr "Этот плагин не предоставляет описание." diff --git a/plugins/Seen/locales/ru.po b/plugins/Seen/locales/ru.po new file mode 100644 index 000000000..82c6b3343 --- /dev/null +++ b/plugins/Seen/locales/ru.po @@ -0,0 +1,201 @@ +# Seen plugin for Limnoria +# Copyright (C) 2024 Limnoria +# ssdaniel24 <bo7oaonteg2m__at__mailDOTru>, 2024. +msgid "" +msgstr "" +"Project-Id-Version: \n" +"POT-Creation-Date: 2022-02-06 00:12+0100\n" +"PO-Revision-Date: 2024-06-12 15:01+0300\n" +"Last-Translator: ssdaniel24 <bo7oaonteg2m__at__mailDOTru>\n" +"Language-Team: \n" +"Language: ru\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: pygettext.py 1.5\n" +"X-Generator: Poedit 3.4.2\n" + +#: config.py:50 +msgid "" +"The minimum non-wildcard characters\n" +" required to perform a 'seen' request. Of course, it only applies if " +"there\n" +" is a wildcard in the request." +msgstr "" +"Минимальное количество обыкновенных символов (не символов подстановки!), " +"необходимые для запроса 'seen'. Конечно же эта настройка действует только, " +"когда запрос содержит символы подстановки." + +#: config.py:54 +msgid "" +"Determines whether the last message will\n" +" be displayed with @seen. Useful for keeping messages from a channel\n" +" private." +msgstr "" +"Определяет будет ли последнее сообщение показываться в результатах команды " +"seen. Полезно для сохранения приватности сообщений в канале." + +#: plugin.py:98 +msgid "" +"This plugin allows you to see when and what someone last said and\n" +" what you missed since you left a channel." +msgstr "" +"Этот плагин позволяет вам видеть, когда и что кто-нибудь в последний раз " +"написал, и что вы упустили с тех пор как покинули канал." + +#: plugin.py:190 +msgid "Not enough non-wildcard characters." +msgstr "Недостаточно обыкновенных символов (НЕ символов подстановки)." + +#: plugin.py:198 plugin.py:306 +msgid "%s was last seen in %s %s ago" +msgstr "%s в последний раз видели в %s %s назад." + +#: plugin.py:204 plugin.py:283 plugin.py:310 +msgid "%s: %s" +msgstr "%s: %s" + +#: plugin.py:210 +msgid "%s (%s ago)" +msgstr "%s (%s назад)" + +#: plugin.py:212 +msgid "%s could be %L" +msgstr "%s мог(ла) быть %L" + +#: plugin.py:212 +msgid "or" +msgstr "или" + +#: plugin.py:214 +msgid "I haven't seen anyone matching %s." +msgstr "Я не видел никого, кто бы соответствовал %s." + +#: plugin.py:216 plugin.py:313 +msgid "I have not seen %s." +msgstr "Я не видел %s." + +#: plugin.py:223 +msgid "You must be in %s to use this command." +msgstr "Вы должны быть в %s для использования этой команды." + +#: plugin.py:225 +msgid "%s must be in %s to use this command." +msgstr "%s должен/должна быть в %s для использования этой команды." + +#: plugin.py:231 +msgid "" +"[<channel>] <nick>\n" +"\n" +" Returns the last time <nick> was seen and what <nick> was last " +"seen\n" +" saying. <channel> is only necessary if the message isn't sent on " +"the\n" +" channel itself. <nick> may contain * as a wildcard.\n" +" " +msgstr "" +"[<канал>] <ник>\n" +"\n" +"Возвращает последнее время, когда видели <ник> и его/её последнее " +"сообщение. Передавать <канал> требуется в случае, если команда запущена не " +"на этом канале. Данный <ник> может содержать * как символ подстановки." + +#: plugin.py:238 plugin.py:256 +msgid "You've found me!" +msgstr "О нет! Вы нашли меня." + +#: plugin.py:246 +msgid "" +"[<channel>] [--user <name>] [<nick>]\n" +"\n" +" Returns the last time <nick> was seen and what <nick> was last " +"seen\n" +" doing. This includes any form of activity, instead of just " +"PRIVMSGs.\n" +" If <nick> isn't specified, returns the last activity seen in\n" +" <channel>. If --user is specified, looks up name in the user " +"database\n" +" and returns the last time user was active in <channel>. <channel> " +"is\n" +" only necessary if the message isn't sent on the channel itself.\n" +" " +msgstr "" +"[<канал>] [--user <имя>] [<ник>]\n" +"\n" +"Возвращает последнее время, когда видели <ник> и его/её последние " +"действия. Это включает в себя любые формы активности, не ограничиваясь " +"только PRIVMSG. Если <ник> не задан, то возвращает последнюю активность в " +"данном <канале>. Если задан --user, то ищет данное <имя> в базе данных и " +"возвращает последнее время, когда этот пользователь был активен в данном " +"<канале>. Передавать <канал> требуется в случае, если команда запущена не " +"на этом канале." + +#: plugin.py:280 +msgid "Someone was last seen in %s %s ago" +msgstr "В последний раз кого-то видели в %s %s назад" + +#: plugin.py:286 +msgid "I have never seen anyone." +msgstr "Я не видел никого." + +#: plugin.py:290 +msgid "" +"[<channel>]\n" +"\n" +" Returns the last thing said in <channel>. <channel> is only " +"necessary\n" +" if the message isn't sent in the channel itself.\n" +" " +msgstr "" +"[<канал>]\n" +"\n" +"Возвращает последнее, что писали в <канале>. Передавать в аргумент <канал> " +"требуется в случае, если команда запущена не на этом канале." + +#: plugin.py:317 +msgid "" +"[<channel>] <name>\n" +"\n" +" Returns the last time <name> was seen and what <name> was last " +"seen\n" +" saying. This looks up <name> in the user seen database, which " +"means\n" +" that it could be any nick recognized as user <name> that was " +"seen.\n" +" <channel> is only necessary if the message isn't sent in the " +"channel\n" +" itself.\n" +" " +msgstr "" +"[<канал>] <имя>\n" +"\n" +"Возвращает время, когда в последний раз видели <имя> и его/её последнее " +"сообщение. Эта команда ищет <имя> в базе данных пользователей Seen, что " +"значит поиск будет производится среди всех ников, закреплённых за " +"пользователем с данным <именем>. Передавать в аргументы <канал> требуется " +"в случае, если команда запущена не на этом канале." + +#: plugin.py:331 +msgid "" +"[<channel>] [<nick>]\n" +"\n" +" Returns the messages since <nick> last left the channel.\n" +" If <nick> is not given, it defaults to the nickname of the person\n" +" calling the command.\n" +" " +msgstr "" +"[<канал>] [<ник>]\n" +"\n" +"Возвращает сообщения с тех пор как данный <ник> покинул канал. Если <ник> " +"не передан в аргументы, то используется ник пользователя, запустившего " +"команду." + +#: plugin.py:363 +msgid "I couldn't find in my history of %s messages where %r last left %s" +msgstr "Не могу найти в моей истории сообщений (%s), где %r покинул %s." + +#: plugin.py:372 +msgid "Either %s didn't leave, or no messages were sent while %s was gone." +msgstr "" +"Либо %s не покидал(а) канал, либо ни одного сообщения не было отправлено с " +"тех пор, как %s вышел/вышла." diff --git a/plugins/Todo/locales/ru.po b/plugins/Todo/locales/ru.po new file mode 100644 index 000000000..992757566 --- /dev/null +++ b/plugins/Todo/locales/ru.po @@ -0,0 +1,176 @@ +# Todo plugin for Limnoria +# Copyright (C) 2024 Limnoria +# ssdaniel24 <bo7oaonteg2m__at__mailDOTru>, 2024. +# +msgid "" +msgstr "" +"Project-Id-Version: \n" +"POT-Creation-Date: 2022-02-06 00:12+0100\n" +"PO-Revision-Date: 2024-06-12 15:01+0300\n" +"Last-Translator: ssdaniel24 <bo7oaonteg2m__at__mailDOTru>\n" +"Language-Team: \n" +"Language: ru\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: pygettext.py 1.5\n" +"X-Generator: Poedit 3.4.2\n" + +#: config.py:51 +msgid "" +"Determines whether users can read the\n" +" todo-list of another user." +msgstr "Определяет, могут ли пользователи читать чужие списки дел." + +#: plugin.py:123 +msgid "" +"This plugin allows you to create your own personal to-do list on\n" +" the bot." +msgstr "" +"Этот плагин позволяет вам создавать ваши собственные списки дел в боте." + +#: plugin.py:139 +msgid "" +"[<username>] [<task id>]\n" +"\n" +" Retrieves a task for the given task id. If no task id is given, " +"it\n" +" will return a list of task ids that that user has added to their " +"todo\n" +" list.\n" +" " +msgstr "" +"[<имя пользователя>] [<id задачи>]\n" +"\n" +"Получает задачу по заданному id. Если id не передан в аргументы, то " +"возвращает список id задач, которые данный пользователь добавил в свой " +"список дел." + +#: plugin.py:150 +msgid "You are not allowed to see other users todo-list." +msgstr "Вам не разрешено видеть чужие списки дел." + +#: plugin.py:157 +msgid "#%i: %s" +msgstr "#%i: %s" + +#: plugin.py:162 +msgid "%s for %s: %L" +msgstr "%s для %s: %L" + +#: plugin.py:166 +msgid "That user has no tasks in their todo list." +msgstr "Этот пользователь не имеет задач в его/её списке дел." + +#: plugin.py:168 +msgid "You have no tasks in your todo list." +msgstr "У вас нет задач в вашем списке дел." + +#: plugin.py:175 +msgid "Active" +msgstr "Активная" + +#: plugin.py:177 +msgid "Inactive" +msgstr "Неактивная" + +#: plugin.py:179 +msgid ", priority: %i" +msgstr ", приоритет: %i" + +#: plugin.py:182 +msgid "%s todo for %s: %s (Added at %s)" +msgstr "%s задача для %s: %s (добавлено %s)" + +#: plugin.py:186 plugin.py:270 plugin.py:284 +msgid "task id" +msgstr "id задачи" + +#: plugin.py:191 +msgid "" +"[--priority=<num>] <text>\n" +"\n" +" Adds <text> as a task in your own personal todo list. The " +"optional\n" +" priority argument allows you to set a task as a high or low " +"priority.\n" +" Any integer is valid.\n" +" " +msgstr "" +"[--priority=<число>] <текст>\n" +"\n" +"Добавляет данный <текст> в ваш список дел. Необязательный аргумент с " +"приоритетом позволяет вам задавать высокий или низкий приоритет задачи. " +"Допустимо любое целое число." + +#: plugin.py:202 +msgid "(Todo #%i added)" +msgstr "(Задача #%i добавлена)" + +#: plugin.py:208 +msgid "" +"<task id> [<task id> ...]\n" +"\n" +" Removes <task id> from your personal todo list.\n" +" " +msgstr "" +"<id задачи> [<id задачи> ...]\n" +"\n" +"Удаляет задачу с данным <id> из вашего списка дела." + +#: plugin.py:219 +msgid "" +"Task %i could not be removed either because that id doesn't exist or it has " +"been removed already." +msgstr "" +"Задача %i не может быть удалена, так как либо задачи с таким id не " +"существует, либо она уже удалена." + +#: plugin.py:223 +msgid "" +"No tasks were removed because the following tasks could not be removed: %L." +msgstr "" +"Ни одна задача не была удалена, так как они не могут быть удалены: %L." + +#: plugin.py:233 +msgid "" +"[--{regexp} <value>] [<glob> <glob> ...]\n" +"\n" +" Searches your todos for tasks matching <glob>. If --regexp is " +"given,\n" +" its associated value is taken as a regexp and matched against the\n" +" tasks.\n" +" " +msgstr "" +"[-{regexp} <значение>] [<шаблон> <шаблон> ...]\n" +"\n" +"Производит поиск задач по вашим спискам дел, совпадающих с <шаблоном> " +"поиска. Если дан --regexp, то его <значение> принимается как регулярное " +"выражение и сопоставляется с задачами." + +#: plugin.py:256 +msgid "No tasks matched that query." +msgstr "Ни одна задача не совпадает с запросом." + +#: plugin.py:262 +msgid "" +"<id> <priority>\n" +"\n" +" Sets the priority of the todo with the given id to the specified " +"value.\n" +" " +msgstr "" +"<id> <приоритет>\n" +"\n" +"Выставляет приоритет задачи с данным <id> в данное значение." + +#: plugin.py:276 +msgid "" +"<task id> <regexp>\n" +"\n" +" Modify the task with the given id using the supplied regexp.\n" +" " +msgstr "" +"<id задачи> <regexp>\n" +"\n" +"Изменяет задачу с данным id, используя данное регулярное выражение." diff --git a/src/i18n.py b/src/i18n.py index da1bc6167..4557fb758 100644 --- a/src/i18n.py +++ b/src/i18n.py @@ -50,7 +50,7 @@ MSGSTR = 'msgstr "' FUZZY = '#, fuzzy' currentLocale = 'en' -SUPPORTED_LANGUAGES = ['de', 'en', 'es', 'fi', 'fr', 'it'] +SUPPORTED_LANGUAGES = ['de', 'en', 'es', 'fi', 'fr', 'it', 'ru'] class PluginNotFound(Exception): pass From 7ccaeb088abec2b3681aaa14a42c2e116a35f38f Mon Sep 17 00:00:00 2001 From: Valentin Lorentz <progval+git@progval.net> Date: Sat, 15 Jun 2024 08:19:55 +0200 Subject: [PATCH 71/73] GPG: Import documentation removed from the Getting Started guide --- plugins/GPG/README.rst | 30 ++++++++++++++++++++++++++++++ plugins/GPG/plugin.py | 33 ++++++++++++++++++++++++++++++++- 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/plugins/GPG/README.rst b/plugins/GPG/README.rst index 170685c4a..1880f2175 100644 --- a/plugins/GPG/README.rst +++ b/plugins/GPG/README.rst @@ -13,6 +13,36 @@ Usage Provides authentication based on GPG keys. +First you must associate your GPG key with your Limnoria account. The gpg +add command takes two arguments, key id and key server. + +My key is 0x0C207F07B2F32B67 and it's on keyserver pool.sks-keyservers.net +so and now I add it to my bot:: + + <Mikaela> +gpg add 0x0C207F07B2F32B67 pool.sks-keyservers.net + <Yvzabevn> 1 key imported, 0 unchanged, 0 not imported. + +Now I can get token to sign so I can identify:: + + <Guest45020> +gpg gettoken + <Yvzabevn> Your token is: {03640620-97ea-4fdf-b0c3-ce8fb62f2dc5}. Please sign it with your GPG key, paste it somewhere, and call the 'auth' command with the URL to the (raw) file containing the signature. + +Then I follow the instructions and sign my token in terminal:: + + echo "{03640620-97ea-4fdf-b0c3-ce8fb62f2dc5}"|gpg --clearsign|curl -F 'sprunge=<-' http://sprunge.us + +Note that I sent the output to curl with flags to directly send the +clearsigned content to sprunge.us pastebin. Curl should be installed on +most of distributions and comes with msysgit. If you remove the curl part, +you get the output to terminal and can pastebin it to any pastebin of +your choice. Sprunge.us has only plain text and is easy so I used it in +this example. + +And last I give the bot link to the plain text signature:: + + <Guest45020> +gpg auth http://sprunge.us/DUdd + <Yvzabevn> You are now authenticated as Mikaela. + .. _commands-GPG: Commands diff --git a/plugins/GPG/plugin.py b/plugins/GPG/plugin.py index a4c65d537..de0b6f2b6 100644 --- a/plugins/GPG/plugin.py +++ b/plugins/GPG/plugin.py @@ -89,7 +89,38 @@ else: 'too much time to answer the request.')) class GPG(callbacks.Plugin): - """Provides authentication based on GPG keys.""" + """Provides authentication based on GPG keys. + + First you must associate your GPG key with your Limnoria account. The gpg + add command takes two arguments, key id and key server. + + My key is 0x0C207F07B2F32B67 and it's on keyserver pool.sks-keyservers.net + so and now I add it to my bot:: + + <Mikaela> +gpg add 0x0C207F07B2F32B67 pool.sks-keyservers.net + <Yvzabevn> 1 key imported, 0 unchanged, 0 not imported. + + Now I can get token to sign so I can identify:: + + <Guest45020> +gpg gettoken + <Yvzabevn> Your token is: {03640620-97ea-4fdf-b0c3-ce8fb62f2dc5}. Please sign it with your GPG key, paste it somewhere, and call the 'auth' command with the URL to the (raw) file containing the signature. + + Then I follow the instructions and sign my token in terminal:: + + echo "{03640620-97ea-4fdf-b0c3-ce8fb62f2dc5}"|gpg --clearsign|curl -F 'sprunge=<-' http://sprunge.us + + Note that I sent the output to curl with flags to directly send the + clearsigned content to sprunge.us pastebin. Curl should be installed on + most of distributions and comes with msysgit. If you remove the curl part, + you get the output to terminal and can pastebin it to any pastebin of + your choice. Sprunge.us has only plain text and is easy so I used it in + this example. + + And last I give the bot link to the plain text signature:: + + <Guest45020> +gpg auth http://sprunge.us/DUdd + <Yvzabevn> You are now authenticated as Mikaela. + """ class key(callbacks.Commands): @check_gpg_available def add(self, irc, msg, args, user, keyid, keyserver): From 01cdfee53e9ab4858c1ffff149e5a43510c4fa1a Mon Sep 17 00:00:00 2001 From: Pratyush Desai <pratyush.desai@liberta.casa> Date: Fri, 28 Jun 2024 07:35:11 +0530 Subject: [PATCH 72/73] Karma: ignore trailing chars, spaces, tabs (#1579) Signed-off-by: Pratyush Desai <pratyush.desai@liberta.casa> --- plugins/Karma/plugin.py | 4 ++-- plugins/Karma/test.py | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/plugins/Karma/plugin.py b/plugins/Karma/plugin.py index bd896bf30..4f7c91160 100644 --- a/plugins/Karma/plugin.py +++ b/plugins/Karma/plugin.py @@ -272,7 +272,7 @@ class Karma(callbacks.Plugin): karma = '' for s in inc: if thing.endswith(s): - thing = thing[:-len(s)] + thing = thing[:-len(s)].rstrip(",:\t ") # Don't reply if the target isn't a nick if onlynicks and thing.lower() not in map(ircutils.toLower, irc.state.channels[channel].users): @@ -286,7 +286,7 @@ class Karma(callbacks.Plugin): karma = self.db.get(channel, self._normalizeThing(thing)) for s in dec: if thing.endswith(s): - thing = thing[:-len(s)] + thing = thing[:-len(s)].rstrip(",:\t ") if onlynicks and thing.lower() not in map(ircutils.toLower, irc.state.channels[channel].users): return diff --git a/plugins/Karma/test.py b/plugins/Karma/test.py index 97c178919..94285e89f 100644 --- a/plugins/Karma/test.py +++ b/plugins/Karma/test.py @@ -60,6 +60,10 @@ class KarmaTestCase(ChannelPluginTestCase): 'Karma for [\'"]moo[\'"].*increased 1.*total.*1') self.assertRegexp('karma MoO', 'Karma for [\'"]MoO[\'"].*increased 1.*total.*1') + # Test trailing characters and spaces + self.assertNoResponse('baz, ++', 2) + self.assertRegexp('karma baz', + 'Karma for [\'"]baz[\'"].*increased 1.*total.*1') def testKarmaRankingDisplayConfigurable(self): try: From b3f256681fef53fa8035c1e9d130791a163391ca Mon Sep 17 00:00:00 2001 From: Valentin Lorentz <progval+git@progval.net> Date: Thu, 11 Jul 2024 16:57:01 +0200 Subject: [PATCH 73/73] Services: Fix crash in __call__ When a password is added for a nick that is not a valid config entry name, this causes _getNickServPassword to raise an error; and __call__ needs to catch it or the bot becomes unusable. --- plugins/Services/plugin.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/plugins/Services/plugin.py b/plugins/Services/plugin.py index b07ba088e..40c677159 100644 --- a/plugins/Services/plugin.py +++ b/plugins/Services/plugin.py @@ -179,7 +179,11 @@ class Services(callbacks.Plugin): if nick not in self.registryValue('nicks', network=irc.network): return nickserv = self.registryValue('NickServ', network=irc.network) - password = self._getNickServPassword(nick, irc.network) + try: + password = self._getNickServPassword(nick, irc.network) + except Exception: + self.log.exception('Could not get NickServ password for %s', nick) + return ghostDelay = self.registryValue('ghostDelay', network=irc.network) if not ghostDelay: return