mirror of
https://github.com/progval/Limnoria.git
synced 2025-04-25 12:31:04 -05:00
Merge branch 'master' into account-ban
This commit is contained in:
commit
cf1231df4e
9
.editorconfig
Normal file
9
.editorconfig
Normal 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
1
.gitattributes
vendored
@ -1,2 +1,3 @@
|
||||
* text=auto
|
||||
sandbox export-ignore
|
||||
.git* export-ignore
|
||||
|
13
.github/workflows/test.yml
vendored
13
.github/workflows/test.yml
vendored
@ -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
|
||||
|
@ -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/
|
||||
|
@ -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
415
plugins/Aka/locales/ru.po
Normal 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 "Не найдено ни одного совпадающего псевдонима."
|
249
plugins/Anonymous/locales/ru.po
Normal file
249
plugins/Anonymous/locales/ru.po
Normal 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 сообщения."
|
@ -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))
|
||||
|
@ -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:
|
||||
|
@ -1,5 +1,3 @@
|
||||
#!/usr/bin/python
|
||||
|
||||
###
|
||||
# Copyright (c) 2002-2005, Jeremiah Fincher
|
||||
# Copyright (c) 2010-2021, Valentin Lorentz
|
||||
|
@ -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))
|
||||
|
@ -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):
|
||||
|
@ -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)?)?"
|
||||
)
|
||||
|
||||
|
||||
|
@ -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
|
||||
|
@ -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):
|
||||
|
@ -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):
|
||||
|
@ -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
|
||||
"""
|
||||
|
@ -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()
|
||||
|
@ -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')
|
||||
|
@ -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
|
||||
|
@ -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:
|
||||
|
@ -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]))
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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 '
|
||||
|
@ -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>]
|
||||
|
@ -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
|
||||
|
||||
|
@ -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")
|
||||
|
@ -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:
|
||||
|
@ -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 "
|
||||
|
@ -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"
|
||||
|
@ -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."
|
||||
|
@ -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"
|
||||
|
@ -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 ""
|
||||
|
||||
|
@ -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):
|
||||
|
205
plugins/PluginDownloader/locales/ru.po
Normal file
205
plugins/PluginDownloader/locales/ru.po
Normal 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 "Этот плагин не предоставляет описание."
|
@ -227,6 +227,10 @@ repositories = utils.InsensitivePreservingDict({
|
||||
'oddluck',
|
||||
'limnoria-plugins',
|
||||
),
|
||||
'appas': GithubRepository(
|
||||
'matiasw',
|
||||
'my-limnoria-plugins',
|
||||
),
|
||||
})
|
||||
|
||||
class PluginDownloader(callbacks.Plugin):
|
||||
|
@ -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:
|
||||
|
||||
|
@ -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
|
||||
|
@ -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>'),
|
||||
|
@ -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):
|
||||
|
@ -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 <pre>HTML</pre></title>
|
||||
<updated>2023-10-04T16:14:39Z</updated>
|
||||
<content type="html">
|
||||
content with <pre>HTML</pre>
|
||||
</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 <pre>HTML</pre></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 <pre>HTML</pre></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 <pre>HTML</pre>
|
||||
</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 <pre>HTML</pre></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 <pre>HTML</pre>
|
||||
</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 <pre>HTML</pre></title>
|
||||
<description>description with <pre>HTML</pre></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
|
||||
|
@ -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:
|
||||
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
201
plugins/Seen/locales/ru.po
Normal 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 вышел/вышла."
|
@ -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):
|
||||
|
@ -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))
|
||||
|
@ -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
|
||||
|
@ -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
176
plugins/Todo/locales/ru.po
Normal 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, используя данное регулярное выражение."
|
@ -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.
|
||||
|
||||
|
@ -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):
|
||||
|
@ -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):
|
||||
|
@ -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']
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
6
setup.py
6
setup.py
@ -49,10 +49,12 @@ except ImportError:
|
||||
install. This package is pretty standard, and often installed alongside
|
||||
Python, but it is missing on your system.
|
||||
Try installing it with your package manager, it is usually called
|
||||
'python3-setuptools'. If that does not work, try installing python3-pip
|
||||
'python3-setuptools'; or with '%s -m pip install setuptools'.
|
||||
If that does not work, try installing python3-pip
|
||||
instead, either with your package manager or by following these
|
||||
instructions: https://pip.pypa.io/en/stable/installation/ (replace
|
||||
'python' with 'python3' in all the commands)""")
|
||||
'python' with 'python3' in all the commands)"""
|
||||
% sys.executable)
|
||||
sys.stderr.write(os.linesep*2)
|
||||
sys.stderr.write(textwrap.fill(s))
|
||||
sys.stderr.write(os.linesep*2)
|
||||
|
@ -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):
|
||||
|
18
src/conf.py
18
src/conf.py
@ -419,6 +419,15 @@ def registerNetwork(name, password='', ssl=True, sasl_username='',
|
||||
registry.String('', _("""Determines what user modes the bot will request
|
||||
from the server when it first connects. If empty, defaults to
|
||||
supybot.protocols.irc.umodes""")))
|
||||
registerGlobalValue(network, 'vhost',
|
||||
registry.String('', _("""Determines what vhost the bot will bind to before
|
||||
connecting a server (IRC, HTTP, ...) via IPv4. If empty, defaults to
|
||||
supybot.protocols.irc.vhost""")))
|
||||
registerGlobalValue(network, 'vhostv6',
|
||||
registry.String('', _("""Determines what vhost the bot will bind to before
|
||||
connecting a server (IRC, HTTP, ...) via IPv6. If empty, defaults to
|
||||
supybot.protocols.irc.vhostv6""")))
|
||||
|
||||
sasl = registerGroup(network, 'sasl')
|
||||
registerGlobalValue(sasl, 'username', registry.String(sasl_username,
|
||||
_("""Determines what SASL username will be used on %s. This should
|
||||
@ -676,8 +685,9 @@ registerChannelValue(supybot.replies, 'success',
|
||||
|
||||
registerChannelValue(supybot.replies, 'error',
|
||||
registry.NormalizedString(_("""An error has occurred and has been logged.
|
||||
Please contact this bot's administrator for more information."""), _("""
|
||||
Determines what error message the bot gives when it wants to be
|
||||
Please contact this bot's administrator for more information.
|
||||
If this configuration variable is empty, no generic error message will be sent."""),
|
||||
_("""Determines what error message the bot gives when it wants to be
|
||||
ambiguous.""")))
|
||||
|
||||
registerChannelValue(supybot.replies, 'errorOwner',
|
||||
@ -940,7 +950,7 @@ class Directory(registry.String):
|
||||
if os.path.isabs(filename):
|
||||
filename = os.path.abspath(filename)
|
||||
selfAbs = os.path.abspath(myself)
|
||||
commonPrefix = os.path.commonprefix([selfAbs, filename])
|
||||
commonPrefix = os.path.commonpath([selfAbs, filename])
|
||||
filename = filename[len(commonPrefix):]
|
||||
elif not os.path.isabs(myself):
|
||||
if filename.startswith(myself):
|
||||
@ -953,7 +963,7 @@ class DataFilename(registry.String):
|
||||
def __call__(self):
|
||||
v = super(DataFilename, self).__call__()
|
||||
dataDir = supybot.directories.data()
|
||||
if not v.startswith(dataDir):
|
||||
if not v.startswith("/") and not v.startswith(dataDir):
|
||||
v = os.path.basename(v)
|
||||
v = os.path.join(dataDir, v)
|
||||
self.setValue(v)
|
||||
|
@ -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)
|
||||
|
@ -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.')
|
||||
|
@ -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)
|
||||
|
@ -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"""
|
||||
|
@ -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):
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
@ -104,7 +104,7 @@ def _main():
|
||||
|
||||
def main():
|
||||
try:
|
||||
main()
|
||||
_main()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
|
||||
|
@ -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)
|
||||
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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')
|
||||
|
@ -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()
|
||||
|
@ -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)
|
||||
|
Loading…
x
Reference in New Issue
Block a user