diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 000000000..655090d66 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*] +insert_final_newline = true +indent_style = space + +[*.py] +indent_size = 4 +max_line_length = 79 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 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c04590105..97d73356f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,10 +11,15 @@ jobs: build: runs-on: ${{ matrix.runs-on }} + timeout-minutes: 10 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 @@ -66,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 }} @@ -83,9 +88,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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 259195eaf..4cb2f98b9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,14 +15,10 @@ 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 -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. @@ -32,6 +28,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/ diff --git a/plugins/Admin/test.py b/plugins/Admin/test.py index 92ae557c2..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') @@ -87,13 +88,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: + conf.supybot.networks.test.channels.setValue('') def testNick(self): try: @@ -107,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: @@ -121,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)) 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 , 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 \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" +" @load Alias\n" +" jamessan: The operation succeeded.\n" +" @load Aka\n" +" jamessan: The operation succeeded.\n" +"\n" +" Then we import the Alias database to Aka in case it exists and unload\n" +" Alias::\n" +"\n" +" @importaliasdatabase\n" +" jamessan: The operation succeeded.\n" +" @unload Alias\n" +" jamessan: The operation succeeded.\n" +"\n" +" And now we will finally add the Aka ``alias`` itself::\n" +"\n" +" @aka add \"alias\" \"aka $1 $*\"\n" +" 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" +" @aka add trout \"reply action slaps $1 with a large " +"trout\"\n" +" jamessan: The operation succeeded.\n" +" @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" +" @load Alias\n" +" jamessan: The operation succeeded.\n" +" @load Aka\n" +" jamessan: The operation succeeded.\n" +"\n" +"После этого импортируем базу данных плагина Alias в Aka, если та " +"существует, и отключим плагин Alias::\n" +"\n" +" @importaliasdatabase\n" +" jamessan: The operation succeeded.\n" +" @unload Alias\n" +" jamessan: The operation succeeded.\n" +"\n" +"И наконец добавим псевдоним команды из плагина Alias, чтобы оставить " +"обратную совместимость:\n" +"\n" +" @aka add \"alias\" \"aka $1 $*\"\n" +" jamessan: The operation succeeded.\n" +"\n" +"Теперь вы можете использовать плагин Aka как вы использовали до этого " +"плагин Alias.\n" +"\n" +"Дать леща\n" +"^^^^^^^^^\n" +"\n" +"Добавляем псевдоним (чтобы дать кому-то леща), который принимает одно слово " +"как аргумент::\n" +"\n" +" @aka add trout \"reply action с размаху даёт леща $1\"\n" +" jamessan: The operation succeeded.\n" +" @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 "" +"\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 "" +"\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>] \n" +"\n" +" Defines an alias that executes . The \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>] \n" +"\n" +" Overwrites an existing alias to execute instead. " +"The\n" +" 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>] \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>] \n" +"\n" +" Locks an alias so that no one else can change it.\n" +" " +msgstr "" +"[--channel <#канал>] <псевдоним>\n" +"\n" +"Блокирует данный псевдоним, чтобы никто не мог его изменить." + +#: plugin.py:874 +msgid "" +"[--channel <#channel>] \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>] \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 . If 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>] \n" +"\n" +" Searches Akas defined for . If 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 , 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 \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 Hi, my owner is :)\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 :)`` 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 "" +" \n" +"\n" +" Sends to .\n" +" " +msgstr "" +"<канал> <текст>\n" +"\n" +"Отправляет <текст> в <канал>." + +#: plugin.py:124 +msgid "" +" \n" +"\n" +" Sends to . 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 "" +" \n" +"\n" +" Performs in .\n" +" " +msgstr "" +"<канал> <действие>\n" +"\n" +"Выполняет <действие> в <канале>." + +#: plugin.py:148 +msgid "" +" \n" +"\n" +" Sends the to 's last message.\n" +" 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/Channel/plugin.py b/plugins/Channel/plugin.py index 65cbecc8c..3589e8bd5 100644 --- a/plugins/Channel/plugin.py +++ b/plugins/Channel/plugin.py @@ -1023,9 +1023,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)) diff --git a/plugins/DDG/test.py b/plugins/DDG/test.py index c10ba9955..b5f7961ae 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?\:\/\/') + 'ddg search wikipedia', r'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/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 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/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): 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-?)P" - "(?:(?P\d+)Y)?" - "(?:(?P\d+)M)?" - "(?:(?P\d+)D)?" - "(?:T(?:(?P\d+)H)?(?:(?P\d+)M)?(?:(?P\d+)S)?)?" + r"(?P-?)P" + r"(?:(?P\d+)Y)?" + r"(?:(?P\d+)M)?" + r"(?:(?P\d+)D)?" + r"(?:T(?:(?P\d+)H)?(?:(?P\d+)M)?(?:(?P\d+)S)?)?" ) 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:: + + +gpg add 0x0C207F07B2F32B67 pool.sks-keyservers.net + 1 key imported, 0 unchanged, 0 not imported. + +Now I can get token to sign so I can identify:: + + +gpg gettoken + 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:: + + +gpg auth http://sprunge.us/DUdd + 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:: + + +gpg add 0x0C207F07B2F32B67 pool.sks-keyservers.net + 1 key imported, 0 unchanged, 0 not imported. + + Now I can get token to sign so I can identify:: + + +gpg gettoken + 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:: + + +gpg auth http://sprunge.us/DUdd + You are now authenticated as Mikaela. + """ class key(callbacks.Commands): @check_gpg_available def add(self, irc, msg, args, user, keyid, keyserver): diff --git a/plugins/Geography/test.py b/plugins/Geography/test.py index cc88a47fd..4dbfb8538 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") @@ -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 """ diff --git a/plugins/Internet/plugin.py b/plugins/Internet/plugin.py index c9822dd08..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.pir.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() 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/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: 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])) diff --git a/plugins/MessageParser/plugin.py b/plugins/MessageParser/plugin.py index 981f3a70b..062e07916 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: @@ -191,24 +192,27 @@ 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(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 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 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 ' 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/Network/plugin.py b/plugins/Network/plugin.py index d1271e364..ee7e72fe4 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. @@ -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/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") 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): 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 , 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 \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 \" to list plugins,\n" +" which are available in that repository. When you want to install a " +"plugin,\n" +" just run command \"install \".\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 `\n" +"\n" +" When you see anything interesting, you can use\n" +" `plugindownloader info ` to see what the plugin is\n" +" for.\n" +"\n" +" And finally to install the plugin,\n" +" `plugindownloader install `.\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 "" +"[]\n" +"\n" +" Displays the list of plugins in the .\n" +" If 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 "" +" \n" +"\n" +" Downloads and installs the from the ." +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 "" +" \n" +"\n" +" Displays informations on the in the ." +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/PluginDownloader/plugin.py b/plugins/PluginDownloader/plugin.py index 35755b0fd..cd2434021 100644 --- a/plugins/PluginDownloader/plugin.py +++ b/plugins/PluginDownloader/plugin.py @@ -227,6 +227,10 @@ repositories = utils.InsensitivePreservingDict({ 'oddluck', 'limnoria-plugins', ), + 'appas': GithubRepository( + 'matiasw', + 'my-limnoria-plugins', + ), }) class PluginDownloader(callbacks.Plugin): diff --git a/plugins/RSS/README.rst b/plugins/RSS/README.rst index eaf505e44..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 ----- @@ -140,7 +139,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/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 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..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) @@ -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 @@ -493,10 +498,53 @@ 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) + 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 + + 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) def announce_entry(self, irc, channel, feed, entry): diff --git a/plugins/RSS/test.py b/plugins/RSS/test.py index 9427d63dd..494b1e9b0 100644 --- a/plugins/RSS/test.py +++ b/plugins/RSS/test.py @@ -59,7 +59,6 @@ not_well_formed = """ """ - class MockResponse: headers = {} url = '' @@ -69,6 +68,9 @@ class MockResponse: def close(self): pass + def geturl(self): + return url + def mock_urllib(f): mock = MockResponse() @@ -82,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 @@ -119,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 @@ -356,6 +379,138 @@ class RSSTestCase(ChannelPluginTestCase): self.assertRegexp('rss http://xkcd.com/rss.xml', 'On the other hand, the refractor\'s') + @mock_urllib + def testAtomContentHtmlOnly(self, mock): + timeFastForward(1.1) + 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> + + +""" + 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 testAtomContentXhtmlOnly(self, mock): + timeFastForward(1.1) + 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
+
+
+
+
""" + 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 testAtomContentHtmlAndPlaintext(self, mock): + timeFastForward(1.1) + 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 + + +""" + 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 testAtomContentPlaintextAndHtml(self, mock): + timeFastForward(1.1) + 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> + + +""" + 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): + 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 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/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: 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 , 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 \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 "" +"[] \n" +"\n" +" Returns the last time was seen and what was last " +"seen\n" +" saying. is only necessary if the message isn't sent on " +"the\n" +" channel itself. 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 "" +"[] [--user ] []\n" +"\n" +" Returns the last time was seen and what was last " +"seen\n" +" doing. This includes any form of activity, instead of just " +"PRIVMSGs.\n" +" If isn't specified, returns the last activity seen in\n" +" . If --user is specified, looks up name in the user " +"database\n" +" and returns the last time user was active in . " +"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 "" +"[]\n" +"\n" +" Returns the last thing said in . is only " +"necessary\n" +" if the message isn't sent in the channel itself.\n" +" " +msgstr "" +"[<канал>]\n" +"\n" +"Возвращает последнее, что писали в <канале>. Передавать в аргумент <канал> " +"требуется в случае, если команда запущена не на этом канале." + +#: plugin.py:317 +msgid "" +"[] \n" +"\n" +" Returns the last time was seen and what was last " +"seen\n" +" saying. This looks up in the user seen database, which " +"means\n" +" that it could be any nick recognized as user that was " +"seen.\n" +" is only necessary if the message isn't sent in the " +"channel\n" +" itself.\n" +" " +msgstr "" +"[<канал>] <имя>\n" +"\n" +"Возвращает время, когда в последний раз видели <имя> и его/её последнее " +"сообщение. Эта команда ищет <имя> в базе данных пользователей Seen, что " +"значит поиск будет производится среди всех ников, закреплённых за " +"пользователем с данным <именем>. Передавать в аргументы <канал> требуется " +"в случае, если команда запущена не на этом канале." + +#: plugin.py:331 +msgid "" +"[] []\n" +"\n" +" Returns the messages since last left the channel.\n" +" If 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/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)) diff --git a/plugins/Services/plugin.py b/plugins/Services/plugin.py index 3087804b2..40c677159 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 @@ -123,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.' @@ -149,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) @@ -177,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 @@ -372,6 +378,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.States.CONNECTED, + irclib.IrcStateFsm.States.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 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 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 , 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 \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 "" +"[] []\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 "" +"[<имя пользователя>] []\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=] \n" +"\n" +" Adds 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 "" +" [ ...]\n" +"\n" +" Removes from your personal todo list.\n" +" " +msgstr "" +" [ ...]\n" +"\n" +"Удаляет задачу с данным из вашего списка дела." + +#: 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} ] [ ...]\n" +"\n" +" Searches your todos for tasks matching . 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 "" +" \n" +"\n" +" Sets the priority of the todo with the given id to the specified " +"value.\n" +" " +msgstr "" +" <приоритет>\n" +"\n" +"Выставляет приоритет задачи с данным в данное значение." + +#: plugin.py:276 +msgid "" +" \n" +"\n" +" Modify the task with the given id using the supplied regexp.\n" +" " +msgstr "" +" \n" +"\n" +"Изменяет задачу с данным id, используя данное регулярное выражение." 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. 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): - """ [] + 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): + """ [] - Returns the resulting of doing a crypt() on . If 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('. If 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(' - 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 # on every page. @@ -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)) + '>', @@ -186,9 +187,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) 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 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) diff --git a/src/callbacks.py b/src/callbacks.py index 0844cca8e..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 @@ -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, @@ -730,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/src/conf.py b/src/conf.py index 555550473..a517d3a4c 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 @@ -676,8 +685,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', @@ -940,7 +950,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): @@ -953,7 +963,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) diff --git a/src/drivers/Socket.py b/src/drivers/Socket.py index 3e73ac655..7b7f1df98 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) @@ -305,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) 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/httpserver.py b/src/httpserver.py index 3b150fc11..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) @@ -337,7 +444,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) diff --git a/src/i18n.py b/src/i18n.py index 37bfb355b..4557fb758 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', 'ru'] class PluginNotFound(Exception): pass @@ -71,7 +72,9 @@ 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', Languages(currentLocale, """Determines the bot's default language if translations exist. Currently supported are: %s""" 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/src/irclib.py b/src/irclib.py index a0d7f4076..b4cfe677e 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.''' @@ -1784,7 +1784,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: @@ -1822,17 +1821,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. @@ -1956,17 +1951,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([ @@ -2116,7 +2115,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) @@ -2129,16 +2128,18 @@ 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) 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. @@ -2161,11 +2162,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 2dbe69ce6..4f3a93bea 100644 --- a/src/ircutils.py +++ b/src/ircutils.py @@ -1091,28 +1091,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/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 diff --git a/src/scripts/limnoria_test.py b/src/scripts/limnoria_test.py index 7db6b9107..b370f9bac 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,20 +44,24 @@ 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(""" -supybot.directories.data: %(base_dir)s/test-data -supybot.directories.conf: %(base_dir)s/test-conf -supybot.directories.log: %(base_dir)s/test-logs +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: {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 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: @ @@ -66,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) @@ -131,6 +139,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 ' @@ -238,9 +249,18 @@ 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: + # 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) 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 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 diff --git a/test/test_callbacks.py b/test/test_callbacks.py index 8420a0263..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() @@ -684,12 +690,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') 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() diff --git a/test/test_irclib.py b/test/test_irclib.py index 85c60793d..6f4f560f7 100644 --- a/test/test_irclib.py +++ b/test/test_irclib.py @@ -946,79 +946,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): @@ -1594,26 +1600,41 @@ 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('') 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') + 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', @@ -1631,17 +1652,17 @@ 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') + 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() @@ -1667,17 +1688,17 @@ 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') + 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') @@ -1697,15 +1718,16 @@ 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') + 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='') @@ -1722,16 +1744,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)