Merge branch 'master' into account-ban

This commit is contained in:
Valentin Lorentz 2024-07-19 13:18:08 +02:00
commit cf1231df4e
75 changed files with 2357 additions and 343 deletions

9
.editorconfig Normal file
View File

@ -0,0 +1,9 @@
root = true
[*]
insert_final_newline = true
indent_style = space
[*.py]
indent_size = 4
max_line_length = 79

1
.gitattributes vendored
View File

@ -1,2 +1,3 @@
* text=auto
sandbox export-ignore
.git* export-ignore

View File

@ -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

View File

@ -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/

View File

@ -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))

415
plugins/Aka/locales/ru.po Normal file
View File

@ -0,0 +1,415 @@
# Aka plugin for Limnoria
# Copyright (C) 2024 Limnoria
# ssdaniel24 <bo7oaonteg2m__at__mailDOTru>, 2024.
msgid ""
msgstr ""
"Project-Id-Version: \n"
"POT-Creation-Date: 2022-02-06 00:12+0100\n"
"PO-Revision-Date: 2024-06-12 21:50+0300\n"
"Last-Translator: ssdaniel24 <bo7oaonteg2m__at__mailDOTru>\n"
"Language-Team: \n"
"Language: ru\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: pygettext.py 1.5\n"
"X-Generator: Poedit 3.4.2\n"
#: config.py:55
msgid ""
"The maximum number of words allowed in a\n"
" command name. Setting this to an high value may slow down your bot\n"
" on long commands."
msgstr ""
"Максимальное количество слов, которые могут быть в имени команды.\n"
"Выставление большого значения может замедлить работу вашего бота на длинных "
"командах."
#: config.py:61
msgid ""
"Determines whether the Akas will be\n"
" browsable through the HTTP server."
msgstr "Определяет, где псевдонимы могут быть просмотрены через HTTP-сервер."
#: plugin.py:141 plugin.py:274 plugin.py:732
msgid "This Aka already exists."
msgstr "Этот псевдоним уже существует."
#: plugin.py:170 plugin.py:182 plugin.py:196 plugin.py:301 plugin.py:318
#: plugin.py:335 plugin.py:912
msgid "This Aka does not exist."
msgstr "Этот псевдоним не существует."
#: plugin.py:303
msgid "This Aka is already locked."
msgstr "Этот псевдоним уже заблокирован."
#: plugin.py:320
msgid "This Aka is already unlocked."
msgstr "Этот псевдоним уже разблокирован."
#: plugin.py:465
msgid "By %s at %s"
msgstr "%s %s"
#: plugin.py:501
msgid ""
"\n"
" This plugin allows users to define aliases to commands and "
"combinations\n"
" of commands (via nesting).\n"
"\n"
" Importing from Alias\n"
" ^^^^^^^^^^^^^^^^^^^^\n"
"\n"
" Add an aka, Alias, which eases the transitioning to Aka from Alias.\n"
"\n"
" First we will load Alias and Aka::\n"
"\n"
" <jamessan> @load Alias\n"
" <bot> jamessan: The operation succeeded.\n"
" <jamessan> @load Aka\n"
" <bot> jamessan: The operation succeeded.\n"
"\n"
" Then we import the Alias database to Aka in case it exists and unload\n"
" Alias::\n"
"\n"
" <jamessan> @importaliasdatabase\n"
" <bot> jamessan: The operation succeeded.\n"
" <jamessan> @unload Alias\n"
" <bot> jamessan: The operation succeeded.\n"
"\n"
" And now we will finally add the Aka ``alias`` itself::\n"
"\n"
" <jamessan> @aka add \"alias\" \"aka $1 $*\"\n"
" <bot> jamessan: The operation succeeded.\n"
"\n"
" Now you can use Aka as you used Alias before.\n"
"\n"
" Trout\n"
" ^^^^^\n"
"\n"
" Add an aka, trout, which expects a word as an argument::\n"
"\n"
" <jamessan> @aka add trout \"reply action slaps $1 with a large "
"trout\"\n"
" <bot> jamessan: The operation succeeded.\n"
" <jamessan> @trout me\n"
" * bot slaps me with a large trout\n"
"\n"
" This ``trout`` aka requires the plugin ``Reply`` to be loaded since it\n"
" provides the ``action`` command.\n"
"\n"
" LastFM\n"
" ^^^^^^\n"
"\n"
" Add an aka, ``lastfm``, which expects a last.fm username and replies "
"with\n"
" their most recently played item::\n"
"\n"
" @aka add lastfm \"rss [format concat http://ws.audioscrobbler."
"com/1.0/user/ [format concat [web urlquote $1] /recenttracks.rss]]\"\n"
"\n"
" This ``lastfm`` aka requires the following plugins to be loaded: "
"``RSS``,\n"
" ``Format`` and ``Web``.\n"
"\n"
" ``RSS`` provides ``rss``, ``Format`` provides ``concat`` and ``Web`` "
"provides\n"
" ``urlquote``.\n"
"\n"
" Note that if the nested commands being aliased hadn't been quoted, "
"then\n"
" those commands would have been run immediately, and ``@lastfm`` would "
"always\n"
" reply with the same information, the result of those commands.\n"
" "
msgstr ""
"\n"
"Этот плагин позволяет пользователям создавать собственные псевдонимы к "
"командам и комбинациями команд (вложенные команды).\n"
"\n"
"Импорт из Alias\n"
"^^^^^^^^^^^^^^^\n"
"\n"
"Переходим к использованию плагина Aka от плагина Alias.\n"
"\n"
"Во-первых, загрузим Alias и Aka:\n"
"\n"
" <jamessan> @load Alias\n"
" <bot> jamessan: The operation succeeded.\n"
" <jamessan> @load Aka\n"
" <bot> jamessan: The operation succeeded.\n"
"\n"
"После этого импортируем базу данных плагина Alias в Aka, если та "
"существует, и отключим плагин Alias::\n"
"\n"
"<jamessan> @importaliasdatabase\n"
"<bot> jamessan: The operation succeeded.\n"
"<jamessan> @unload Alias\n"
"<bot> jamessan: The operation succeeded.\n"
"\n"
"И наконец добавим псевдоним команды из плагина Alias, чтобы оставить "
"обратную совместимость:\n"
"\n"
"<jamessan> @aka add \"alias\" \"aka $1 $*\"\n"
"<bot> jamessan: The operation succeeded.\n"
"\n"
"Теперь вы можете использовать плагин Aka как вы использовали до этого "
"плагин Alias.\n"
"\n"
"Дать леща\n"
"^^^^^^^^^\n"
"\n"
"Добавляем псевдоним (чтобы дать кому-то леща), который принимает одно слово "
"как аргумент::\n"
"\n"
"<jamessan> @aka add trout \"reply action с размаху даёт леща $1\"\n"
"<bot> jamessan: The operation succeeded.\n"
"<jamessan> @trout мне\n"
"* bot с размаху даёт леща мне\n"
"\n"
"LastFM\n"
"^^^^^^\n"
"\n"
"Добавляем псевдоним - ``lastfm``, принимает аргументом имя пользователя и "
"отвечает с последней песней, которая у него играла.\n"
"\n"
"@aka add lastfm \"rss [format concat http://ws.audioscrobbler.com/1.0/user/ "
"[format concat [web urlquote $1] /recenttracks.rss]]\"\n"
"\n"
"Этот псевдоним ``lastfm`` требует для работы следующие плагины: ``RSS``, "
"``Format`` и ``Web``.\n"
"\n"
"``RSS`` предоставляет ``rss``, ``Format`` предоставляет ``concat`` и "
"``Web`` предоставляет ``urlquote``.\n"
"\n"
"Обратите внимание, что если бы вложенные команды не были бы обозначены "
"кавычками, то они были бы запущены немедленно, и псевдоним ``@lastfm`` "
"показывал бы всегда одну и ту же информацию - результат выполнения этих "
"команд."
#: plugin.py:699
msgid "You've attempted more nesting than is currently allowed on this bot."
msgstr "Вы попробовали больше вложений, чем сейчас это разрешено в боте."
#: plugin.py:703
msgid " at least"
msgstr " хотя бы"
#: plugin.py:712
msgid "Locked by %s at %s"
msgstr "Заблокировано %s %s"
#: plugin.py:717
msgid ""
"<a global alias,%s %n>\n"
"\n"
"Alias for %q.%s"
msgstr ""
"<глобальный псевдоним, %s %n>\n"
"\n"
"Псевдоним для %q.%s"
#: plugin.py:718 plugin.py:722
msgid "argument"
msgstr "аргумент"
#: plugin.py:721
msgid ""
"<an alias on %s,%s %n>\n"
"\n"
"Alias for %q.%s"
msgstr ""
"<псевдоним %s,%s %n>\n"
"\n"
"Псевдоним для %q.%s"
#: plugin.py:729
msgid "You can't overwrite commands in this plugin."
msgstr "Вы не можете перезаписывать команды в этом плагине."
#: plugin.py:734
msgid "This Aka has too many spaces in its name."
msgstr "Этот псевдоним содержит слишком много пробелов в имени."
#: plugin.py:739
msgid "Can't mix $* and optional args (@1, etc.)"
msgstr "Нельзя перемешивать $* и необязательные аргументы (@1 и др.)"
#: plugin.py:746
msgid "This Aka is locked."
msgstr "Этот псевдоним заблокирован."
#: plugin.py:750
msgid ""
"[--channel <#channel>] <name> <command>\n"
"\n"
" Defines an alias <name> that executes <command>. The <command>\n"
" should be in the standard \"command argument [nestedcommand "
"argument]\"\n"
" arguments to the alias; they'll be filled with the first, second, "
"etc.\n"
" arguments. $1, $2, etc. can be used for required arguments. @1, "
"@2,\n"
" etc. can be used for optional arguments. $* simply means \"all\n"
" arguments that have not replaced $1, $2, etc.\", ie. it will also\n"
" include optional arguments.\n"
" "
msgstr ""
"[--channel <#канал>] <название> <команда>\n"
"\n"
"Определяет псевдоним c <названием>, который запускает <команду>. <команда> "
"должна быть вида: \"<команда> <аргумент> [<вложенная команда> <аргумент>] "
"<аргументы к псевдониму>\". Аргументы к псевдониму по порядку: $1, $2 и тд. "
"используются для обязательных аргументов; @1, @2 и т.д. используются для "
"необязательных аргументов; $* означает \"все аргументы, которые не были "
"заменены с помощью $1, $2 и т.д.\", это включает в себя и необязательные "
"аргументы тоже."
#: plugin.py:764 plugin.py:796 plugin.py:827 plugin.py:859 plugin.py:882
#: plugin.py:905 plugin.py:951 plugin.py:994
msgid "%r is not a valid channel."
msgstr "%r не является допустимым каналом."
#: plugin.py:782
msgid ""
"[--channel <#channel>] <name> <command>\n"
"\n"
" Overwrites an existing alias <name> to execute <command> instead. "
"The\n"
" <command> should be in the standard \"command argument "
"[nestedcommand\n"
" argument]\" arguments to the alias; they'll be filled with the "
"first,\n"
" second, etc. arguments. $1, $2, etc. can be used for required\n"
" arguments. @1, @2, etc. can be used for optional arguments. $* "
"simply\n"
" means \"all arguments that have not replaced $1, $2, etc.\", ie. it "
"will\n"
" also include optional arguments.\n"
" "
msgstr ""
"[--channel <#канал>] <название> <команда>\n"
"\n"
"Перезаписывает существующий псевдоним с <названием>, чтобы он запускал "
"вместо предыдущей данную <команду>. <команда> должна быть вида: \"<команда> "
"<аргумент> [<вложенная команда> <аргумент>] <аргументы к псевдониму>\". "
"Аргументы к псевдониму по порядку: $1, $2 и тд. используются для "
"обязательных аргументов; @1, @2 и т.д. используются для необязательных "
"аргументов; $* означает \"все аргументы, которые не были заменены с помощью "
"$1, $2 и т.д.\", это включает в себя и необязательные аргументы тоже."
#: plugin.py:819
msgid ""
"[--channel <#channel>] <name>\n"
"\n"
" Removes the given alias, if unlocked.\n"
" "
msgstr ""
"[--channel <#канал>] <название>\n"
"\n"
"Удаляет данный псевдоним, если тот не заблокирован."
#: plugin.py:841
msgid ""
"Check if the user has any of the required capabilities to manage\n"
" the regexp database."
msgstr ""
"Проверяет, есть ли у пользователя необходимые привилегии для управления "
"базой данных регулярных выражений."
#: plugin.py:851
msgid ""
"[--channel <#channel>] <alias>\n"
"\n"
" Locks an alias so that no one else can change it.\n"
" "
msgstr ""
"[--channel <#канал>] <псевдоним>\n"
"\n"
"Блокирует данный псевдоним, чтобы никто не мог его изменить."
#: plugin.py:874
msgid ""
"[--channel <#channel>] <alias>\n"
"\n"
" Unlocks an alias so that people can define new aliases over it.\n"
" "
msgstr ""
"[--channel <#канал>] <псевдоним>\n"
"\n"
"Разблокирует данный псевдоним, чтобы люди могли определять новые псевдонимы "
"поверх этого."
#: plugin.py:897
msgid ""
"[--channel <#channel>] <alias>\n"
"\n"
" This command shows the content of an Aka.\n"
" "
msgstr ""
"[--channel <#канал>] <псевдоним>\n"
"\n"
"Эта команда показывает содержание данного псевдонима."
#: plugin.py:917
msgid ""
"takes no arguments\n"
"\n"
" Imports the Alias database into Aka's, and clean the former."
msgstr ""
"не принимает аргументов\n"
"\n"
"Импортирует базу данных Alias в Aka, и очищает первую."
#: plugin.py:922
msgid "Alias plugin is not loaded."
msgstr "Плагин Alias не загружен."
#: plugin.py:933
msgid "Error occured when importing the %n: %L"
msgstr "Произошла ошибка при импорте %n: %L"
#: plugin.py:941
msgid ""
"[--channel <#channel>] [--keys] [--unlocked|--locked]\n"
"\n"
" Lists all Akas defined for <channel>. If <channel> is not "
"specified,\n"
" lists all global Akas. If --keys is given, lists only the Aka "
"names\n"
" and not their commands."
msgstr ""
"[--channel <#канал>] [--keys] [--unlocked|--locked]\n"
"\n"
"Показывает все псевдонимы, определённые для данного <канала>. Если <канал> "
"не дан в аргументах, то показывает все глобальные псевдонимы. Если дан --"
"keys, то показывает только названия псевдонимов без соответствующих им "
"команд."
#: plugin.py:960
msgid "--locked and --unlocked are incompatible options."
msgstr "--locked и --unlocked несовместимые параметры."
#: plugin.py:980
msgid "No Akas found."
msgstr "Не найдено ни одного псевдонима."
#: plugin.py:985
msgid ""
"[--channel <#channel>] <query>\n"
"\n"
" Searches Akas defined for <channel>. If <channel> is not "
"specified,\n"
" searches all global Akas."
msgstr ""
"[--channel <#канал>] <запрос>\n"
"\n"
"Производит поиск среди псевдонимов, определённых в данном <канале>, по "
"данному <запросу>. Если <канал> не дан в аргументах, то поиск производится "
"по всем глобальным псевдонимам."
#: plugin.py:1004
msgid "No matching Akas were found."
msgstr "Не найдено ни одного совпадающего псевдонима."

View File

@ -0,0 +1,249 @@
# Anonymous plugin for Limnoria
# Copyright (C) 2024 Limnoria
# ssdaniel24 <bo7oaonteg2m__at__mailDOTru>, 2024.
#
msgid ""
msgstr ""
"Project-Id-Version: \n"
"POT-Creation-Date: 2022-02-06 00:12+0100\n"
"PO-Revision-Date: 2024-06-12 22:04+0300\n"
"Last-Translator: ssdaniel24 <bo7oaonteg2m__at__mailDOTru>\n"
"Language-Team: \n"
"Language: ru\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: pygettext.py 1.5\n"
"X-Generator: Poedit 3.4.2\n"
#: config.py:50
msgid ""
"Determines whether\n"
" the bot should require people trying to use this plugin to be in the\n"
" channel they wish to anonymously send to."
msgstr ""
"Определяет, должен ли бот требовать, чтобы люди, использующие этот плагин, "
"находились в канале, куда они хотят написать анонимно."
#: config.py:54
msgid ""
"Determines whether the bot should require\n"
" people trying to use this plugin to be registered."
msgstr ""
"Определяет, должен ли бот требовать регистрации у людей, которые "
"используют этот плагин."
#: config.py:57
msgid ""
"Determines what capability (if any) the bot should\n"
" require people trying to use this plugin to have."
msgstr ""
"Определяет какие привилегии (если таковые имеются) должен проверять бот у "
"людей, которые используют этот плагин."
#: config.py:60
msgid ""
"Determines whether the bot will allow the\n"
" \"tell\" command to be used. If true, the bot will allow the \"tell\"\n"
" command to send private messages to other users."
msgstr ""
"Определяет разрешение на использование команды \"tell\". Если значение "
"установлено в true, то бот позволит использовать команду \"tell\" для "
"отправки личных сообщений другим пользователям."
#: plugin.py:45
msgid ""
"\n"
" This plugin allows users to act through the bot anonymously. The "
"'do'\n"
" command has the bot perform an anonymous action in a given channel, "
"and\n"
" the 'say' command allows other people to speak through the bot. "
"Since\n"
" this can be fairly well abused, you might want to set\n"
" supybot.plugins.Anonymous.requireCapability so only users with that\n"
" capability can use this plugin. For extra security, you can require "
"that\n"
" the user be *in* the channel they are trying to address anonymously "
"with\n"
" supybot.plugins.Anonymous.requirePresenceInChannel, or you can "
"require\n"
" that the user be registered by setting\n"
" supybot.plugins.Anonymous.requireRegistration.\n"
"\n"
" Example: Proving that you are the owner\n"
" ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n"
"\n"
" When you ask for cloak/vhost for your bot, the network operators will\n"
" often ask you to prove that you own the bot. You can do this for "
"example\n"
" with the following method::\n"
"\n"
" @load Anonymous\n"
" @config plugins.anonymous.requirecapability owner\n"
" @config plugins.anonymous.allowprivatetarget True\n"
" @anonymous say <operator nick> Hi, my owner is <your nick> :)\n"
"\n"
" This\n"
" * Loads the plugin.\n"
" * Makes the plugin require that you are the owner\n"
"\n"
" * If anyone could send private messages as the bot, they could also\n"
" access network services.\n"
"\n"
" * Allows sending private messages\n"
" * Sends message ``Hi, my owner is <your nick> :)`` to ``operator "
"nick``.\n"
"\n"
" * Note that you won't see the messages that are sent to the bot.\n"
"\n"
" "
msgstr ""
"Этот плагин позволяет пользователям анонимно взаимодействовать через бота. "
"Команда 'do' позволяет выполнить некоторое анонимное действие через бота в "
"данном канале, и команда 'say' позволяет другим пользователям общаться "
"через бота. Этим плагином можно легко злоупотреблять, поэтому возможно вы "
"захотите настройку supybot.plugins.Anonymous.requireCapability, чтобы "
"только пользователи с данной привилегией могли могли использовать этот "
"плагин. Для повышенной безопасности, вы можете требовать, чтобы "
"пользователь был в канале, где они хотят взаимодействовать через бота "
"анонимно, с помощью настроки supybot.plugins.Anonymous."
"requirePresenceInChannel. Или вы можете требовать, чтобы пользователь был "
"зарегистрирован с помощью настройки supybot.plugins.Anonymous."
"requireRegistration.\n"
"\n"
"Пример: доказательство того, что вы являетесь владельцем\n"
"^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n"
"\n"
"Когда вы просите cloak или vhost для своего бота, операторы сети часто "
"могут просить вас доказать, что являетесь владельцем бота. Для примера вы "
"можете сделать это так:\n"
"\n"
"@load Anonymous\n"
"@config plugins.anonymous.requirecapability owner\n"
"@config plugins.anonymous.allowprivatetarget True\n"
"@anonymous say <ник оператора>: Привет, мой хозяин это <ваш ник> :)\n"
"\n"
"Комбинация команд выше\n"
"* Загружает плагин.\n"
"* Выставляет в настройках плагина, чтобы бот требовал привилегии "
"владельца.\n"
"\n"
" * Если кто-нибудь может посылать сообщения от имени бота, то они в том "
"числе могут запрашивать сервисы сети.\n"
"\n"
"* Разрешает отправку личных сообщений.\n"
"* Отправляет сообщение ``Привет, мой хозяин это <ваш ник> :)`` в адрес "
"``<ника оператора>``.\n"
"\n"
" Примечание: вы не сможете получать сообщения, которые отправлены боту."
#: plugin.py:98
msgid "You must be in %s to %q in there."
msgstr "Вы должны быть в %s, чтобы %q там."
#: plugin.py:102
msgid "I'm lobotomized in %s."
msgstr "Мне сделали лоботомию в %s."
#: plugin.py:105
msgid ""
"That channel has set its capabilities so as to disallow the use of this "
"plugin."
msgstr "Этот канал настроен на запрет использования этого плагина."
#: plugin.py:108
msgid ""
"This command is disabled (supybot.plugins.Anonymous.allowPrivateTarget is "
"False)."
msgstr ""
"Эта команда отключена (настройка supybot.plugins.Anonymous."
"allowPrivateTarget установлена в False)."
#: plugin.py:112
msgid ""
"<channel> <text>\n"
"\n"
" Sends <text> to <channel>.\n"
" "
msgstr ""
"<канал> <текст>\n"
"\n"
"Отправляет <текст> в <канал>."
#: plugin.py:124
msgid ""
"<nick> <text>\n"
"\n"
" Sends <text> to <nick>. Can only be used if\n"
" supybot.plugins.Anonymous.allowPrivateTarget is True.\n"
" "
msgstr ""
"<ник> <текст>\n"
"\n"
"Отправляет <текст> в адрес <ника>. Команда может быть использована, только "
"если настройка supybot.plugins.Anonymous.allowPrivateTarget установлена в "
"True."
#: plugin.py:137
msgid ""
"<channel> <action>\n"
"\n"
" Performs <action> in <channel>.\n"
" "
msgstr ""
"<канал> <действие>\n"
"\n"
"Выполняет <действие> в <канале>."
#: plugin.py:148
msgid ""
"<channel> <reaction> <nick>\n"
"\n"
" Sends the <reaction> to <nick>'s last message.\n"
" <reaction> is typically a smiley or an emoji.\n"
"\n"
" This may not be supported on the current network, as this\n"
" command depends on IRCv3 features.\n"
" This is also not supported if\n"
" supybot.protocols.irc.experimentalExtensions disabled\n"
" (don't enable it unless you know what you are doing).\n"
" "
msgstr ""
"<канал> <реакция> <ник>\n"
"\n"
"Отправляет <реакцию> в ответ на последнее сообщение <ника>. <реакция> это "
"обычно смайлик или эмодзи.\n"
"\n"
"Текущая сеть может не поддерживать эту команду, так как команда зависит от "
"возможностей IRCv3. Она также не поддерживается, если в плагине отключена "
"настройка supybot.protocols.irc.experimentalExtensions (не переключайте, "
"если вы не знаете, что вы делаете)."
#: plugin.py:162
msgid ""
"Unable to react, supybot.protocols.irc.experimentalExtensions is disabled."
msgstr ""
"Не удаётся отправить реакцию, настройка supybot.protocols.irc."
"experimentalExtensions отключена."
#: plugin.py:167
msgid "Unable to react, the network does not support message-tags."
msgstr ""
"Не удаётся отправить реакцию, данная сеть не поддерживает message-tags."
#: plugin.py:172
msgid ""
"Unable to react, the network does not allow draft/reply and/or draft/react."
msgstr ""
"Не удаётся отправить реакцию, данная сеть не позволяет использовать"
"draft/reply или draft/react."
#: plugin.py:181
msgid "I couldn't find a message from %s in my history of %s messages."
msgstr "Не могу найти сообщение от %s в моей истории сообщений (%s)."
#: plugin.py:189
msgid "Unable to react, %s's last message does not have a message id."
msgstr ""
"Не удаётся отправить реакцию, последнее сообщение %s не имеет id сообщения."

View File

@ -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))

View File

@ -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:

View File

@ -1,5 +1,3 @@
#!/usr/bin/python
###
# Copyright (c) 2002-2005, Jeremiah Fincher
# Copyright (c) 2010-2021, Valentin Lorentz

View File

@ -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))

View File

@ -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):

View File

@ -33,11 +33,11 @@ import datetime
# Credits for the regexp and function: https://stackoverflow.com/a/2765366/539465
_XSD_DURATION_RE = re.compile(
"(?P<sign>-?)P"
"(?:(?P<years>\d+)Y)?"
"(?:(?P<months>\d+)M)?"
"(?:(?P<days>\d+)D)?"
"(?:T(?:(?P<hours>\d+)H)?(?:(?P<minutes>\d+)M)?(?:(?P<seconds>\d+)S)?)?"
r"(?P<sign>-?)P"
r"(?:(?P<years>\d+)Y)?"
r"(?:(?P<months>\d+)M)?"
r"(?:(?P<days>\d+)D)?"
r"(?:T(?:(?P<hours>\d+)H)?(?:(?P<minutes>\d+)M)?(?:(?P<seconds>\d+)S)?)?"
)

View File

@ -13,6 +13,36 @@ Usage
Provides authentication based on GPG keys.
First you must associate your GPG key with your Limnoria account. The gpg
add command takes two arguments, key id and key server.
My key is 0x0C207F07B2F32B67 and it's on keyserver pool.sks-keyservers.net
so and now I add it to my bot::
<Mikaela> +gpg add 0x0C207F07B2F32B67 pool.sks-keyservers.net
<Yvzabevn> 1 key imported, 0 unchanged, 0 not imported.
Now I can get token to sign so I can identify::
<Guest45020> +gpg gettoken
<Yvzabevn> Your token is: {03640620-97ea-4fdf-b0c3-ce8fb62f2dc5}. Please sign it with your GPG key, paste it somewhere, and call the 'auth' command with the URL to the (raw) file containing the signature.
Then I follow the instructions and sign my token in terminal::
echo "{03640620-97ea-4fdf-b0c3-ce8fb62f2dc5}"|gpg --clearsign|curl -F 'sprunge=<-' http://sprunge.us
Note that I sent the output to curl with flags to directly send the
clearsigned content to sprunge.us pastebin. Curl should be installed on
most of distributions and comes with msysgit. If you remove the curl part,
you get the output to terminal and can pastebin it to any pastebin of
your choice. Sprunge.us has only plain text and is easy so I used it in
this example.
And last I give the bot link to the plain text signature::
<Guest45020> +gpg auth http://sprunge.us/DUdd
<Yvzabevn> You are now authenticated as Mikaela.
.. _commands-GPG:
Commands

View File

@ -89,7 +89,38 @@ else:
'too much time to answer the request.'))
class GPG(callbacks.Plugin):
"""Provides authentication based on GPG keys."""
"""Provides authentication based on GPG keys.
First you must associate your GPG key with your Limnoria account. The gpg
add command takes two arguments, key id and key server.
My key is 0x0C207F07B2F32B67 and it's on keyserver pool.sks-keyservers.net
so and now I add it to my bot::
<Mikaela> +gpg add 0x0C207F07B2F32B67 pool.sks-keyservers.net
<Yvzabevn> 1 key imported, 0 unchanged, 0 not imported.
Now I can get token to sign so I can identify::
<Guest45020> +gpg gettoken
<Yvzabevn> Your token is: {03640620-97ea-4fdf-b0c3-ce8fb62f2dc5}. Please sign it with your GPG key, paste it somewhere, and call the 'auth' command with the URL to the (raw) file containing the signature.
Then I follow the instructions and sign my token in terminal::
echo "{03640620-97ea-4fdf-b0c3-ce8fb62f2dc5}"|gpg --clearsign|curl -F 'sprunge=<-' http://sprunge.us
Note that I sent the output to curl with flags to directly send the
clearsigned content to sprunge.us pastebin. Curl should be installed on
most of distributions and comes with msysgit. If you remove the curl part,
you get the output to terminal and can pastebin it to any pastebin of
your choice. Sprunge.us has only plain text and is easy so I used it in
this example.
And last I give the bot link to the plain text signature::
<Guest45020> +gpg auth http://sprunge.us/DUdd
<Yvzabevn> You are now authenticated as Mikaela.
"""
class key(callbacks.Commands):
@check_gpg_available
def add(self, irc, msg, args, user, keyid, keyserver):

View File

@ -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):

View File

@ -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
"""

View File

@ -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()

View File

@ -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')

View File

@ -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

View File

@ -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:

View File

@ -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]))

View File

@ -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

View File

@ -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

View File

@ -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 '

View File

@ -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 [<network>]

View File

@ -150,7 +150,7 @@ class Network(callbacks.Plugin):
Gives the bot <command> (with its associated <arg>s) on <network>.
"""
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):
"""<command> [<arg> ...]
@ -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

View File

@ -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")

View File

@ -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:

View File

@ -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 "

View File

@ -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"

View File

@ -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."

View File

@ -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"

View File

@ -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 ""

View File

@ -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):

View File

@ -0,0 +1,205 @@
# PluginDownloader plugin for Limnoria
# Copyright (C) 2024 Limnoria
# ssdaniel24 <bo7oaonteg2m__at__mailDOTru>, 2024.
#
msgid ""
msgstr ""
"Project-Id-Version: \n"
"POT-Creation-Date: 2022-02-06 00:12+0100\n"
"PO-Revision-Date: 2024-06-12 22:10+0300\n"
"Last-Translator: ssdaniel24 <bo7oaonteg2m__at__mailDOTru>\n"
"Language-Team: \n"
"Language: ru\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: pygettext.py 1.5\n"
"X-Generator: Poedit 3.4.2\n"
#: plugin.py:167
msgid ""
"Plugin is probably not compatible with your Python version (3.x) and could "
"not be converted because 2to3 is not installed."
msgstr ""
"Плагин возможно несовместим с вашей версией Python (3.x) и не может быть "
"конвертирован, так как 2to3 не установлен."
#: plugin.py:174
msgid ""
"Plugin was designed for Python 2, but an attempt to convert it to Python 3 "
"has been made. There is no guarantee it will work, though."
msgstr ""
"Плагин был разработан на Python 2, но была сделана попытка конвертировать "
"его на Python 3, однако не гарантируется, что плагин будет работать."
#: plugin.py:178
msgid "Plugin successfully installed."
msgstr "Плагин успешно установлен."
#: plugin.py:323
msgid ""
"\n"
" This plugin allows you to install unofficial plugins from\n"
" multiple repositories easily. Use the \"repolist\" command to see list "
"of\n"
" available repositories and \"repolist <repository>\" to list plugins,\n"
" which are available in that repository. When you want to install a "
"plugin,\n"
" just run command \"install <repository> <plugin>\".\n"
"\n"
" First start by using the `plugindownloader repolist` command to see the\n"
" available repositories.\n"
"\n"
" To see the plugins inside repository, use command\n"
" `plugindownloader repolist <repository>`\n"
"\n"
" When you see anything interesting, you can use\n"
" `plugindownloader info <repository> <plugin>` to see what the plugin is\n"
" for.\n"
"\n"
" And finally to install the plugin,\n"
" `plugindownloader install <repository> <plugin>`.\n"
"\n"
" Examples\n"
" ^^^^^^^^\n"
"\n"
" ::\n"
"\n"
" < Mikaela> @load PluginDownloader\n"
" < Limnoria> Ok.\n"
" < Mikaela> @plugindownloader repolist\n"
" < Limnoria> Antibody, jlu5, Hoaas, Iota, progval, SpiderDave, "
"boombot, code4lib, code4lib-edsu, code4lib-snapshot, doorbot, frumious, "
"jonimoose, mailed-notifier, mtughan-weather, nanotube-bitcoin, nyuszika7h, "
"nyuszika7h-old, pingdom, quantumlemur, resistivecorpse, scrum, skgsergio, "
"stepnem\n"
" < Mikaela> @plugindownloader repolist progval\n"
" < Limnoria> AttackProtector, AutoTrans, Biography, Brainfuck, "
"ChannelStatus, Cleverbot, Coffee, Coinpan, Debian, ERepublik, Eureka, "
"Fortune, GUI, GitHub, Glob2Chan, GoodFrench, I18nPlaceholder, IMDb, "
"IgnoreNonVoice, Iwant, Kickme, LimnoriaChan, LinkRelay, ListEmpty, Listener, "
"Markovgen, MegaHAL, MilleBornes, NoLatin1, NoisyKarma, OEIS, PPP, PingTime, "
"Pinglist, RateLimit, Rbls, Redmine, Scheme, Seeks, (1 more message)\n"
" < Mikaela> more\n"
" < Limnoria> SilencePlugin, StdoutCapture, Sudo, SupyML, SupySandbox, "
"TWSS, Trigger, Trivia, Twitter, TwitterStream, Untiny, Variables, WebDoc, "
"WebLogs, WebStats, Website, WikiTrans, Wikipedia, WunderWeather\n"
" < Mikaela> @plugindownloader info progval Wikipedia\n"
" < Limnoria> Grabs data from Wikipedia.\n"
" < Mikaela> @plugindownloader install progval Wikipedia\n"
" < Limnoria> Ok.\n"
" < Mikaela> @load Wikipedia\n"
" < Limnoria> Ok.\n"
" "
msgstr ""
"Этот плагин позволяет вам с легкостью устанавливать неофициальные плагины из "
"различных репозиториев. Используйте команду \"repolist\", чтобы увидеть "
"список доступных репозиториев, и команду \"repolist <репозиторий>\", чтобы "
"увидеть список плагинов, доступные в данном репозитории. Когда вы захотите "
"установить плагин, просто запустите команду \"install <репозиторий> "
"<плагин>\".\n"
"\n"
"Для начала используйте команду `plugindownloader repolist`, чтобы увидеть "
"доступные репозитории.\n"
"\n"
"Чтобы увидеть плагины в репозитории, используйте команду `plugindownloader "
"repolist <репозиторий>`\n"
"\n"
"Когда вы найдёте что-нибудь интересное, вы можете использовать команду "
"`plugindownloader info <репозиторий> <плагин>`, чтобы увидеть для чего этот "
"плагин нужен.\n"
"\n"
"И наконец, для установки плагина используйте `plugindownloader install "
"<репозиторий> <плагин>`.\n"
"\n"
"Примеры\n"
"^^^^^^^\n"
"\n"
"< Mikaela> @load PluginDownloader\n"
"< Limnoria> Ok.\n"
"< Mikaela> @plugindownloader repolist\n"
"< Limnoria> Antibody, jlu5, Hoaas, Iota, progval, SpiderDave, boombot, "
"code4lib, code4lib-edsu, code4lib-snapshot, doorbot, frumious, jonimoose, "
"mailed-notifier, mtughan-weather, nanotube-bitcoin, nyuszika7h, nyuszika7h-"
"old, pingdom, quantumlemur, resistivecorpse, scrum, skgsergio, stepnem\n"
"< Mikaela> @plugindownloader repolist progval\n"
"< Limnoria> AttackProtector, AutoTrans, Biography, Brainfuck, ChannelStatus, "
"Cleverbot, Coffee, Coinpan, Debian, ERepublik, Eureka, Fortune, GUI, GitHub, "
"Glob2Chan, GoodFrench, I18nPlaceholder, IMDb, IgnoreNonVoice, Iwant, Kickme, "
"LimnoriaChan, LinkRelay, ListEmpty, Listener, Markovgen, MegaHAL, "
"MilleBornes, NoLatin1, NoisyKarma, OEIS, PPP, PingTime, Pinglist, RateLimit, "
"Rbls, Redmine, Scheme, Seeks, (1 more message)\n"
"< Mikaela> more\n"
"< Limnoria> SilencePlugin, StdoutCapture, Sudo, SupyML, SupySandbox, TWSS, "
"Trigger, Trivia, Twitter, TwitterStream, Untiny, Variables, WebDoc, WebLogs, "
"WebStats, Website, WikiTrans, Wikipedia, WunderWeather\n"
"< Mikaela> @plugindownloader info progval Wikipedia\n"
"< Limnoria> Grabs data from Wikipedia.\n"
"< Mikaela> @plugindownloader install progval Wikipedia\n"
"< Limnoria> Ok.\n"
"< Mikaela> @load Wikipedia\n"
"< Limnoria> Ok."
#: plugin.py:368
msgid ""
"[<repository>]\n"
"\n"
" Displays the list of plugins in the <repository>.\n"
" If <repository> is not given, returns a list of available\n"
" repositories."
msgstr ""
"[<репозиторий>]\n"
"\n"
"Показывает список плагинов в данном <репозитории>. Если <репозиторий> не дан "
"аргументом, то показывает список доступных репозиториев."
#: plugin.py:376 plugin.py:387
msgid ", "
msgstr ""
#: plugin.py:378 plugin.py:400 plugin.py:425
msgid "This repository does not exist or is not known by this bot."
msgstr "Этот репозиторий не существует или неизвестен боту."
#: plugin.py:385
msgid "No plugin found in this repository."
msgstr "В этом репозитории не найдено ни одного плагина."
#: plugin.py:392
msgid ""
"<repository> <plugin>\n"
"\n"
" Downloads and installs the <plugin> from the <repository>."
msgstr ""
"<репозиторий> <плагин>\n"
"\n"
"Скачивает и устанавливает данный <плагин> из данного <репозитория>."
#: plugin.py:396
msgid ""
"This command is not available, because supybot.commands.allowShell is False."
msgstr ""
"Эта команда недоступна, так как настройка supybot.command.allowShell "
"установлена в False."
#: plugin.py:405 plugin.py:430
msgid "This plugin does not exist in this repository."
msgstr "Этого плагина нет в данном репозитории."
#: plugin.py:420
msgid ""
"<repository> <plugin>\n"
"\n"
" Displays informations on the <plugin> in the <repository>."
msgstr ""
"<репозиторий> <плагин>\n"
"\n"
"Показывает информацию о данном <плагине> в этом <репозитории>."
#: plugin.py:434
msgid "No README found for this plugin."
msgstr "В этом плагине не найдено файла README."
#: plugin.py:437
msgid "This plugin has no description."
msgstr "Этот плагин не предоставляет описание."

View File

@ -227,6 +227,10 @@ repositories = utils.InsensitivePreservingDict({
'oddluck',
'limnoria-plugins',
),
'appas': GithubRepository(
'matiasw',
'my-limnoria-plugins',
),
})
class PluginDownloader(callbacks.Plugin):

View File

@ -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
<https://pypi.org/project/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:

View File

@ -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
<https://pypi.org/project/feedparser/>`_ installed.
"""
import supybot

View File

@ -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>'),

View File

@ -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):

View File

@ -59,7 +59,6 @@ not_well_formed = """<?xml version="1.0" encoding="utf-8"?>
</rss>
"""
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 = """
<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/" xml:lang="en-US">
<title>Recent Commits to anope:2.0</title>
<updated>2023-10-04T16:14:39Z</updated>
<entry>
<title>title with &lt;pre&gt;HTML&lt;/pre&gt;</title>
<updated>2023-10-04T16:14:39Z</updated>
<content type="html">
content with &lt;pre&gt;HTML&lt;/pre&gt;
</content>
</entry>
</feed>"""
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 = """
<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/" xml:lang="en-US">
<title>Recent Commits to anope:2.0</title>
<updated>2023-10-04T16:14:39Z</updated>
<entry>
<title>title with &lt;pre&gt;HTML&lt;/pre&gt;</title>
<updated>2023-10-04T16:14:39Z</updated>
<content type="xhtml">
<div xmlns="http://www.w3.org/1999/xhtml">
content with <pre>XHTML</pre>
</div>
</content>
</entry>
</feed>"""
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 = """
<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/" xml:lang="en-US">
<title>Recent Commits to anope:2.0</title>
<updated>2023-10-04T16:14:39Z</updated>
<entry>
<title>title with &lt;pre&gt;HTML&lt;/pre&gt;</title>
<updated>2023-10-04T16:14:39Z</updated>
<!-- Atom spec says multiple contents is invalid, feedparser says it's not.
I like having the option, so let's make sure we support it. -->
<content type="html">
content with &lt;pre&gt;HTML&lt;/pre&gt;
</content>
<content type="text">
content with plaintext
</content>
</entry>
</feed>"""
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 = """
<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/" xml:lang="en-US">
<title>Recent Commits to anope:2.0</title>
<updated>2023-10-04T16:14:39Z</updated>
<entry>
<title>title with &lt;pre&gt;HTML&lt;/pre&gt;</title>
<updated>2023-10-04T16:14:39Z</updated>
<!-- Atom spec says multiple contents is invalid, feedparser says it's not.
I like having the option, so let's make sure we support it. -->
<content type="text">
content with plaintext
</content>
<content type="html">
content with &lt;pre&gt;HTML&lt;/pre&gt;
</content>
</entry>
</feed>"""
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 = """
<?xml version="1.0" encoding="utf-8"?>
<rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:foaf="http://xmlns.com/foaf/0.1/" xmlns:og="http://ogp.me/ns#" xmlns:rdfs="http://www.w3.org/2000/01/rdf-schema#" xmlns:schema="http://schema.org/" xmlns:sioc="http://rdfs.org/sioc/ns#" xmlns:sioct="http://rdfs.org/sioc/types#" xmlns:skos="http://www.w3.org/2004/02/skos/core#" xmlns:xsd="http://www.w3.org/2001/XMLSchema#" version="2.0">
<channel>
<title>feed title</title>
<description/>
<language>en</language>
<item>
<title>title with &lt;pre&gt;HTML&lt;/pre&gt;</title>
<description>description with &lt;pre&gt;HTML&lt;/pre&gt;</description>
</item>
</channel>
</feed>"""
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

View File

@ -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:

View File

@ -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

View File

@ -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

View File

@ -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:

201
plugins/Seen/locales/ru.po Normal file
View File

@ -0,0 +1,201 @@
# Seen plugin for Limnoria
# Copyright (C) 2024 Limnoria
# ssdaniel24 <bo7oaonteg2m__at__mailDOTru>, 2024.
msgid ""
msgstr ""
"Project-Id-Version: \n"
"POT-Creation-Date: 2022-02-06 00:12+0100\n"
"PO-Revision-Date: 2024-06-12 15:01+0300\n"
"Last-Translator: ssdaniel24 <bo7oaonteg2m__at__mailDOTru>\n"
"Language-Team: \n"
"Language: ru\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: pygettext.py 1.5\n"
"X-Generator: Poedit 3.4.2\n"
#: config.py:50
msgid ""
"The minimum non-wildcard characters\n"
" required to perform a 'seen' request. Of course, it only applies if "
"there\n"
" is a wildcard in the request."
msgstr ""
"Минимальное количество обыкновенных символов (не символов подстановки!), "
"необходимые для запроса 'seen'. Конечно же эта настройка действует только, "
"когда запрос содержит символы подстановки."
#: config.py:54
msgid ""
"Determines whether the last message will\n"
" be displayed with @seen. Useful for keeping messages from a channel\n"
" private."
msgstr ""
"Определяет будет ли последнее сообщение показываться в результатах команды "
"seen. Полезно для сохранения приватности сообщений в канале."
#: plugin.py:98
msgid ""
"This plugin allows you to see when and what someone last said and\n"
" what you missed since you left a channel."
msgstr ""
"Этот плагин позволяет вам видеть, когда и что кто-нибудь в последний раз "
"написал, и что вы упустили с тех пор как покинули канал."
#: plugin.py:190
msgid "Not enough non-wildcard characters."
msgstr "Недостаточно обыкновенных символов (НЕ символов подстановки)."
#: plugin.py:198 plugin.py:306
msgid "%s was last seen in %s %s ago"
msgstr "%s в последний раз видели в %s %s назад."
#: plugin.py:204 plugin.py:283 plugin.py:310
msgid "%s: %s"
msgstr "%s: %s"
#: plugin.py:210
msgid "%s (%s ago)"
msgstr "%s (%s назад)"
#: plugin.py:212
msgid "%s could be %L"
msgstr "%s мог(ла) быть %L"
#: plugin.py:212
msgid "or"
msgstr "или"
#: plugin.py:214
msgid "I haven't seen anyone matching %s."
msgstr "Я не видел никого, кто бы соответствовал %s."
#: plugin.py:216 plugin.py:313
msgid "I have not seen %s."
msgstr "Я не видел %s."
#: plugin.py:223
msgid "You must be in %s to use this command."
msgstr "Вы должны быть в %s для использования этой команды."
#: plugin.py:225
msgid "%s must be in %s to use this command."
msgstr "%s должен/должна быть в %s для использования этой команды."
#: plugin.py:231
msgid ""
"[<channel>] <nick>\n"
"\n"
" Returns the last time <nick> was seen and what <nick> was last "
"seen\n"
" saying. <channel> is only necessary if the message isn't sent on "
"the\n"
" channel itself. <nick> may contain * as a wildcard.\n"
" "
msgstr ""
"[<канал>] <ник>\n"
"\n"
"Возвращает последнее время, когда видели <ник> и его/её последнее "
"сообщение. Передавать <канал> требуется в случае, если команда запущена не "
"на этом канале. Данный <ник> может содержать * как символ подстановки."
#: plugin.py:238 plugin.py:256
msgid "You've found me!"
msgstr "О нет! Вы нашли меня."
#: plugin.py:246
msgid ""
"[<channel>] [--user <name>] [<nick>]\n"
"\n"
" Returns the last time <nick> was seen and what <nick> was last "
"seen\n"
" doing. This includes any form of activity, instead of just "
"PRIVMSGs.\n"
" If <nick> isn't specified, returns the last activity seen in\n"
" <channel>. If --user is specified, looks up name in the user "
"database\n"
" and returns the last time user was active in <channel>. <channel> "
"is\n"
" only necessary if the message isn't sent on the channel itself.\n"
" "
msgstr ""
"[<канал>] [--user <имя>] [<ник>]\n"
"\n"
"Возвращает последнее время, когда видели <ник> и его/её последние "
"действия. Это включает в себя любые формы активности, не ограничиваясь "
"только PRIVMSG. Если <ник> не задан, то возвращает последнюю активность в "
"данном <канале>. Если задан --user, то ищет данное <имя> в базе данных и "
"возвращает последнее время, когда этот пользователь был активен в данном "
"<канале>. Передавать <канал> требуется в случае, если команда запущена не "
"на этом канале."
#: plugin.py:280
msgid "Someone was last seen in %s %s ago"
msgstr "В последний раз кого-то видели в %s %s назад"
#: plugin.py:286
msgid "I have never seen anyone."
msgstr "Я не видел никого."
#: plugin.py:290
msgid ""
"[<channel>]\n"
"\n"
" Returns the last thing said in <channel>. <channel> is only "
"necessary\n"
" if the message isn't sent in the channel itself.\n"
" "
msgstr ""
"[<канал>]\n"
"\n"
"Возвращает последнее, что писали в <канале>. Передавать в аргумент <канал> "
"требуется в случае, если команда запущена не на этом канале."
#: plugin.py:317
msgid ""
"[<channel>] <name>\n"
"\n"
" Returns the last time <name> was seen and what <name> was last "
"seen\n"
" saying. This looks up <name> in the user seen database, which "
"means\n"
" that it could be any nick recognized as user <name> that was "
"seen.\n"
" <channel> is only necessary if the message isn't sent in the "
"channel\n"
" itself.\n"
" "
msgstr ""
"[<канал>] <имя>\n"
"\n"
"Возвращает время, когда в последний раз видели <имя> и его/её последнее "
"сообщение. Эта команда ищет <имя> в базе данных пользователей Seen, что "
"значит поиск будет производится среди всех ников, закреплённых за "
"пользователем с данным <именем>. Передавать в аргументы <канал> требуется "
"в случае, если команда запущена не на этом канале."
#: plugin.py:331
msgid ""
"[<channel>] [<nick>]\n"
"\n"
" Returns the messages since <nick> last left the channel.\n"
" If <nick> is not given, it defaults to the nickname of the person\n"
" calling the command.\n"
" "
msgstr ""
"[<канал>] [<ник>]\n"
"\n"
"Возвращает сообщения с тех пор как данный <ник> покинул канал. Если <ник> "
"не передан в аргументы, то используется ник пользователя, запустившего "
"команду."
#: plugin.py:363
msgid "I couldn't find in my history of %s messages where %r last left %s"
msgstr "Не могу найти в моей истории сообщений (%s), где %r покинул %s."
#: plugin.py:372
msgid "Either %s didn't leave, or no messages were sent while %s was gone."
msgstr ""
"Либо %s не покидал(а) канал, либо ни одного сообщения не было отправлено с "
"тех пор, как %s вышел/вышла."

View File

@ -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, '<last>')
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):

View File

@ -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))

View File

@ -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

View File

@ -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

176
plugins/Todo/locales/ru.po Normal file
View File

@ -0,0 +1,176 @@
# Todo plugin for Limnoria
# Copyright (C) 2024 Limnoria
# ssdaniel24 <bo7oaonteg2m__at__mailDOTru>, 2024.
#
msgid ""
msgstr ""
"Project-Id-Version: \n"
"POT-Creation-Date: 2022-02-06 00:12+0100\n"
"PO-Revision-Date: 2024-06-12 15:01+0300\n"
"Last-Translator: ssdaniel24 <bo7oaonteg2m__at__mailDOTru>\n"
"Language-Team: \n"
"Language: ru\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: pygettext.py 1.5\n"
"X-Generator: Poedit 3.4.2\n"
#: config.py:51
msgid ""
"Determines whether users can read the\n"
" todo-list of another user."
msgstr "Определяет, могут ли пользователи читать чужие списки дел."
#: plugin.py:123
msgid ""
"This plugin allows you to create your own personal to-do list on\n"
" the bot."
msgstr ""
"Этот плагин позволяет вам создавать ваши собственные списки дел в боте."
#: plugin.py:139
msgid ""
"[<username>] [<task id>]\n"
"\n"
" Retrieves a task for the given task id. If no task id is given, "
"it\n"
" will return a list of task ids that that user has added to their "
"todo\n"
" list.\n"
" "
msgstr ""
"[<имя пользователя>] [<id задачи>]\n"
"\n"
"Получает задачу по заданному id. Если id не передан в аргументы, то "
"возвращает список id задач, которые данный пользователь добавил в свой "
"список дел."
#: plugin.py:150
msgid "You are not allowed to see other users todo-list."
msgstr "Вам не разрешено видеть чужие списки дел."
#: plugin.py:157
msgid "#%i: %s"
msgstr "#%i: %s"
#: plugin.py:162
msgid "%s for %s: %L"
msgstr "%s для %s: %L"
#: plugin.py:166
msgid "That user has no tasks in their todo list."
msgstr "Этот пользователь не имеет задач в его/её списке дел."
#: plugin.py:168
msgid "You have no tasks in your todo list."
msgstr "У вас нет задач в вашем списке дел."
#: plugin.py:175
msgid "Active"
msgstr "Активная"
#: plugin.py:177
msgid "Inactive"
msgstr "Неактивная"
#: plugin.py:179
msgid ", priority: %i"
msgstr ", приоритет: %i"
#: plugin.py:182
msgid "%s todo for %s: %s (Added at %s)"
msgstr "%s задача для %s: %s (добавлено %s)"
#: plugin.py:186 plugin.py:270 plugin.py:284
msgid "task id"
msgstr "id задачи"
#: plugin.py:191
msgid ""
"[--priority=<num>] <text>\n"
"\n"
" Adds <text> as a task in your own personal todo list. The "
"optional\n"
" priority argument allows you to set a task as a high or low "
"priority.\n"
" Any integer is valid.\n"
" "
msgstr ""
"[--priority=<число>] <текст>\n"
"\n"
"Добавляет данный <текст> в ваш список дел. Необязательный аргумент с "
"приоритетом позволяет вам задавать высокий или низкий приоритет задачи. "
"Допустимо любое целое число."
#: plugin.py:202
msgid "(Todo #%i added)"
msgstr "(Задача #%i добавлена)"
#: plugin.py:208
msgid ""
"<task id> [<task id> ...]\n"
"\n"
" Removes <task id> from your personal todo list.\n"
" "
msgstr ""
"<id задачи> [<id задачи> ...]\n"
"\n"
"Удаляет задачу с данным <id> из вашего списка дела."
#: plugin.py:219
msgid ""
"Task %i could not be removed either because that id doesn't exist or it has "
"been removed already."
msgstr ""
"Задача %i не может быть удалена, так как либо задачи с таким id не "
"существует, либо она уже удалена."
#: plugin.py:223
msgid ""
"No tasks were removed because the following tasks could not be removed: %L."
msgstr ""
"Ни одна задача не была удалена, так как они не могут быть удалены: %L."
#: plugin.py:233
msgid ""
"[--{regexp} <value>] [<glob> <glob> ...]\n"
"\n"
" Searches your todos for tasks matching <glob>. If --regexp is "
"given,\n"
" its associated value is taken as a regexp and matched against the\n"
" tasks.\n"
" "
msgstr ""
"[-{regexp} <значение>] [<шаблон> <шаблон> ...]\n"
"\n"
"Производит поиск задач по вашим спискам дел, совпадающих с <шаблоном> "
"поиска. Если дан --regexp, то его <значение> принимается как регулярное "
"выражение и сопоставляется с задачами."
#: plugin.py:256
msgid "No tasks matched that query."
msgstr "Ни одна задача не совпадает с запросом."
#: plugin.py:262
msgid ""
"<id> <priority>\n"
"\n"
" Sets the priority of the todo with the given id to the specified "
"value.\n"
" "
msgstr ""
"<id> <приоритет>\n"
"\n"
"Выставляет приоритет задачи с данным <id> в данное значение."
#: plugin.py:276
msgid ""
"<task id> <regexp>\n"
"\n"
" Modify the task with the given id using the supplied regexp.\n"
" "
msgstr ""
"<id задачи> <regexp>\n"
"\n"
"Изменяет задачу с данным id, используя данное регулярное выражение."

View File

@ -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.

View File

@ -33,7 +33,6 @@ import os
import re
import pwd
import sys
import crypt
import errno
import random
import select
@ -41,6 +40,12 @@ import struct
import subprocess
import shlex
try:
import crypt
except ImportError:
# Python >= 3.13
crypt = None
import supybot.conf as conf
import supybot.utils as utils
from supybot.commands import *
@ -119,25 +124,26 @@ class Unix(callbacks.Plugin):
irc.reply(format('%i', os.getpid()), private=True)
pid = wrap(pid, [('checkCapability', 'owner')])
_cryptre = re.compile(b'[./0-9A-Za-z]')
@internationalizeDocstring
def crypt(self, irc, msg, args, password, salt):
"""<password> [<salt>]
if crypt is not None: # Python < 3.13
_cryptre = re.compile(b'[./0-9A-Za-z]')
@internationalizeDocstring
def crypt(self, irc, msg, args, password, salt):
"""<password> [<salt>]
Returns the resulting of doing a crypt() on <password>. If <salt> is
not given, uses a random salt. If running on a glibc2 system,
prepending '$1$' to your salt will cause crypt to return an MD5sum
based crypt rather than the standard DES based crypt.
"""
def makeSalt():
s = b'\x00'
while self._cryptre.sub(b'', s) != b'':
s = struct.pack('<h', random.randrange(-(2**15), 2**15))
return s
if not salt:
salt = makeSalt().decode()
irc.reply(crypt.crypt(password, salt))
crypt = wrap(crypt, ['something', additional('something')])
Returns the resulting of doing a crypt() on <password>. If <salt> is
not given, uses a random salt. If running on a glibc2 system,
prepending '$1$' to your salt will cause crypt to return an MD5sum
based crypt rather than the standard DES based crypt.
"""
def makeSalt():
s = b'\x00'
while self._cryptre.sub(b'', s) != b'':
s = struct.pack('<h', random.randrange(-(2**15), 2**15))
return s
if not salt:
salt = makeSalt().decode()
irc.reply(crypt.crypt(password, salt))
crypt = wrap(crypt, ['something', additional('something')])
@internationalizeDocstring
def spell(self, irc, msg, args, word):

View File

@ -31,6 +31,11 @@
import os
import socket
try:
import crypt
except ImportError:
crypt = None
from supybot.test import *
try:
@ -106,8 +111,9 @@ if os.name == 'posix':
def testProgstats(self):
self.assertNotError('progstats')
def testCrypt(self):
self.assertNotError('crypt jemfinch')
if crypt is not None: # Python < 3.13
def testCrypt(self):
self.assertNotError('crypt jemfinch')
@skipUnlessFortune
def testFortune(self):

View File

@ -154,7 +154,7 @@ class Web(callbacks.PluginRegexp):
if parsed_url.netloc == 'youtube.com' \
or parsed_url.netloc.endswith(('.youtube.com')):
# there is a lot of Javascript before the <title>
size = max(409600, size)
size = max(819200, size)
if parsed_url.netloc in ('reddit.com', 'www.reddit.com', 'new.reddit.com'):
# Since 2022-03, New Reddit has 'Reddit - Dive into anything' as
# <title> on every page.
@ -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']

View File

@ -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)

View File

@ -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

View File

@ -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)

View File

@ -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):

View File

@ -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)

View File

@ -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)

View File

@ -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.')

View File

@ -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)

View File

@ -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"""

View File

@ -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):

View File

@ -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

View File

@ -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

View File

@ -104,7 +104,7 @@ def _main():
def main():
try:
main()
_main()
except KeyboardInterrupt:
pass

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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')

View File

@ -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()

View File

@ -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)