├── .coveragerc ├── .gitignore ├── LICENSE.md ├── README.md ├── docs ├── api │ ├── client.rst │ ├── features.rst │ └── index.rst ├── conf.py ├── features │ ├── index.rst │ ├── overview.rst │ └── writing.rst ├── index.rst ├── intro.rst ├── licensing.rst └── usage.rst ├── pydle ├── __init__.py ├── client.py ├── connection.py ├── features │ ├── __init__.py │ ├── account.py │ ├── ctcp.py │ ├── ircv3 │ │ ├── __init__.py │ │ ├── cap.py │ │ ├── ircv3_1.py │ │ ├── ircv3_2.py │ │ ├── ircv3_3.py │ │ ├── metadata.py │ │ ├── monitor.py │ │ ├── sasl.py │ │ └── tags.py │ ├── isupport.py │ ├── rfc1459 │ │ ├── __init__.py │ │ ├── client.py │ │ ├── parsing.py │ │ └── protocol.py │ ├── rpl_whoishost │ │ ├── __init__.py │ │ └── rpl_whoishost.py │ ├── tls.py │ └── whox.py ├── protocol.py └── utils │ ├── __init__.py │ ├── _args.py │ ├── irccat.py │ └── run.py ├── pyproject.toml ├── pytest.ini ├── requirements.txt ├── tests ├── __init__.py ├── conftest.py ├── fixtures.py ├── mocks.py ├── test__fixtures.py ├── test__mocks.py ├── test_client.py ├── test_client_channels.py ├── test_client_users.py ├── test_featurize.py ├── test_ircv3.py └── test_misc.py └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | source = pydle 4 | omit = 5 | pydle/utils/* 6 | 7 | [report] 8 | exclude_lines = 9 | raise NotImplementedError 10 | pass 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | *.pyc 3 | *.pyo 4 | *.swp 5 | *.swo 6 | 7 | /env 8 | /docs/_build 9 | /.coverage 10 | /.tox 11 | /*.egg-info 12 | 13 | .idea 14 | *venv* -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014-2016, Shiz 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright 8 | notice, this list of conditions and the following disclaimer. 9 | * Redistributions in binary form must reproduce the above copyright 10 | notice, this list of conditions and the following disclaimer in the 11 | documentation and/or other materials provided with the distribution. 12 | * Neither the name of the nor the 13 | names of its contributors may be used to endorse or promote products 14 | derived from this software without specific prior written permission. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 17 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 18 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 19 | DISCLAIMED. IN NO EVENT SHALL BE LIABLE FOR ANY 20 | DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 21 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 22 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 23 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 24 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 25 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 26 | 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | pydle 2 | ===== 3 | Python IRC library. 4 | ------------------- 5 | 6 | pydle is a compact, flexible and standards-abiding IRC library for Python 3.6 through 3.9. 7 | 8 | Features 9 | -------- 10 | * Well-organized: Thanks to the modularized feature system, it's not hard to find what you're looking for in the well-organised source code. 11 | * Standards-abiding: Based on [RFC1459](https://tools.ietf.org/html/rfc1459.html) with some small extension tweaks, with full support of optional extension standards: 12 | - [TLS](http://tools.ietf.org/html/rfc5246) 13 | - [CTCP](http://www.irchelp.org/irchelp/rfc/ctcpspec.html) 14 | - (coming soon) [DCC](http://www.irchelp.org/irchelp/rfc/dccspec.html) and extensions 15 | - [ISUPPORT/PROTOCTL](http://tools.ietf.org/html/draft-hardy-irc-isupport-00) 16 | - [IRCv3.1](http://ircv3.net) (full) 17 | - [IRCv3.2](http://ircv3.net) (base complete, most optional extensions) 18 | - [IRCv3.3](http://ircv3.net) (base in progress) 19 | * Asynchronous: IRC is an asynchronous protocol and so should be a library that implements it. Coroutines are used to process events from the server asynchronously. 20 | * Modularised and extensible: Features on top of RFC1459 are implemented as separate modules for a user to pick and choose, and write their own. Broad features are written to be as extensible as possible. 21 | * Liberally licensed: The 3-clause BSD license ensures you can use it everywhere. 22 | 23 | Basic Usage 24 | ----------- 25 | `pip install pydle` 26 | 27 | From there, you can `import pydle` and subclass `pydle.Client` for your own functionality. 28 | 29 | > To enable SSL support, install the `sasl` extra. 30 | > `pip install pydle[sasl]` 31 | 32 | Setting a nickname and starting a connection over TLS: 33 | ```python 34 | import pydle 35 | 36 | # Simple echo bot. 37 | class MyOwnBot(pydle.Client): 38 | async def on_connect(self): 39 | await self.join('#bottest') 40 | 41 | async def on_message(self, target, source, message): 42 | # don't respond to our own messages, as this leads to a positive feedback loop 43 | if source != self.nickname: 44 | await self.message(target, message) 45 | 46 | client = MyOwnBot('MyBot', realname='My Bot') 47 | client.run('irc.rizon.net', tls=True, tls_verify=False) 48 | ``` 49 | 50 | *But wait, I want to handle multiple clients!* 51 | 52 | No worries! Use `pydle.ClientPool` like such: 53 | ```python 54 | pool = pydle.ClientPool() 55 | for i in range(10): 56 | client = MyOwnBot('MyBot' + str(i)) 57 | pool.connect(client, 'irc.rizon.net', 6697, tls=True, tls_verify=False) 58 | 59 | # This will make sure all clients are treated in a fair way priority-wise. 60 | pool.handle_forever() 61 | ``` 62 | 63 | Furthermore, since pydle is simply `asyncio`-based, you can run the client in your own event loop, like this: 64 | ```python 65 | import asyncio 66 | 67 | client = MyOwnBot('MyBot') 68 | loop = asyncio.get_event_loop() 69 | asyncio.ensure_future(client.connect('irc.rizon.net', tls=True, tls_verify=False), loop=loop) 70 | loop.run_forever() 71 | ``` 72 | 73 | 74 | Customization 75 | ------------- 76 | 77 | If you want to customize bot features, you can subclass `pydle.BasicClient` and one or more features from `pydle.features` or your own feature classes, like such: 78 | ```python 79 | # Only support RFC1459 (+small features), CTCP and our own ACME extension to IRC. 80 | class MyFeaturedBot(pydle.features.ctcp.CTCPSupport, acme.ACMESupport, rfc1459.RFC1459Support): 81 | pass 82 | ``` 83 | 84 | To create your own features, just subclass from `pydle.BasicClient` and start adding callbacks for IRC messages: 85 | ```python 86 | # Support custom ACME extension. 87 | class ACMESupport(pydle.BasicClient): 88 | async def on_raw_999(self, source, params): 89 | """ ACME's custom 999 numeric tells us to change our nickname. """ 90 | nickname = params[0] 91 | await self.set_nickname(nickname) 92 | ``` 93 | 94 | FAQ 95 | --- 96 | 97 | **Q: When constructing my own client class from several base classes, I get the following error: _TypeError: Cannot create a consistent method resolution order (MRO) for bases X, Y, Z_. What causes this and how can I solve it?** 98 | 99 | Pydle's use of class inheritance as a feature model may cause method resolution order conflicts if a feature inherits from a different feature, while a class inherits from both the original feature and the inheriting feature. To solve such problem, pydle offers a `featurize` function that will automatically put all classes in the right order and create an appropriate base class: 100 | ```python 101 | # Purposely mis-ordered base classes, as SASLSupport inherits from CapabilityNegotiationSupport, but everything works fine. 102 | MyBase = pydle.featurize(pydle.features.CapabilityNegotiationSupport, pydle.features.SASLSupport) 103 | class Client(MyBase): 104 | pass 105 | ``` 106 | 107 | **Q: How do I...?** 108 | 109 | Stop! Read the [documentation](http://pydle.readthedocs.org) first. If you're still in need of support, join us on IRC! We hang at `#pydle` on `irc.libera.chat`. If someone is around, they'll most likely gladly help you. 110 | 111 | License 112 | ------- 113 | 114 | Pydle is licensed under the 3-clause BSD license. See LICENSE.md for details. 115 | -------------------------------------------------------------------------------- /docs/api/client.rst: -------------------------------------------------------------------------------- 1 | ========== 2 | Client API 3 | ========== 4 | .. module:: pydle.client 5 | 6 | 7 | .. class:: pydle.Client 8 | 9 | :class:`pydle.Client` implements the featureset of :class:`pydle.BasicClient` with all the features in the :mod:`pydle.features` namespace added. 10 | For the full reference, check the :class:`pydle.BasicClient` documentation and the :doc:`Feature API reference `. 11 | 12 | .. class:: pydle.MinimalClient 13 | 14 | :class:`pydle.MinimalClient` implements the featureset of :class:`pydle.BasicClient` with some vital features in the :mod:`pydle.features` namespace added, namely: 15 | 16 | * :class:`pydle.features.RFC1459Support` 17 | * :class:`pydle.features.TLSSupport` 18 | * :class:`pydle.features.CTCPSupport` 19 | * :class:`pydle.features.ISUPPORTSupport` 20 | * :class:`pydle.features.WHOXSupport` 21 | 22 | For the full reference, check the :class:`pydle.BasicClient` documentation and the :doc:`Feature API reference `. 23 | 24 | ----- 25 | 26 | .. autoclass:: pydle.ClientPool 27 | :members: 28 | 29 | ----- 30 | 31 | .. autofunction:: pydle.featurize 32 | 33 | .. autoclass:: pydle.BasicClient 34 | :members: 35 | 36 | :attr:`users` 37 | 38 | A :class:`dict` mapping a username to a :class:`dict` with general information about that user. 39 | Available keys in the information dict: 40 | 41 | * ``nickname``: The user's nickname. 42 | * ``username``: The user's reported username on their source device. 43 | * ``realname``: The user's reported real name (GECOS). 44 | * ``hostname``: The hostname where the user is connecting from. 45 | 46 | :attr:`channels` 47 | 48 | A :class:`dict` mapping a joined channel name to a :class:`dict` with information about that channel. 49 | Available keys in the information dict: 50 | 51 | * ``users``: A :class:`set` of all users currently in the channel. 52 | -------------------------------------------------------------------------------- /docs/api/features.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | Features API 3 | ============ 4 | .. module:: pydle.features 5 | 6 | 7 | RFC1459 8 | ------- 9 | .. autoclass:: pydle.features.RFC1459Support 10 | :members: 11 | 12 | ----- 13 | 14 | Transport Layer Security 15 | ------------------------ 16 | .. autoclass:: pydle.features.TLSSupport 17 | :members: 18 | 19 | ----- 20 | 21 | Client-to-Client Protocol 22 | ------------------------- 23 | .. autoclass:: pydle.features.CTCPSupport 24 | :members: 25 | 26 | ----- 27 | 28 | Account 29 | ------- 30 | .. autoclass:: pydle.features.AccountSupport 31 | :members: 32 | 33 | ----- 34 | 35 | ISUPPORT 36 | -------- 37 | .. autoclass:: pydle.features.ISUPPORTSupport 38 | :members: 39 | 40 | ----- 41 | 42 | Extended WHO 43 | ------------ 44 | .. autoclass:: pydle.features.WHOXSupport 45 | -------------------------------------------------------------------------------- /docs/api/index.rst: -------------------------------------------------------------------------------- 1 | ============= 2 | API reference 3 | ============= 4 | 5 | .. toctree:: 6 | :maxdepth: 2 7 | 8 | client 9 | features 10 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import sys 3 | import os 4 | import os.path as path 5 | import datetime 6 | 7 | 8 | ### -- General options -- ### 9 | 10 | # Make autodoc and import work. 11 | if path.exists(path.join('..', 'pydle')): 12 | sys.path.insert(0, os.path.abspath('..')) 13 | import pydle 14 | 15 | # General information about the project. 16 | project = pydle.__name__ 17 | copyright = '2013-{current}, Shiz'.format(current=datetime.date.today().year) 18 | version = release = pydle.__version__ 19 | 20 | # Sphinx extensions to use. 21 | extensions = [ 22 | # Generate API description from code. 23 | 'sphinx.ext.autodoc', 24 | # Generate unit tests from docstrings. 25 | 'sphinx.ext.doctest', 26 | # Link to Sphinx documentation for related projects. 27 | 'sphinx.ext.intersphinx', 28 | # Generate TODO descriptions from docstrings. 29 | 'sphinx.ext.todo', 30 | # Conditional operator for documentation. 31 | 'sphinx.ext.ifconfig', 32 | # Include full source code with documentation. 33 | 'sphinx.ext.viewcode' 34 | ] 35 | 36 | # Documentation links for projects we link to. 37 | intersphinx_mapping = { 38 | 'python': ('http://docs.python.org/3', None) 39 | } 40 | 41 | 42 | ### -- Build locations -- ### 43 | 44 | templates_path = ['_templates'] 45 | exclude_patterns = ['_build'] 46 | source_suffix = '.rst' 47 | master_doc = 'index' 48 | 49 | 50 | ### -- General build settings -- ### 51 | 52 | pygments_style = 'trac' 53 | 54 | 55 | ### -- HTML output -- ## 56 | 57 | # Only set RTD theme if we're building locally. 58 | if os.environ.get('READTHEDOCS', None) != 'True': 59 | import sphinx_rtd_theme 60 | html_theme = "sphinx_rtd_theme" 61 | html_theme_path = [ sphinx_rtd_theme.get_html_theme_path() ] 62 | html_show_sphinx = False 63 | htmlhelp_basename = 'pydledoc' 64 | 65 | 66 | ### -- LaTeX output -- ## 67 | 68 | latex_documents = [ 69 | ('index', 'pydle.tex', 'pydle Documentation', 'Shiz', 'manual'), 70 | ] 71 | 72 | 73 | ### -- Manpage output -- ### 74 | 75 | man_pages = [ 76 | ('index', 'pydle', 'pydle Documentation', ['Shiz'], 1) 77 | ] 78 | 79 | 80 | ### -- Sphinx customization code -- ## 81 | 82 | def skip(app, what, name, obj, skip, options): 83 | if skip: 84 | return True 85 | if name.startswith('_') and name != '__init__': 86 | return True 87 | if name.startswith('on_data'): 88 | return True 89 | if name.startswith('on_raw_'): 90 | return True 91 | if name.startswith('on_ctcp') and name not in ('on_ctcp', 'on_ctcp_reply'): 92 | return True 93 | if name.startswith('on_isupport_'): 94 | return True 95 | if name.startswith('on_capability_'): 96 | return True 97 | return False 98 | 99 | def setup(app): 100 | app.connect('autodoc-skip-member', skip) 101 | -------------------------------------------------------------------------------- /docs/features/index.rst: -------------------------------------------------------------------------------- 1 | ======== 2 | Features 3 | ======== 4 | 5 | pydle's main IRC functionality is divided into separate modular components called "features". 6 | These features allow you to mix and match your client to fit exactly to your requirements, 7 | as well as provide an easy way to extend pydle yourself, without having to dive into the source code. 8 | 9 | .. toctree:: 10 | :maxdepth: 2 11 | 12 | overview 13 | writing 14 | -------------------------------------------------------------------------------- /docs/features/overview.rst: -------------------------------------------------------------------------------- 1 | ================= 2 | Built-in features 3 | ================= 4 | The following features are packaged with pydle and live in the :mod:`pydle.features` namespace. 5 | 6 | RFC1459 7 | ======= 8 | *API:* :class:`pydle.features.RFC1459Support` 9 | 10 | RFC1459_ is the bread and butter of IRC: it is the standard that defines the very base concepts 11 | of the IRC protocol, ranging from what a channel is to the basic commands to channel limits. 12 | If you want your client to have actually any useful IRC functionality, it is recommend to include this feature. 13 | 14 | .. _RFC1459: https://tools.ietf.org/html/rfc1459.html 15 | 16 | Transport Layer Security (TLS) 17 | ============================== 18 | *API:* :class:`pydle.features.TLSSupport` 19 | 20 | Support for secure connections to the IRC server using `Transport Layer Security`_. 21 | 22 | This allows, if the server supports it, for encrypted connections between the server and the client, 23 | to prevent snooping and provide two-way authentication: both for the server to ensure its identity to the 24 | client, and for the client to ensure its identity to the server. 25 | The latter can also be used in certain service packages to automatically identify to the user account. 26 | 27 | In order to connect to a TLS-enabled server, supply ``tls=True`` to :meth:`pydle.features.TLSSupport.connect`. 28 | 29 | .. hint:: 30 | pydle does not verify server-side TLS certificates by default; to enable certificate verification, 31 | supply ``tls_verify=True`` to :meth:`pydle.features.TLSSupport.connect` as well. 32 | 33 | In order to supply a client certificate, :class:`pydle.features.TLSSupport` takes 3 additional constructor parameters: 34 | 35 | * ``tls_client_cert``: A path to the TLS client certificate. 36 | * ``tls_client_cert_key``: A path to the TLS client certificate key. 37 | * ``tls_client_cert_password``: The optional password for the certificate key. 38 | 39 | .. _`Transport Layer Security`: https://tools.ietf.org/html/rfc5246 40 | 41 | Client-to-Client Protocol (CTCP) 42 | ================================ 43 | *API:* :class:`pydle.features.CTCPSupport` 44 | 45 | Support for encapsulation of out-of-band features into standard IRC messages using the `Client-to-Client Protocol`_. 46 | 47 | This allows you to send meta-messages to other users, requesting e.g. their local time, client version, and more, 48 | and respond to such requests. It adds `pydle.Client.ctcp(target, query, contents=None)`, which allows you to send a 49 | CTCP request to a target, and `pydle.Client.ctcp_reply(target, query, contents=None)`, which allows you to respond to 50 | CTCP requests. 51 | 52 | In addition, it registers the `pydle.Client.on_ctcp(from, query, contents)` hook, which allows you to act upon *any* CTCP 53 | request, and a per-type hook in the form of `pydle.Client.on_ctcp_(from, contents)`, which allows you to act upon CTCP 54 | requests of type `type`. `type` will always be lowercased. A few examples of `type` can be: `action`, `time`, `version`. 55 | 56 | Finally, it registers the `pydle.Client.on_ctcp_reply(from, query, contents)` hook, which acts similar to the above hook, 57 | except it is triggered when the client receives a CTCP response. It also registers `pydle.Client.on_ctcp__reply`, which 58 | works similar to the per-type hook described above. 59 | 60 | .. _`Client-to-Client Protocol`: http://www.irchelp.org/irchelp/rfc/ctcpspec.html 61 | 62 | Server-side Extension Support (ISUPPORT) 63 | ======================================== 64 | *API:* :class:`pydle.features.ISUPPORTSupport` 65 | 66 | Support for IRC protocol extensions using the `ISUPPORT`_ message. 67 | 68 | This feature allows pydle to support protocol extensions which are defined using the non-standard `ISUPPORT` (005) message. 69 | It includes built-in support for a number of popular `ISUPPORT`-based extensions, like `CASEMAPPING`, `CHANMODES`, `NETWORK` 70 | and `PREFIX`. 71 | 72 | It also provides the generic `pydle.Client.on_isupport_type(value)` hook, where `type` is the type of `ISUPPORT`-based 73 | extension that the server indicated support for, and `value` is the optional value of said extension, 74 | or `None` if no value was present. 75 | 76 | .. _`ISUPPORT`: http://tools.ietf.org/html/draft-hardy-irc-isupport-00 77 | 78 | Account System 79 | ============== 80 | *API:* :class:`pydle.features.AccountSupport` 81 | 82 | Support for a generic IRC account system. 83 | 84 | Most IRC networks have some kind of account system that allows users to register and manage their nicknames and personas. 85 | This feature provides additional support in pydle for this idea and its integration into the networks. 86 | 87 | Currently, all it does is set the `identified` and `account` fields when doing a `WHOIS` query (`pydle.Client.whois(user)`) on 88 | someone, which indicate if the target user has identified to their account, and if such, their account name, if available. 89 | 90 | Extended User Tracking 91 | ====================== 92 | *API:* :class:`pydle.features.WHOXSupport` 93 | 94 | Support for better user tracking using `WHOX`_. 95 | 96 | This feature allows pydle to perform more accurate tracking of usernames, idents and account names, using the `WHOX`_ IRC 97 | extension. This allows pydle's internal user database to be more accurate and up-to-date. 98 | 99 | .. _`WHOX`: http://hg.quakenet.org/snircd/file/tip/doc/readme.who 100 | 101 | IRCv3 102 | ===== 103 | *API:* :class:`pydle.features.IRCv3Support` 104 | 105 | A shortcut for IRCv3.1 and IRCv3.2 support; see below. 106 | 107 | IRCv3.1 108 | ======= 109 | *API:* :class:`pydle.features.IRCv3_1Support` 110 | 111 | IRCv3.1 support. 112 | 113 | The `IRCv3 Working Group`_ is a working group organized by several network, server author, and client author representatives 114 | with the intention to standardize current non-standard IRC practices better, and modernize certain parts of the IRC protocol. 115 | The IRCv3 standards are specified as a bunch of extension specifications on top of the last widely-used IRC version, IRC v2.7, 116 | also known as `RFC1459`_. 117 | 118 | The `IRCv3.1 specification`_ adds useful features to IRC from a client perspective, including `SASL authentication`_, 119 | support for `indicating when a user identified to their account`_, and `indicating when a user went away from their PC`_. 120 | 121 | Including this feature entirely will activate all IRCv3.1 functionality for pydle. You can also opt-in to only select the two 122 | major features of IRCv3.1, the capability negotiation framework and SASL authentication support, as described below, 123 | by only including their features. 124 | 125 | .. _`IRCv3 Working Group`: http://ircv3.org 126 | .. _`IRCv3.1 specification`: http://ircv3.org 127 | .. _`SASL authentication`: http://ircv3.org/extensions/sasl-3.1 128 | .. _`indicating when a user identified to their account`: http://ircv3.org/extensions/account-notify-3.1 129 | .. _`indicating when a user went away from their PC`: http://ircv3.org/extensions/away-notify-3.1 130 | 131 | Capability Negotiation Support 132 | ------------------------------ 133 | *API:* :class:`pydle.features.ircv3.CapabilityNegotiationSupport` 134 | 135 | Support for `capability negotiation` for IRC protocol extensions. 136 | 137 | This feature enables support for a generic framework for negotiating IRC protocol extension support between the client and the 138 | server. It was quickly found that `ISUPPORT` alone wasn't sufficient, as it only advertises support from the server side instead 139 | of allowing the server and client to negotiate. This is a generic base feature: enabling it on its own won't do much, instead 140 | other features like the IRCv3.1 support feature, or the SASL authentication feature will rely on it to work. 141 | 142 | This feature adds three generic hooks for feature authors whose features makes use of capability negotiation: 143 | 144 | * ``pydle.Client.on_capability__available(value)``: Called when the server indicates capability `cap` is available. 145 | Is passed a value as given by the IRC server, or `None` if no value was given Should return either a boolean indicating whether 146 | or not to request the capability, or a string indicating to request the capability with the returned value. 147 | * ``pydle.Client.on_capability__enabled()``: Called when the server has acknowledged the request of capability `cap`, and it 148 | has been enabled. Should return one of three values: `pydle.CAPABILITY_NEGOTIATING` when the capability will be further negotiated, 149 | `pydle.CAPABILITY_NEGOTIATED` when the capability has been negotiated successfully, or `pydle.CAPABILITY_FAILED` when negotiation 150 | of the capability has failed. If the function returned `pydle.CAPABILITY_NEGOTIATING`, it has to call 151 | `pydle.Client.capability_negotiated(cap, success=True)` when negotiating is finished. 152 | * ``pydle.Client.on_capability__disabled()``: Called when a previously-enabled capability `cap` has been disabled. 153 | 154 | .. _`capability negotiation`: http://ircv3.org/specification/capability-negotiation-3.1 155 | 156 | User Authentication Support (SASL) 157 | ---------------------------------- 158 | *API:* :class:`pydle.features.ircv3.SASLSupport` 159 | 160 | Support for user authentication using `SASL`_. 161 | 162 | This feature enables users to identify to their network account using the SASL protocol and practices. Three extra arguments are added 163 | to the `pydle.Client` constructor: 164 | 165 | * ``sasl_username``: The SASL username. 166 | * ``sasl_password``: The SASL password. 167 | * ``sasl_identity``: The identity to use. Default, and most common, is ``''``. 168 | * ``sasl_mechanism``: The SASL mechanism to force. Default involves auto-selection from server-supported mechanism, or a `PLAIN`` fallback. 169 | 170 | These arguments are also set as attributes. 171 | 172 | Currently, pydle's SASL support requires on the Python `pure-sasl`_ package and is thus limited to the mechanisms it supports. 173 | The ``EXTERNAL`` mechanism is also supported without, however. 174 | 175 | .. _`SASL`: https://tools.ietf.org/html/rfc4422 176 | .. _`pure-sasl`: https://github.com/thobbs/pure-sasl 177 | 178 | IRCv3.2 179 | ======= 180 | *API:* :class:`pydle.features.IRCv3_2Support` 181 | 182 | Support for the IRCv3.2 specification. 183 | 184 | The `IRCv3.2 specification`_ is the second iteration of specifications from the `IRCv3 Working Group`_. This set of specification is 185 | still under development, and may change at any time. pydle's support is conservative, likely incomplete and to-be considered 186 | experimental. 187 | 188 | pydle currently supports the following IRCv3.2 extensions: 189 | 190 | * IRCv3.2 `improved capability negotiation`_. 191 | * Indication of changed ident/host using `CHGHOST`_. 192 | * Indication of `ident and host` in RFC1459's /NAMES command response. 193 | * Monitoring of a user's online status using `MONITOR`_. 194 | * `Message tags`_ to add metadata to messages. 195 | * Arbitrary key/value storage using `METADATA`_. 196 | 197 | .. _`IRCv3 Working Group`: http://ircv3.net 198 | .. _`IRCv3.2 specification`: http://ircv3.net 199 | .. _`improved capability negotiation`: http://ircv3.net/specs/core/capability-negotiation-3.2.html 200 | .. _`CHGHOST`: http://ircv3.net/specs/extensions/chghost-3.2.html 201 | .. _`MONITOR`: http://ircv3.net/specs/core/monitor-3.2.html 202 | .. _`ident and host`: http://ircv3.net/specs/extensions/userhost-in-names-3.2.html 203 | .. _`Message tags`: http://ircv3.net/specs/core/message-tags-3.2.html 204 | .. _`METADATA`: http://ircv3.net/specs/core/metadata-3.2.html 205 | 206 | As with the IRCv3.1 features, using this feature enables all of pydle's IRCv3.2 support. A user can also opt to only use individual 207 | large IRCv3.2 features by using the features below. 208 | 209 | Online Status Monitoring 210 | ------------------------ 211 | *API:* :class:`pydle.features.ircv3.MonitoringSupport` 212 | 213 | Support for monitoring a user's online status. 214 | 215 | This feature allows a client to monitor the online status of certain nicknames. It adds the `pydle.Client.monitor(nickname)` and 216 | `pydle.Client.unmonitor(nickname)` APIs to add and remove nicknames from the monitor list. 217 | 218 | If a monitored user comes online, `pydle.Client.on_user_online(nickname)` will be called. Similarly, if a user disappears offline, 219 | `pydle.Client.on_user_offline(nickname)` will be called. 220 | 221 | Tagged Messages 222 | --------------- 223 | *API:* :class:`pydle.features.ircv3.TaggedMessageSupport` 224 | 225 | Support for message metadata using tags. 226 | 227 | This feature allows pydle to parse message metadata that is transmitted using 'tags'. Currently, this has no impact on any APIs 228 | or hooks for client developers. 229 | 230 | Metadata 231 | -------- 232 | *API:* :class:`pydle.features.ircv3.MetadataSupport` 233 | 234 | Support for user and channel metadata. 235 | 236 | This allows you to set and unset arbitrary key-value information on yourself and on channels, as well as retrieve such values from other users and channels. 237 | 238 | ============== 239 | IRCd implementation-specific features 240 | ============== 241 | Optional features that for IRCds that have non-standard messages. 242 | 243 | UnrealIRCd 244 | ========== 245 | Features implementation-specific to UnrealIRCd servers. 246 | 247 | RPL_WHOIS_HOST 248 | -------------- 249 | *API:* :class:`pydle.features.rpl_whoishost.RplWhoisHostSupport` 250 | 251 | Support For `RPL_WHOIS_HOST` messages, this allows pydle to expose an IRC users 252 | real IP address and host, if the bot has access to that information. 253 | 254 | This information will fill in the `real_ip_address` and `real_hostname` fields 255 | of an :class:`pydle.Client.whois()` response. 256 | -------------------------------------------------------------------------------- /docs/features/writing.rst: -------------------------------------------------------------------------------- 1 | ================ 2 | Writing features 3 | ================ 4 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | ===================================================== 2 | pydle - a Pythonic, extensible, compliant IRC library 3 | ===================================================== 4 | 5 | pydle is a compact, flexible and standards-abiding IRC library for Python 3, written out of frustration with existing solutions. 6 | 7 | Features 8 | -------- 9 | - **Well-organized, easily extensible** 10 | 11 | Thanks to the modular setup, pydle's functionality is seperated in modules according to the standard they were defined in. 12 | This makes specific functionality trivial to find and decreases unwanted coupling, 13 | as well as allowing users to pick-and-choose the functionality they need. 14 | 15 | No spaghetti code. 16 | - **Compliant** 17 | 18 | pydle contains modules, or "features" in pydle terminology, for almost every relevant IRC standard: 19 | 20 | * RFC1459_: The standard that defines the basic functionality of IRC - no client could live without. 21 | * TLS_: Support for chatting securely using TLS encryption. 22 | * CTCP_: The IRC Client-to-Client Protocol, allowing clients to query eachother for data. 23 | * ISUPPORT_: A method for the server to indicate non-standard or extended functionality to a client, and for clients to activate said functionality if needed. 24 | * WHOX_: Easily query status information for a lot of users at once. 25 | * IRCv3.1_: An ongoing effort to bring the IRC protocol to the twenty-first century, featuring enhancements such as extended capability negotiation and SASL authentication. 26 | * IRCv3.2_ *(in progress)*: The next, in-development iteration of IRCv3. Features among others advanced message tagging, a generalized metadata system, and online status monitoring. 27 | 28 | No half-assing functionality. 29 | - **Asynchronous** 30 | 31 | IRC is an asychronous protocol; it only makes sense a clients that implements it is asynchronous as well. Built on top of the wonderful asyncio_ library, pydle relies on proven technologies to deliver proper high-performance asynchronous functionality and primitives. 32 | pydle allows using Futures to make asynchronous programming just as intuitive as doing regular blocking operations. 33 | 34 | No callback spaghetti. 35 | - **Liberally licensed** 36 | 37 | The 3-clause BSD license ensures you can use pydle whenever, for what purpose you want. 38 | 39 | No arbitrary restrictions. 40 | 41 | .. _RFC1459: https://tools.ietf.org/html/rfc1459.html 42 | .. _TLS: https://tools.ietf.org/html/rfc5246 43 | .. _CTCP: http://www.irchelp.org/irchelp/rfc/ctcpspec.html 44 | .. _ISUPPORT: https://tools.ietf.org/html/draft-hardy-irc-isupport-00 45 | .. _WHOX: https://hg.quakenet.org/snircd/file/tip/doc/readme.who 46 | .. _IRCv3.1: http://ircv3.org/ 47 | .. _IRCv3.2: http://ircv3.org/ 48 | .. _asyncio: https://docs.python.org/3/library/asyncio.html 49 | 50 | Contents 51 | -------- 52 | .. toctree:: 53 | :maxdepth: 2 54 | 55 | intro 56 | usage 57 | features/index 58 | api/index 59 | licensing 60 | -------------------------------------------------------------------------------- /docs/intro.rst: -------------------------------------------------------------------------------- 1 | ===================== 2 | Introduction to pydle 3 | ===================== 4 | 5 | What is pydle? 6 | -------------- 7 | pydle is an IRC library for Python 3.6 through 3.9. 8 | 9 | Although old and dated on some fronts, IRC is still used by a variety of communities as the real-time communication method of choice, 10 | and the most popular IRC networks can still count on tens of thousands of users at any point during the day. 11 | 12 | pydle was created out of perceived lack of a good, Pythonic, IRC library solution that also worked with Python 3. 13 | It attempts to follow the standards properly, while also having functionality for the various extensions to the protocol that have been made over the many years. 14 | 15 | What isn't pydle? 16 | ----------------- 17 | pydle is not an end-user IRC client. Although a simple client may be trivial to implement using pydle, pydle itself does not seek out to be a full-fledged client. 18 | It does, however, provide the building blocks to which you can delegate most, if not all, of your IRC protocol headaches. 19 | 20 | pydle also isn't production-ready: while the maintainers try their utmost best to keep the API stable, pydle is still in heavy development, 21 | and APIs are prone to change or removal at least until version 1.0 has been reached. 22 | 23 | Requirements 24 | ------------ 25 | Most of pydle is written in pure, portable Python that only relies on the standard library. 26 | Optionally, if you plan to use pydle's SASL functionality for authentication, the excellent pure-sasl_ library is required. 27 | 28 | All dependencies can be installed using the standard package manager for Python, pip, and the included requirements file: 29 | 30 | .. code:: bash 31 | 32 | pip install -r requirements.txt 33 | 34 | .. _pure-sasl: https://github.com/thobbs/pure-sasl 35 | 36 | Compatibility 37 | ------------- 38 | pydle works in any interpreter that implements Python 3.6-3.9. Although mainly tested in CPython_, the standard Python implementation, 39 | there is no reason why pydle itself should not work in alternative implementations like PyPy_, as long as they support the Python 3.6 language requirements. 40 | 41 | .. _CPython: https://python.org 42 | .. _PyPy: http://pypy.org 43 | -------------------------------------------------------------------------------- /docs/licensing.rst: -------------------------------------------------------------------------------- 1 | ========= 2 | Licensing 3 | ========= 4 | 5 | pydle license 6 | ------------- 7 | 8 | :: 9 | 10 | Copyright (c) 2014-2016, Shiz 11 | All rights reserved. 12 | 13 | Redistribution and use in source and binary forms, with or without 14 | modification, are permitted provided that the following conditions are met: 15 | 16 | * Redistributions of source code must retain the above copyright 17 | notice, this list of conditions and the following disclaimer. 18 | * Redistributions in binary form must reproduce the above copyright 19 | notice, this list of conditions and the following disclaimer in the 20 | documentation and/or other materials provided with the distribution. 21 | * Neither the name of the nor the 22 | names of its contributors may be used to endorse or promote products 23 | derived from this software without specific prior written permission. 24 | 25 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 26 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 27 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 28 | DISCLAIMED. IN NO EVENT SHALL SHIZ BE LIABLE FOR ANY 29 | DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 30 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 31 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 32 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 33 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 34 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 35 | 36 | ------ 37 | 38 | pydle optionally relies on pure-sasl_ to provide SASL authentication methods; its license is printed in verbatim below. 39 | 40 | .. _pure-sasl: https://github.com/thobbs/pure-sasl 41 | 42 | pure-sasl license 43 | ----------------- 44 | 45 | :: 46 | 47 | http://www.opensource.org/licenses/mit-license.php 48 | 49 | Copyright 2007-2011 David Alan Cridland 50 | Copyright 2011 Lance Stout 51 | Copyright 2012 Tyler L Hobbs 52 | 53 | Permission is hereby granted, free of charge, to any person obtaining a copy of this 54 | software and associated documentation files (the "Software"), to deal in the Software 55 | without restriction, including without limitation the rights to use, copy, modify, merge, 56 | publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons 57 | to whom the Software is furnished to do so, subject to the following conditions: 58 | 59 | The above copyright notice and this permission notice shall be included in all copies or 60 | substantial portions of the Software. 61 | 62 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 63 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR 64 | PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE 65 | FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 66 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 67 | DEALINGS IN THE SOFTWARE. 68 | -------------------------------------------------------------------------------- /docs/usage.rst: -------------------------------------------------------------------------------- 1 | =========== 2 | Using pydle 3 | =========== 4 | 5 | .. note:: 6 | This section covers basic use of pydle. To see the full spectrum of what pydle is capable of, 7 | refer to the :doc:`API reference `. 8 | 9 | A simple client 10 | --------------- 11 | The most basic way to use pydle is instantiating a :class:`pydle.Client` object, connecting it to a server 12 | and having it handle messages forever using :meth:`pydle.Client.handle_forever`. 13 | pydle will automatically take care of ensuring that the connection persists, and will reconnect if for some reason disconnected unexpectedly. 14 | 15 | .. code:: python 16 | 17 | import pydle 18 | 19 | client = pydle.Client('MyBot') 20 | # Client.connect() is a coroutine. 21 | await client.connect('irc.freenode.net', tls=True) 22 | client.handle_forever() 23 | 24 | Adding functionality 25 | -------------------- 26 | Of course, the above client doesn't really do much, except idling and error recovery. 27 | To truly start adding functionality to the client, subclass :class:`pydle.Client` and override one or more of the IRC callbacks. 28 | 29 | .. code:: python 30 | 31 | import pydle 32 | 33 | class MyClient(pydle.Client): 34 | """ This is a simple bot that will greet people as they join the channel. """ 35 | 36 | async def on_connect(self): 37 | await super().on_connect() 38 | # Can't greet many people without joining a channel. 39 | await self.join('#kochira') 40 | 41 | async def on_join(self, channel, user): 42 | await super().on_join(channel, user) 43 | await self.message(channel, 'Hey there, {user}!', user=user) 44 | 45 | client = MyClient('MyBot') 46 | await client.connect('irc.freenode.net', tls=True) 47 | client.handle_forever() 48 | 49 | This trivial example shows a few things: 50 | 51 | 1. :meth:`pydle.Client.on_connect` is a callback that gets invoked as soon as the client is fully connected to the server. 52 | 2. :meth:`pydle.Client.on_join` is a callback that gets invoked whenever a user joins a channel. 53 | 3. Trivially enough, we can use :meth:`pydle.Client.join` to instruct the client to join a channel. 54 | 4. Finally, we can use :meth:`pydle.Client.message` to send a message to a channel or to a user; 55 | it will even format the message for us according to `advanced string formatting`_. 56 | 57 | .. hint:: 58 | It is recommended to call the callbacks of the parent class using ``super()``, to make sure whatever functionality 59 | implemented by your parent classes gets called too: pydle will gracefully handle the call even if no functionality 60 | was implemented or no callbacks overridden. 61 | 62 | .. _`advanced string formatting`: http://legacy.python.org/dev/peps/pep-3101/ 63 | Authentication 64 | ----------------- 65 | Pydle can also handle authenticating against IRC services by default, all you need to do is tell 66 | it what its credentials are. 67 | 68 | .. note:: 69 | the server must support SASL based authentication. 70 | 71 | ----------- 72 | SASL(Username + password) 73 | ----------- 74 | To authenticate, pydle simply needs to be provided with a set of credentials to present during the 75 | connection process, the most common type being a username+password pair 76 | 77 | .. code:: python 78 | 79 | import pydle 80 | 81 | client = pydle.Client( 82 | nickname="my_irc_bot[bot]", 83 | sasl_username = "username", 84 | sasl_password = "my_secret_bot_password", 85 | sasl_identity = "account_to_identify_against", 86 | ) 87 | 88 | ----------- 89 | External authentication (Certificate) 90 | ----------- 91 | As an alternative to using passwords for credentials, certificates can also be used via the 92 | SASL (External) authentication method. 93 | 94 | All you need to do is tell pydle where it can find the certificate, which it will then present 95 | during the TLS handshake when connecting to the server. 96 | 97 | .. code:: python 98 | 99 | import pydle 100 | 101 | client = pydle.Client( 102 | nickname="my_irc_bot[bot]", 103 | sasl_mechanism = "EXTERNAL", 104 | tls_client_cert = "/path/to/client_certificate" 105 | ) 106 | 107 | .. note:: 108 | this authentication mode only works over TLS connections 109 | 110 | 111 | Multiple servers, multiple clients 112 | ---------------------------------- 113 | Any pydle client instance can only be connected to a single server. That doesn't mean that you are restricted 114 | to only being active on a single server at once, though. Using a :class:`pydle.ClientPool`, 115 | you can instantiate multiple clients, connect them to different servers using :meth:`pydle.ClientPool.connect`, 116 | and handle them within a single loop. 117 | 118 | .. code:: python 119 | 120 | import pydle 121 | 122 | class MyClient(pydle.Client): 123 | """ This is a simple bot that will greet people as they join the channel. """ 124 | 125 | async def on_connect(self): 126 | await super().on_connect() 127 | # Can't greet many people without joining a channel. 128 | await self.join('#kochira') 129 | 130 | async def on_join(self, channel, user): 131 | await super().on_join(channel, user) 132 | await self.message(channel, 'Hey there, {user}!', user=user) 133 | 134 | # Setup pool and connect clients. 135 | pool = pydle.ClientPool() 136 | servers = [ 'irc.freenode.net', 'irc.rizon.net', 'irc.esper.net' ] 137 | 138 | for server in servers: 139 | client = MyClient('MyBot') 140 | pool.connect(client, server, tls=True) 141 | 142 | # Handle all clients in the pool at once. 143 | pool.handle_forever() 144 | 145 | .. warning:: 146 | While multiple :class:`pydle.ClientPool` instances can be created and ran, you should ensure a client is only 147 | active in a single :class:`pydle.ClientPool` at once. Being active in multiple pools can lead to strange things 148 | like receiving messages twice, or interleaved outgoing messages. 149 | 150 | Mixing and matching 151 | ------------------- 152 | Thanks to pydle's modular "feature" system, you don't have to support everything you want to support. 153 | You can choose just to select the options you think you need for your client by using :func:`pydle.featurize` to create a base class 154 | out of the featured you need. 155 | 156 | .. code:: python 157 | 158 | import pydle 159 | 160 | # Create a client that just supports the base RFC1459 spec, CTCP and an IRC services-style account system. 161 | MyBaseClient = pydle.featurize(pydle.features.RFC1459Support, pydle.features.CTCPSupport, pydle.features.AccountSupport) 162 | 163 | class MyClient(MyBaseClient): 164 | ... 165 | 166 | 167 | A list of all available built-in features and their use can be found at the :doc:`API reference `. 168 | 169 | In addition to this, you can of course also write your own features. Feature writing is discussed thoroughly in the :doc:`feature section `. 170 | Once you have written a feature, you can just featurize it on top of an existing client class. 171 | 172 | .. code:: python 173 | 174 | import pydle 175 | import vendor 176 | 177 | # Add vendor feature on top of the base client. 178 | MyBaseClient = pydle.featurize(pydle.Client, vendor.VendorExtensionSupport) 179 | 180 | class MyClient(MyBaseClient): 181 | ... 182 | 183 | Asynchronous functionality 184 | -------------------------- 185 | Some actions inevitably require blocking and waiting for a result. Since pydle is an asynchronous library where a client runs in a single thread, 186 | doing this blindly could lead to issues like the operation blocking the handling of messages entirely. 187 | 188 | Fortunately, pydle utilizes asyncio coroutines_ which allow you to handle a blocking operation almost as if it were a regular operation, 189 | while still retaining the benefits of asynchronous program flow. Coroutines allow pydle to be notified when a blocking operation is done, 190 | and then resume execution of the calling function appropriately. That way, blocking operations do not block the entire program flow. 191 | 192 | In order for a function to be declared as a coroutine, it has to be declared as an ``async def`` function. 193 | It can then call functions that would normally block using Python's ``await`` operator. 194 | Since a function that calls a blocking function is itself blocking too, it has to be declared a coroutine as well. 195 | 196 | .. hint:: 197 | As with a lot of things, documentation is key. 198 | Documenting that your function does blocking operations lets the caller know how to call the function, 199 | and to include the fact that it calls blocking operations in its own documentation for its own callers. 200 | 201 | For example, if you are implementing an administrative system that works based off nicknames, you might want to check 202 | if the users are identified to ``NickServ``. However, WHOISing a user using :meth:`pydle.Client.whois` would be a blocking operation. 203 | Thanks to coroutines and :meth:`pydle.Client.whois` being a blocking operation compatible with coroutines, 204 | the act of WHOISing will not block the entire program flow of the client. 205 | 206 | .. code:: python 207 | 208 | import pydle 209 | ADMIN_NICKNAMES = [ 'Shiz', 'rfw' ] 210 | 211 | class MyClient(pydle.Client): 212 | """ 213 | This is a simple bot that will tell you if you're an administrator or not. 214 | A real bot with administrative-like capabilities would probably be better off maintaining a cache 215 | that would be invalidated upon parting, quitting or changing nicknames. 216 | """ 217 | 218 | async def on_connect(self): 219 | await super().on_connect() 220 | self.join('#kochira') 221 | 222 | 223 | async def is_admin(self, nickname): 224 | """ 225 | Check whether or not a user has administrative rights for this bot. 226 | This is a blocking function: use a coroutine to call it. 227 | See pydle's documentation on blocking functionality for details. 228 | """ 229 | admin = False 230 | 231 | # Check the WHOIS info to see if the source has identified with NickServ. 232 | # This is a blocking operation, so use yield. 233 | if source in ADMIN_NICKNAMES: 234 | info = await self.whois(source) 235 | admin = info['identified'] 236 | 237 | return admin 238 | 239 | 240 | async def on_message(self, target, source, message): 241 | await super().on_message(target, source, message) 242 | 243 | # Tell a user if they are an administrator for this bot. 244 | if message.startswith('!adminstatus'): 245 | admin = await self.is_admin(source) 246 | 247 | if admin: 248 | self.message(target, '{source}: You are an administrator.', source=source) 249 | else: 250 | self.message(target, '{source}: You are not an administrator.', source=source) 251 | 252 | Writing your own blocking operation that can work with coroutines is trivial: 253 | Simply use the existing asyncio apis: https://docs.python.org/3.7/library/asyncio-task.html#coroutines-and-tasks 254 | 255 | 256 | 257 | .. _coroutines: https://en.wikipedia.org/wiki/Coroutine 258 | -------------------------------------------------------------------------------- /pydle/__init__.py: -------------------------------------------------------------------------------- 1 | # noinspection PyUnresolvedReferences 2 | from asyncio import Future 3 | from functools import cmp_to_key 4 | from . import connection, protocol, client, features 5 | from .client import Error, NotInChannel, AlreadyInChannel, BasicClient, ClientPool 6 | from .features.ircv3.cap import NEGOTIATING as CAPABILITY_NEGOTIATING, FAILED as CAPABILITY_FAILED, \ 7 | NEGOTIATED as CAPABILITY_NEGOTIATED 8 | 9 | 10 | __name__ = 'pydle' 11 | __version__ = '1.0.1' 12 | __version_info__ = (1, 0, 1) 13 | __license__ = 'BSD' 14 | 15 | 16 | def featurize(*features): 17 | """ Put features into proper MRO order. """ 18 | 19 | def compare_subclass(left, right): 20 | if issubclass(left, right): 21 | return -1 22 | if issubclass(right, left): 23 | return 1 24 | return 0 25 | 26 | sorted_features = sorted(features, key=cmp_to_key(compare_subclass)) 27 | name = 'FeaturizedClient[{features}]'.format( 28 | features=', '.join(feature.__name__ for feature in sorted_features)) 29 | return type(name, tuple(sorted_features), {}) 30 | 31 | 32 | class Client(featurize(*features.ALL)): 33 | """ A fully featured IRC client. """ 34 | ... 35 | 36 | 37 | class MinimalClient(featurize(*features.LITE)): 38 | """ A cut-down, less-featured IRC client. """ 39 | ... 40 | -------------------------------------------------------------------------------- /pydle/client.py: -------------------------------------------------------------------------------- 1 | ## client.py 2 | # Basic IRC client implementation. 3 | import asyncio 4 | import logging 5 | from asyncio import new_event_loop, gather, get_event_loop, sleep 6 | import warnings 7 | from . import connection, protocol 8 | import inspect 9 | import functools 10 | 11 | __all__ = ['Error', 'AlreadyInChannel', 'NotInChannel', 'BasicClient', 'ClientPool'] 12 | DEFAULT_NICKNAME = '' 13 | 14 | 15 | class Error(Exception): 16 | """ Base class for all pydle errors. """ 17 | ... 18 | 19 | 20 | class NotInChannel(Error): 21 | def __init__(self, channel): 22 | super().__init__('Not in channel: {}'.format(channel)) 23 | self.channel = channel 24 | 25 | 26 | class AlreadyInChannel(Error): 27 | def __init__(self, channel): 28 | super().__init__('Already in channel: {}'.format(channel)) 29 | self.channel = channel 30 | 31 | 32 | class BasicClient: 33 | """ 34 | Base IRC client class. 35 | This class on its own is not complete: in order to be able to run properly, _has_message, _parse_message and _create_message have to be overloaded. 36 | """ 37 | READ_TIMEOUT = 300 38 | RECONNECT_ON_ERROR = True 39 | RECONNECT_MAX_ATTEMPTS = 3 40 | RECONNECT_DELAYED = True 41 | RECONNECT_DELAYS = [5, 5, 10, 30, 120, 600] 42 | 43 | @property 44 | def PING_TIMEOUT(self): 45 | warnings.warn( 46 | "PING_TIMEOUT has been moved to READ_TIMEOUT and may be removed in a future version. " 47 | "Please migrate to READ_TIMEOUT.", 48 | DeprecationWarning 49 | ) 50 | return self.READ_TIMEOUT 51 | 52 | @PING_TIMEOUT.setter 53 | def PING_TIMEOUT(self, value): 54 | warnings.warn( 55 | "PING_TIMEOUT has been moved to READ_TIMEOUT and may be removed in a future version", 56 | DeprecationWarning 57 | ) 58 | self.READ_TIMEOUT = value 59 | 60 | def __init__(self, nickname, fallback_nicknames=None, username=None, realname=None, 61 | eventloop=None, **kwargs): 62 | """ Create a client. """ 63 | self._nicknames = [nickname] + (fallback_nicknames or []) 64 | self.username = username or nickname.lower() 65 | self.realname = realname or nickname 66 | if eventloop: 67 | self.eventloop = eventloop 68 | else: 69 | self.eventloop = get_event_loop() 70 | 71 | self.own_eventloop = not eventloop 72 | self._reset_connection_attributes() 73 | self._reset_attributes() 74 | 75 | if kwargs: 76 | self.logger.warning('Unused arguments: %s', ', '.join(kwargs.keys())) 77 | 78 | def _reset_attributes(self): 79 | """ Reset attributes. """ 80 | # Record-keeping. 81 | self.channels = {} 82 | self.users = {} 83 | 84 | # Low-level data stuff. 85 | self._receive_buffer = b'' 86 | self._pending = {} 87 | self._handler_top_level = False 88 | 89 | # Misc. 90 | self.logger = logging.getLogger(__name__) 91 | 92 | # Public connection attributes. 93 | self.nickname = DEFAULT_NICKNAME 94 | self.network = None 95 | 96 | def _reset_connection_attributes(self): 97 | """ Reset connection attributes. """ 98 | self.connection = None 99 | self.encoding = None 100 | self._autojoin_channels = [] 101 | self._reconnect_attempts = 0 102 | 103 | ## Connection. 104 | 105 | def run(self, *args, **kwargs): 106 | """ Connect and run bot in event loop. """ 107 | self.eventloop.run_until_complete(self.connect(*args, **kwargs)) 108 | try: 109 | self.eventloop.run_forever() 110 | finally: 111 | self.eventloop.stop() 112 | 113 | async def connect(self, hostname=None, port=None, reconnect=False, **kwargs): 114 | """ Connect to IRC server. """ 115 | if (not hostname or not port) and not reconnect: 116 | raise ValueError('Have to specify hostname and port if not reconnecting.') 117 | 118 | # Disconnect from current connection. 119 | if self.connected: 120 | await self.disconnect(expected=True) 121 | 122 | # Reset attributes and connect. 123 | if not reconnect: 124 | self._reset_connection_attributes() 125 | await self._connect(hostname=hostname, port=port, reconnect=reconnect, **kwargs) 126 | 127 | # Set logger name. 128 | if self.server_tag: 129 | self.logger = logging.getLogger(self.__class__.__name__ + ':' + self.server_tag) 130 | 131 | self.eventloop.create_task(self.handle_forever()) 132 | 133 | async def disconnect(self, expected=True): 134 | """ Disconnect from server. """ 135 | if self.connected: 136 | # Schedule disconnect. 137 | await self._disconnect(expected) 138 | 139 | async def _disconnect(self, expected): 140 | # Shutdown connection. 141 | await self.connection.disconnect() 142 | 143 | # Reset any attributes. 144 | self._reset_attributes() 145 | 146 | # Callback. 147 | await self.on_disconnect(expected) 148 | 149 | # Shut down event loop. 150 | if expected and self.own_eventloop: 151 | self.connection.stop() 152 | 153 | async def _connect(self, hostname, port, reconnect=False, channels=None, 154 | encoding=protocol.DEFAULT_ENCODING, source_address=None): 155 | """ Connect to IRC host. """ 156 | # Create connection if we can't reuse it. 157 | if not reconnect or not self.connection: 158 | self._autojoin_channels = channels or [] 159 | self.connection = connection.Connection(hostname, port, source_address=source_address, 160 | eventloop=self.eventloop) 161 | self.encoding = encoding 162 | 163 | # Connect. 164 | await self.connection.connect() 165 | 166 | def _reconnect_delay(self): 167 | """ Calculate reconnection delay. """ 168 | if self.RECONNECT_ON_ERROR and self.RECONNECT_DELAYED: 169 | if self._reconnect_attempts >= len(self.RECONNECT_DELAYS): 170 | return self.RECONNECT_DELAYS[-1] 171 | return self.RECONNECT_DELAYS[self._reconnect_attempts] 172 | return 0 173 | 174 | ## Internal database management. 175 | 176 | def _create_channel(self, channel): 177 | self.channels[channel] = { 178 | 'users': set(), 179 | } 180 | 181 | def _destroy_channel(self, channel): 182 | # Copy set to prevent a runtime error when destroying the user. 183 | for user in set(self.channels[channel]['users']): 184 | self._destroy_user(user, channel) 185 | del self.channels[channel] 186 | 187 | def _create_user(self, nickname): 188 | # Servers are NOT users. 189 | if not nickname or '.' in nickname: 190 | return 191 | 192 | self.users[nickname] = { 193 | 'nickname': nickname, 194 | 'username': None, 195 | 'realname': None, 196 | 'hostname': None 197 | } 198 | 199 | async def _sync_user(self, nick, metadata): 200 | # Create user in database. 201 | if nick not in self.users: 202 | await self._create_user(nick) 203 | if nick not in self.users: 204 | return 205 | self.users[nick].update(metadata) 206 | 207 | async def _rename_user(self, user, new): 208 | if user in self.users: 209 | self.users[new] = self.users[user] 210 | self.users[new]['nickname'] = new 211 | del self.users[user] 212 | else: 213 | await self._create_user(new) 214 | if new not in self.users: 215 | return 216 | 217 | for ch in self.channels.values(): 218 | # Rename user in channel list. 219 | if user in ch['users']: 220 | ch['users'].discard(user) 221 | ch['users'].add(new) 222 | 223 | def _destroy_user(self, nickname, channel=None): 224 | if channel: 225 | channels = [self.channels[channel]] 226 | else: 227 | channels = self.channels.values() 228 | 229 | for ch in channels: 230 | # Remove from nicklist. 231 | ch['users'].discard(nickname) 232 | 233 | # If we're not in any common channels with the user anymore, we have no reliable way to keep their info up-to-date. 234 | # Remove the user. 235 | if not channel or not any(nickname in ch['users'] for ch in self.channels.values()): 236 | del self.users[nickname] 237 | 238 | def _parse_user(self, data): 239 | """ Parse user and return nickname, metadata tuple. """ 240 | raise NotImplementedError() 241 | 242 | def _format_user_mask(self, nickname): 243 | user = self.users.get(nickname, {"nickname": nickname, "username": "*", "hostname": "*"}) 244 | return self._format_host_mask(user['nickname'], user['username'] or '*', 245 | user['hostname'] or '*') 246 | 247 | def _format_host_mask(self, nick, user, host): 248 | return '{n}!{u}@{h}'.format(n=nick, u=user, h=host) 249 | 250 | ## IRC helpers. 251 | 252 | def is_channel(self, chan): 253 | """ Check if given argument is a channel name or not. """ 254 | return True 255 | 256 | def in_channel(self, channel): 257 | """ Check if we are currently in the given channel. """ 258 | return channel in self.channels.keys() 259 | 260 | def is_same_nick(self, left, right): 261 | """ Check if given nicknames are equal. """ 262 | return left == right 263 | 264 | def is_same_channel(self, left, right): 265 | """ Check if given channel names are equal. """ 266 | return left == right 267 | 268 | ## IRC attributes. 269 | 270 | @property 271 | def connected(self): 272 | """ Whether or not we are connected. """ 273 | return self.connection and self.connection.connected 274 | 275 | @property 276 | def server_tag(self): 277 | if self.connected and self.connection.hostname: 278 | if self.network: 279 | tag = self.network.lower() 280 | else: 281 | tag = self.connection.hostname.lower() 282 | 283 | # Remove hostname prefix. 284 | if tag.startswith('irc.'): 285 | tag = tag[4:] 286 | 287 | # Check if host is either an FQDN or IPv4. 288 | if '.' in tag: 289 | # Attempt to cut off TLD. 290 | host, suffix = tag.rsplit('.', 1) 291 | 292 | # Make sure we aren't cutting off the last octet of an IPv4. 293 | try: 294 | int(suffix) 295 | except ValueError: 296 | tag = host 297 | 298 | return tag 299 | return None 300 | 301 | ## IRC API. 302 | 303 | async def raw(self, message): 304 | """ Send raw command. """ 305 | await self._send(message) 306 | 307 | async def rawmsg(self, command, *args, **kwargs): 308 | """ Send raw message. """ 309 | message = str(self._create_message(command, *args, **kwargs)) 310 | await self._send(message) 311 | 312 | ## Overloadable callbacks. 313 | 314 | async def on_connect(self): 315 | """ Callback called when the client has connected successfully. """ 316 | # Reset reconnect attempts. 317 | self._reconnect_attempts = 0 318 | 319 | async def on_disconnect(self, expected): 320 | if not expected: 321 | # Unexpected disconnect. Reconnect? 322 | if self.RECONNECT_ON_ERROR and ( 323 | self.RECONNECT_MAX_ATTEMPTS is None or self._reconnect_attempts < self.RECONNECT_MAX_ATTEMPTS): 324 | # Calculate reconnect delay. 325 | delay = self._reconnect_delay() 326 | self._reconnect_attempts += 1 327 | 328 | if delay > 0: 329 | self.logger.error( 330 | 'Unexpected disconnect. Attempting to reconnect within %s seconds.', delay) 331 | else: 332 | self.logger.error('Unexpected disconnect. Attempting to reconnect.') 333 | 334 | # Wait and reconnect. 335 | await sleep(delay) 336 | await self.connect(reconnect=True) 337 | else: 338 | self.logger.error('Unexpected disconnect. Giving up.') 339 | 340 | ## Message dispatch. 341 | 342 | def _has_message(self): 343 | """ Whether or not we have messages available for processing. """ 344 | raise NotImplementedError() 345 | 346 | def _create_message(self, command, *params, **kwargs): 347 | raise NotImplementedError() 348 | 349 | def _parse_message(self): 350 | raise NotImplementedError() 351 | 352 | async def _send(self, input): 353 | if not isinstance(input, (bytes, str)): 354 | input = str(input) 355 | if isinstance(input, str): 356 | input = input.encode(self.encoding) 357 | 358 | self.logger.debug('>> %s', input.decode(self.encoding)) 359 | await self.connection.send(input) 360 | 361 | async def handle_forever(self): 362 | """ Handle data forever. """ 363 | while self.connected: 364 | try: 365 | data = await self.connection.recv(timeout=self.READ_TIMEOUT) 366 | except asyncio.TimeoutError: 367 | self.logger.warning( 368 | '>> Receive timeout reached, sending ping to check connection state...') 369 | 370 | try: 371 | await self.rawmsg("PING", self.server_tag) 372 | data = await self.connection.recv(timeout=self.READ_TIMEOUT) 373 | except (asyncio.TimeoutError, ConnectionResetError): 374 | data = None 375 | 376 | if not data: 377 | if self.connected: 378 | await self.disconnect(expected=False) 379 | break 380 | await self.on_data(data) 381 | 382 | ## Raw message handlers. 383 | 384 | async def on_data(self, data): 385 | """ Handle received data. """ 386 | self._receive_buffer += data 387 | 388 | while self._has_message(): 389 | message = self._parse_message() 390 | self.eventloop.create_task(self.on_raw(message)) 391 | 392 | async def on_data_error(self, exception): 393 | """ Handle error. """ 394 | self.logger.error('Encountered error on socket.', 395 | exc_info=(type(exception), exception, None)) 396 | await self.disconnect(expected=False) 397 | 398 | async def on_raw(self, message): 399 | """ Handle a single message. """ 400 | self.logger.debug('<< %s', message._raw) 401 | if not message._valid: 402 | self.logger.warning('Encountered strictly invalid IRC message from server: %s', 403 | message._raw) 404 | 405 | if isinstance(message.command, int): 406 | cmd = str(message.command).zfill(3) 407 | else: 408 | cmd = message.command 409 | 410 | # Invoke dispatcher, if we have one. 411 | method = 'on_raw_' + cmd.lower() 412 | try: 413 | # Set _top_level so __getattr__() can decide whether to return on_unknown or _ignored for unknown handlers. 414 | # The reason for this is that features can always call super().on_raw_* safely and thus don't need to care for other features, 415 | # while unknown messages for which no handlers exist at all are still logged. 416 | self._handler_top_level = True 417 | handler = getattr(self, method) 418 | self._handler_top_level = False 419 | 420 | await handler(message) 421 | except: 422 | self.logger.exception('Failed to execute %s handler.', method) 423 | 424 | async def on_unknown(self, message): 425 | """ Unknown command. """ 426 | self.logger.warning('Unknown command: [%s] %s %s', message.source, message.command, 427 | message.params) 428 | 429 | async def _ignored(self, message): 430 | """ Ignore message. """ 431 | ... 432 | 433 | def __getattr__(self, attr): 434 | """ Return on_unknown or _ignored for unknown handlers, depending on the invocation type. """ 435 | # Is this a raw handler? 436 | if attr.startswith('on_raw_'): 437 | # Are we in on_raw() trying to find any message handler? 438 | if self._handler_top_level: 439 | # In that case, return the method that logs and possibly acts on unknown messages. 440 | return self.on_unknown 441 | # Are we in an existing handler calling super()? 442 | # Just ignore it, then. 443 | return self._ignored 444 | 445 | # This isn't a handler, just raise an error. 446 | raise AttributeError(attr) 447 | 448 | # Bonus features 449 | def event(self, func): 450 | """ 451 | Registers the specified `func` to handle events of the same name. 452 | 453 | The func will always be called with, at least, the bot's `self` instance. 454 | 455 | Returns decorated func, unmodified. 456 | """ 457 | if not func.__name__.startswith("on_"): 458 | raise NameError("Event handlers must start with 'on_'.") 459 | 460 | if not inspect.iscoroutinefunction(func): 461 | raise AssertionError("Wrapped function {!r} must be an `async def` function.".format(func)) 462 | setattr(self, func.__name__, functools.partial(func, self)) 463 | 464 | return func 465 | 466 | 467 | class ClientPool: 468 | """ A pool of clients that are ran and handled in parallel. """ 469 | 470 | def __init__(self, clients=None, eventloop=None): 471 | self.eventloop = eventloop if eventloop else new_event_loop() 472 | self.clients = set(clients or []) 473 | self.connect_args = {} 474 | 475 | def connect(self, client: BasicClient, *args, **kwargs): 476 | """ Add client to pool. """ 477 | self.clients.add(client) 478 | self.connect_args[client] = (args, kwargs) 479 | # hack the clients event loop to use the pools own event loop 480 | client.eventloop = self.eventloop 481 | # necessary to run multiple clients in the same thread via the pool 482 | 483 | def disconnect(self, client): 484 | """ Remove client from pool. """ 485 | self.clients.remove(client) 486 | del self.connect_args[client] 487 | asyncio.run_coroutine_threadsafe(client.disconnect(expected=True), self.eventloop) 488 | 489 | def __contains__(self, item): 490 | return item in self.clients 491 | 492 | ## High-level. 493 | 494 | def handle_forever(self): 495 | """ Main loop of the pool: handle clients forever, until the event loop is stopped. """ 496 | # container for all the client connection coros 497 | connection_list = [] 498 | for client in self.clients: 499 | args, kwargs = self.connect_args[client] 500 | connection_list.append(client.connect(*args, **kwargs)) 501 | # single future for executing the connections 502 | connections = gather(*connection_list, loop=self.eventloop) 503 | 504 | # run the connections 505 | self.eventloop.run_until_complete(connections) 506 | 507 | # run the clients 508 | self.eventloop.run_forever() 509 | -------------------------------------------------------------------------------- /pydle/connection.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import os.path as path 3 | import ssl 4 | import sys 5 | 6 | __all__ = ['Connection'] 7 | 8 | DEFAULT_CA_PATHS = { 9 | 'linux': '/etc/ssl/certs', 10 | 'linux2': '/etc/ssl/certs', 11 | 'freebsd': '/etc/ssl/certs' 12 | } 13 | 14 | MESSAGE_THROTTLE_TRESHOLD = 3 15 | MESSAGE_THROTTLE_DELAY = 2 16 | 17 | 18 | class Connection: 19 | """ A TCP connection over the IRC protocol. """ 20 | CONNECT_TIMEOUT = 10 21 | 22 | def __init__(self, hostname, port, tls=False, tls_verify=True, tls_certificate_file=None, 23 | tls_certificate_keyfile=None, tls_certificate_password=None, ping_timeout=240, 24 | source_address=None, eventloop=None): 25 | self.hostname = hostname 26 | self.port = port 27 | self.source_address = source_address 28 | self.ping_timeout = ping_timeout 29 | 30 | self.tls = tls 31 | self.tls_context = None 32 | self.tls_verify = tls_verify 33 | self.tls_certificate_file = tls_certificate_file 34 | self.tls_certificate_keyfile = tls_certificate_keyfile 35 | self.tls_certificate_password = tls_certificate_password 36 | 37 | self.reader = None 38 | self.writer = None 39 | self.eventloop = eventloop or asyncio.new_event_loop() 40 | 41 | async def connect(self): 42 | """ Connect to target. """ 43 | self.tls_context = None 44 | 45 | if self.tls: 46 | self.tls_context = self.create_tls_context() 47 | 48 | (self.reader, self.writer) = await asyncio.open_connection( 49 | host=self.hostname, 50 | port=self.port, 51 | local_addr=self.source_address, 52 | ssl=self.tls_context, 53 | loop=self.eventloop 54 | ) 55 | 56 | def create_tls_context(self): 57 | """ Transform our regular socket into a TLS socket. """ 58 | # Create context manually, as we're going to set our own options. 59 | tls_context = ssl.SSLContext(ssl.PROTOCOL_SSLv23) 60 | 61 | # Load client/server certificate. 62 | if self.tls_certificate_file: 63 | tls_context.load_cert_chain(self.tls_certificate_file, self.tls_certificate_keyfile, 64 | password=self.tls_certificate_password) 65 | 66 | # Set some relevant options: 67 | # - No server should use SSLv2 or SSLv3 any more, they are outdated and full of security holes. (RFC6176, RFC7568) 68 | # - Disable compression in order to counter the CRIME attack. (https://en.wikipedia.org/wiki/CRIME_%28security_exploit%29) 69 | # - Disable session resumption to maintain perfect forward secrecy. (https://timtaubert.de/blog/2014/11/the-sad-state-of-server-side-tls-session-resumption-implementations/) 70 | for opt in ['NO_SSLv2', 'NO_SSLv3', 'NO_COMPRESSION', 'NO_TICKET']: 71 | if hasattr(ssl, 'OP_' + opt): 72 | tls_context.options |= getattr(ssl, 'OP_' + opt) 73 | 74 | # Set TLS verification options. 75 | if self.tls_verify: 76 | # Load certificate verification paths. 77 | tls_context.set_default_verify_paths() 78 | if sys.platform in DEFAULT_CA_PATHS and path.isdir(DEFAULT_CA_PATHS[sys.platform]): 79 | tls_context.load_verify_locations(capath=DEFAULT_CA_PATHS[sys.platform]) 80 | 81 | # If we want to verify the TLS connection, we first need a certicate. 82 | tls_context.verify_mode = ssl.CERT_REQUIRED 83 | 84 | # And have python call match_hostname in do_handshake 85 | tls_context.check_hostname = True 86 | 87 | # We don't check for revocation, because that's impractical still (https://www.imperialviolet.org/2012/02/05/crlsets.html) 88 | 89 | return tls_context 90 | 91 | async def disconnect(self): 92 | """ Disconnect from target. """ 93 | if not self.connected: 94 | return 95 | 96 | self.writer.close() 97 | self.reader = None 98 | self.writer = None 99 | 100 | @property 101 | def connected(self): 102 | """ Whether this connection is... connected to something. """ 103 | return self.reader is not None and self.writer is not None 104 | 105 | def stop(self): 106 | """ Stop event loop. """ 107 | self.eventloop.call_soon(self.eventloop.stop) 108 | 109 | async def send(self, data): 110 | """ Add data to send queue. """ 111 | self.writer.write(data) 112 | await self.writer.drain() 113 | 114 | async def recv(self, *, timeout=None): 115 | return await asyncio.wait_for(self.reader.readline(), timeout=timeout) 116 | -------------------------------------------------------------------------------- /pydle/features/__init__.py: -------------------------------------------------------------------------------- 1 | from . import rfc1459, account, ctcp, tls, isupport, whox, ircv3 2 | 3 | from .rfc1459 import RFC1459Support 4 | from .account import AccountSupport 5 | from .ctcp import CTCPSupport 6 | from .tls import TLSSupport 7 | from .isupport import ISUPPORTSupport 8 | from .whox import WHOXSupport 9 | from .ircv3 import IRCv3Support, IRCv3_1Support, IRCv3_2Support 10 | from .rpl_whoishost import RplWhoisHostSupport 11 | 12 | ALL = [IRCv3Support, WHOXSupport, ISUPPORTSupport, CTCPSupport, AccountSupport, TLSSupport, RFC1459Support, 13 | RplWhoisHostSupport] 14 | LITE = [WHOXSupport, ISUPPORTSupport, CTCPSupport, TLSSupport, RFC1459Support] 15 | -------------------------------------------------------------------------------- /pydle/features/account.py: -------------------------------------------------------------------------------- 1 | ## account.py 2 | # Account system support. 3 | from pydle.features import rfc1459 4 | 5 | 6 | class AccountSupport(rfc1459.RFC1459Support): 7 | 8 | ## Internal. 9 | 10 | def _create_user(self, nickname): 11 | super()._create_user(nickname) 12 | if nickname in self.users: 13 | self.users[nickname].update({ 14 | 'account': None, 15 | 'identified': False 16 | }) 17 | 18 | async def _rename_user(self, user, new): 19 | await super()._rename_user(user, new) 20 | # Unset account info to be certain until we get a new response. 21 | await self._sync_user(new, {'account': None, 'identified': False}) 22 | await self.whois(new) 23 | 24 | ## IRC API. 25 | async def whois(self, nickname): 26 | info = await super().whois(nickname) 27 | if info is None: 28 | return info 29 | info.setdefault('account', None) 30 | info.setdefault('identified', False) 31 | return info 32 | 33 | ## Message handlers. 34 | 35 | async def on_raw_307(self, message): 36 | """ WHOIS: User has identified for this nickname. (Anope) """ 37 | target, nickname = message.params[:2] 38 | info = { 39 | 'identified': True 40 | } 41 | 42 | if nickname in self.users: 43 | await self._sync_user(nickname, info) 44 | if nickname in self._pending['whois']: 45 | self._whois_info[nickname].update(info) 46 | 47 | async def on_raw_330(self, message): 48 | """ WHOIS account name (Atheme). """ 49 | target, nickname, account = message.params[:3] 50 | info = { 51 | 'account': account, 52 | 'identified': True 53 | } 54 | 55 | if nickname in self.users: 56 | await self._sync_user(nickname, info) 57 | if nickname in self._pending['whois']: 58 | self._whois_info[nickname].update(info) 59 | -------------------------------------------------------------------------------- /pydle/features/ctcp.py: -------------------------------------------------------------------------------- 1 | ## ctcp.py 2 | # Client-to-Client-Protocol (CTCP) support. 3 | import pydle 4 | import pydle.protocol 5 | from pydle.features import rfc1459 6 | from pydle import client 7 | __all__ = ['CTCPSupport'] 8 | 9 | 10 | CTCP_DELIMITER = '\x01' 11 | CTCP_ESCAPE_CHAR = '\x16' 12 | 13 | 14 | class CTCPSupport(rfc1459.RFC1459Support): 15 | """ Support for CTCP messages. """ 16 | 17 | ## Callbacks. 18 | 19 | async def on_ctcp(self, by, target, what, contents): 20 | """ 21 | Callback called when the user received a CTCP message. 22 | Client subclasses can override on_ctcp_ to be called when receiving a message of that specific CTCP type, 23 | in addition to this callback. 24 | """ 25 | ... 26 | 27 | async def on_ctcp_reply(self, by, target, what, response): 28 | """ 29 | Callback called when the user received a CTCP response. 30 | Client subclasses can override on_ctcp__reply to be called when receiving a reply of that specific CTCP type, 31 | in addition to this callback. 32 | """ 33 | ... 34 | 35 | async def on_ctcp_version(self, by, target, contents): 36 | """ Built-in CTCP version as some networks seem to require it. """ 37 | 38 | version = '{name} v{ver}'.format(name=pydle.__name__, ver=pydle.__version__) 39 | await self.ctcp_reply(by, 'VERSION', version) 40 | 41 | ## IRC API. 42 | 43 | async def ctcp(self, target, query, contents=None): 44 | """ Send a CTCP request to a target. """ 45 | if self.is_channel(target) and not self.in_channel(target): 46 | raise client.NotInChannel(target) 47 | 48 | await self.message(target, construct_ctcp(query, contents)) 49 | 50 | async def ctcp_reply(self, target, query, response): 51 | """ Send a CTCP reply to a target. """ 52 | if self.is_channel(target) and not self.in_channel(target): 53 | raise client.NotInChannel(target) 54 | 55 | await self.notice(target, construct_ctcp(query, response)) 56 | 57 | ## Handler overrides. 58 | 59 | async def on_raw_privmsg(self, message): 60 | """ Modify PRIVMSG to redirect CTCP messages. """ 61 | nick, metadata = self._parse_user(message.source) 62 | target, msg = message.params 63 | 64 | if is_ctcp(msg): 65 | await self._sync_user(nick, metadata) 66 | type, contents = parse_ctcp(msg) 67 | 68 | # Find dedicated handler if it exists. 69 | attr = 'on_ctcp_' + pydle.protocol.identifierify(type) 70 | if hasattr(self, attr): 71 | await getattr(self, attr)(nick, target, contents) 72 | # Invoke global handler. 73 | await self.on_ctcp(nick, target, type, contents) 74 | else: 75 | await super().on_raw_privmsg(message) 76 | 77 | async def on_raw_notice(self, message): 78 | """ Modify NOTICE to redirect CTCP messages. """ 79 | nick, metadata = self._parse_user(message.source) 80 | target, msg = message.params 81 | 82 | if is_ctcp(msg): 83 | await self._sync_user(nick, metadata) 84 | _type, response = parse_ctcp(msg) 85 | 86 | # Find dedicated handler if it exists. 87 | attr = 'on_ctcp_' + pydle.protocol.identifierify(_type) + '_reply' 88 | if hasattr(self, attr): 89 | await getattr(self, attr)(nick, target, response) 90 | # Invoke global handler. 91 | await self.on_ctcp_reply(nick, target, _type, response) 92 | else: 93 | await super().on_raw_notice(message) 94 | 95 | 96 | ## Helpers. 97 | 98 | def is_ctcp(message): 99 | """ Check if message follows the CTCP format. """ 100 | return message.startswith(CTCP_DELIMITER) and message.endswith(CTCP_DELIMITER) 101 | 102 | 103 | def construct_ctcp(*parts): 104 | """ Construct CTCP message. """ 105 | message = ' '.join(parts) 106 | message = message.replace('\0', CTCP_ESCAPE_CHAR + '0') 107 | message = message.replace('\n', CTCP_ESCAPE_CHAR + 'n') 108 | message = message.replace('\r', CTCP_ESCAPE_CHAR + 'r') 109 | message = message.replace(CTCP_ESCAPE_CHAR, CTCP_ESCAPE_CHAR + CTCP_ESCAPE_CHAR) 110 | return CTCP_DELIMITER + message + CTCP_DELIMITER 111 | 112 | 113 | def parse_ctcp(query): 114 | """ Strip and de-quote CTCP messages. """ 115 | query = query.strip(CTCP_DELIMITER) 116 | query = query.replace(CTCP_ESCAPE_CHAR + '0', '\0') 117 | query = query.replace(CTCP_ESCAPE_CHAR + 'n', '\n') 118 | query = query.replace(CTCP_ESCAPE_CHAR + 'r', '\r') 119 | query = query.replace(CTCP_ESCAPE_CHAR + CTCP_ESCAPE_CHAR, CTCP_ESCAPE_CHAR) 120 | if ' ' in query: 121 | return query.split(' ', 1) 122 | return query, None 123 | -------------------------------------------------------------------------------- /pydle/features/ircv3/__init__.py: -------------------------------------------------------------------------------- 1 | ## IRCv3.1 support. 2 | from . import cap, sasl, ircv3_1 3 | 4 | from .cap import CapabilityNegotiationSupport 5 | from .sasl import SASLSupport 6 | from .ircv3_1 import IRCv3_1Support 7 | 8 | 9 | ## IRCv3.2 support. 10 | from . import monitor, tags, ircv3_2 11 | 12 | from .monitor import MonitoringSupport 13 | from .tags import TaggedMessageSupport 14 | from .metadata import MetadataSupport 15 | from .ircv3_2 import IRCv3_2Support 16 | 17 | 18 | ## IRCv3.3 support. 19 | from . import ircv3_3 20 | 21 | from .ircv3_3 import IRCv3_3Support 22 | 23 | 24 | class IRCv3Support(IRCv3_3Support, IRCv3_2Support, IRCv3_1Support): 25 | pass 26 | -------------------------------------------------------------------------------- /pydle/features/ircv3/cap.py: -------------------------------------------------------------------------------- 1 | ## cap.py 2 | # Server <-> client optional extension indication support. 3 | # See also: http://ircv3.atheme.org/specification/capability-negotiation-3.1 4 | import pydle.protocol 5 | from pydle.features import rfc1459 6 | 7 | __all__ = ['CapabilityNegotiationSupport', 'NEGOTIATED', 'NEGOTIATING', 'FAILED'] 8 | 9 | 10 | DISABLED_PREFIX = '-' 11 | ACKNOWLEDGEMENT_REQUIRED_PREFIX = '~' 12 | STICKY_PREFIX = '=' 13 | PREFIXES = '-~=' 14 | CAPABILITY_VALUE_DIVIDER = '=' 15 | NEGOTIATING = True 16 | NEGOTIATED = None 17 | FAILED = False 18 | 19 | 20 | class CapabilityNegotiationSupport(rfc1459.RFC1459Support): 21 | """ CAP command support. """ 22 | 23 | ## Internal overrides. 24 | 25 | def _reset_attributes(self): 26 | super()._reset_attributes() 27 | self._capabilities = {} 28 | self._capabilities_requested = set() 29 | self._capabilities_negotiating = set() 30 | 31 | async def _register(self): 32 | """ Hijack registration to send a CAP LS first. """ 33 | if self.registered: 34 | self.logger.debug("skipping cap registration, already registered!") 35 | return 36 | 37 | # Ask server to list capabilities. 38 | await self.rawmsg('CAP', 'LS', '302') 39 | 40 | # Register as usual. 41 | await super()._register() 42 | 43 | def _capability_normalize(self, cap): 44 | cap = cap.lstrip(PREFIXES).lower() 45 | if CAPABILITY_VALUE_DIVIDER in cap: 46 | cap, _, value = cap.partition(CAPABILITY_VALUE_DIVIDER) 47 | else: 48 | value = None 49 | 50 | return cap, value 51 | 52 | ## API. 53 | 54 | async def _capability_negotiated(self, capab): 55 | """ Mark capability as negotiated, and end negotiation if we're done. """ 56 | self._capabilities_negotiating.discard(capab) 57 | 58 | if not self._capabilities_requested and not self._capabilities_negotiating: 59 | await self.rawmsg('CAP', 'END') 60 | 61 | ## Message handlers. 62 | 63 | async def on_raw_cap(self, message): 64 | """ Handle CAP message. """ 65 | target, subcommand = message.params[:2] 66 | params = message.params[2:] 67 | 68 | # Call handler. 69 | attr = 'on_raw_cap_' + pydle.protocol.identifierify(subcommand) 70 | if hasattr(self, attr): 71 | await getattr(self, attr)(params) 72 | else: 73 | self.logger.warning('Unknown CAP subcommand sent from server: %s', subcommand) 74 | 75 | async def on_raw_cap_ls(self, params): 76 | """ Update capability mapping. Request capabilities. """ 77 | to_request = set() 78 | 79 | for capab in params[0].split(): 80 | capab, value = self._capability_normalize(capab) 81 | 82 | # Only process new capabilities. 83 | if capab in self._capabilities: 84 | continue 85 | 86 | # Check if we support the capability. 87 | attr = 'on_capability_' + pydle.protocol.identifierify(capab) + '_available' 88 | supported = (await getattr(self, attr)(value)) if hasattr(self, attr) else False 89 | 90 | if supported: 91 | if isinstance(supported, str): 92 | to_request.add(capab + CAPABILITY_VALUE_DIVIDER + supported) 93 | else: 94 | to_request.add(capab) 95 | else: 96 | self._capabilities[capab] = False 97 | 98 | if to_request: 99 | # Request some capabilities. 100 | self._capabilities_requested.update(x.split(CAPABILITY_VALUE_DIVIDER, 1)[0] for x in to_request) 101 | await self.rawmsg('CAP', 'REQ', ' '.join(to_request)) 102 | else: 103 | # No capabilities requested, end negotiation. 104 | await self.rawmsg('CAP', 'END') 105 | 106 | async def on_raw_cap_list(self, params): 107 | """ Update active capabilities. """ 108 | self._capabilities = {capab: False for capab in self._capabilities} 109 | 110 | for capab in params[0].split(): 111 | capab, value = self._capability_normalize(capab) 112 | self._capabilities[capab] = value if value else True 113 | 114 | async def on_raw_cap_ack(self, params): 115 | """ Update active capabilities: requested capability accepted. """ 116 | for capab in params[0].split(): 117 | cp, value = self._capability_normalize(capab) 118 | self._capabilities_requested.discard(cp) 119 | 120 | # Determine capability type and callback. 121 | if capab.startswith(DISABLED_PREFIX): 122 | self._capabilities[cp] = False 123 | attr = 'on_capability_' + pydle.protocol.identifierify(cp) + '_disabled' 124 | elif capab.startswith(STICKY_PREFIX): 125 | # Can't disable it. Do nothing. 126 | self.logger.error('Could not disable capability %s.', cp) 127 | continue 128 | else: 129 | self._capabilities[cp] = value if value else True 130 | attr = 'on_capability_' + pydle.protocol.identifierify(cp) + '_enabled' 131 | 132 | # Indicate we're gonna use this capability if needed. 133 | if capab.startswith(ACKNOWLEDGEMENT_REQUIRED_PREFIX): 134 | await self.rawmsg('CAP', 'ACK', cp) 135 | 136 | # Run callback. 137 | if hasattr(self, attr): 138 | status = await getattr(self, attr)() 139 | else: 140 | status = NEGOTIATED 141 | 142 | # If the process needs more time, add it to the database and end later. 143 | if status == NEGOTIATING: 144 | self._capabilities_negotiating.add(cp) 145 | elif status == FAILED: 146 | # Ruh-roh, negotiation failed. Disable the capability. 147 | self.logger.warning('Capability negotiation for %s failed. Attempting to disable capability again.', cp) 148 | 149 | await self.rawmsg('CAP', 'REQ', '-' + cp) 150 | self._capabilities_requested.add(cp) 151 | 152 | # If we have no capabilities left to process, end it. 153 | if not self._capabilities_requested and not self._capabilities_negotiating: 154 | await self.rawmsg('CAP', 'END') 155 | 156 | async def on_raw_cap_nak(self, params): 157 | """ Update active capabilities: requested capability rejected. """ 158 | for capab in params[0].split(): 159 | capab, _ = self._capability_normalize(capab) 160 | self._capabilities[capab] = False 161 | self._capabilities_requested.discard(capab) 162 | 163 | # If we have no capabilities left to process, end it. 164 | if not self._capabilities_requested and not self._capabilities_negotiating: 165 | await self.rawmsg('CAP', 'END') 166 | 167 | async def on_raw_cap_del(self, params): 168 | for capab in params[0].split(): 169 | attr = 'on_capability_{}_disabled'.format(pydle.protocol.identifierify(capab)) 170 | if self._capabilities.get(capab, False) and hasattr(self, attr): 171 | await getattr(self, attr)() 172 | await self.on_raw_cap_nak(params) 173 | 174 | async def on_raw_cap_new(self, params): 175 | await self.on_raw_cap_ls(params) 176 | 177 | async def on_raw_410(self, message): 178 | """ Unknown CAP subcommand or CAP error. Force-end negotiations. """ 179 | self.logger.error('Server sent "Unknown CAP subcommand: %s". Aborting capability negotiation.', message.params[0]) 180 | 181 | self._capabilities_requested = set() 182 | self._capabilities_negotiating = set() 183 | await self.rawmsg('CAP', 'END') 184 | 185 | async def on_raw_421(self, message): 186 | """ Hijack to ignore the absence of a CAP command. """ 187 | if message.params[0] == 'CAP': 188 | return 189 | await super().on_raw_421(message) 190 | 191 | async def on_raw_451(self, message): 192 | """ Hijack to ignore the absence of a CAP command. """ 193 | if message.params[0] == 'CAP': 194 | return 195 | await super().on_raw_451(message) 196 | -------------------------------------------------------------------------------- /pydle/features/ircv3/ircv3_1.py: -------------------------------------------------------------------------------- 1 | ## ircv3_1.py 2 | # IRCv3.1 full spec support. 3 | from pydle.features import account, tls 4 | from . import cap 5 | from . import sasl 6 | 7 | __all__ = ['IRCv3_1Support'] 8 | 9 | 10 | NO_ACCOUNT = '*' 11 | 12 | 13 | class IRCv3_1Support(sasl.SASLSupport, cap.CapabilityNegotiationSupport, account.AccountSupport, tls.TLSSupport): 14 | """ Support for IRCv3.1's base and optional extensions. """ 15 | 16 | async def _rename_user(self, user, new): 17 | # If the server supports account-notify, we will be told about the registration status changing. 18 | # As such, we can skip the song and dance pydle.features.account does. 19 | if self._capabilities.get('account-notify', False): 20 | account = self.users.get(user, {}).get('account', None) 21 | identified = self.users.get(user, {}).get('identified', False) 22 | 23 | await super()._rename_user(user, new) 24 | 25 | if self._capabilities.get('account-notify', False): 26 | await self._sync_user(new, {'account': account, 'identified': identified}) 27 | 28 | ## IRC callbacks. 29 | 30 | async def on_capability_account_notify_available(self, value): 31 | """ Take note of user account changes. """ 32 | return True 33 | 34 | async def on_capability_away_notify_available(self, value): 35 | """ Take note of AWAY messages. """ 36 | return True 37 | 38 | async def on_capability_extended_join_available(self, value): 39 | """ Take note of user account and realname on JOIN. """ 40 | return True 41 | 42 | async def on_capability_multi_prefix_available(self, value): 43 | """ Thanks to how underlying client code works we already support multiple prefixes. """ 44 | return True 45 | 46 | async def on_capability_tls_available(self, value): 47 | """ We never need to request this explicitly. """ 48 | return False 49 | 50 | ## Message handlers. 51 | 52 | async def on_raw_account(self, message): 53 | """ Changes in the associated account for a nickname. """ 54 | if not self._capabilities.get('account-notify', False): 55 | return 56 | 57 | nick, metadata = self._parse_user(message.source) 58 | account = message.params[0] 59 | 60 | if nick not in self.users: 61 | return 62 | 63 | await self._sync_user(nick, metadata) 64 | if account == NO_ACCOUNT: 65 | await self._sync_user(nick, {'account': None, 'identified': False}) 66 | else: 67 | await self._sync_user(nick, {'account': account, 'identified': True}) 68 | 69 | async def on_raw_away(self, message): 70 | """ Process AWAY messages. """ 71 | if 'away-notify' not in self._capabilities or not self._capabilities['away-notify']: 72 | return 73 | 74 | nick, metadata = self._parse_user(message.source) 75 | if nick not in self.users: 76 | return 77 | 78 | await self._sync_user(nick, metadata) 79 | self.users[nick]['away'] = len(message.params) > 0 80 | self.users[nick]['away_message'] = message.params[0] if len(message.params) > 0 else None 81 | 82 | async def on_raw_join(self, message): 83 | """ Process extended JOIN messages. """ 84 | if 'extended-join' in self._capabilities and self._capabilities['extended-join']: 85 | nick, metadata = self._parse_user(message.source) 86 | channels, account, realname = message.params 87 | 88 | await self._sync_user(nick, metadata) 89 | 90 | # Emit a fake join message. 91 | fakemsg = self._create_message('JOIN', channels, source=message.source) 92 | await super().on_raw_join(fakemsg) 93 | 94 | if account == NO_ACCOUNT: 95 | account = None 96 | self.users[nick]['account'] = account 97 | self.users[nick]['realname'] = realname 98 | else: 99 | await super().on_raw_join(message) 100 | -------------------------------------------------------------------------------- /pydle/features/ircv3/ircv3_2.py: -------------------------------------------------------------------------------- 1 | ## ircv3_2.py 2 | # IRCv3.2 support (in progress). 3 | from . import ircv3_1 4 | from . import tags 5 | from . import monitor 6 | from . import metadata 7 | 8 | __all__ = ['IRCv3_2Support'] 9 | 10 | 11 | class IRCv3_2Support(metadata.MetadataSupport, monitor.MonitoringSupport, tags.TaggedMessageSupport, ircv3_1.IRCv3_1Support): 12 | """ Support for some of IRCv3.2's extensions. """ 13 | 14 | ## IRC callbacks. 15 | 16 | async def on_capability_account_tag_available(self, value): 17 | """ Add an account message tag to user messages. """ 18 | return True 19 | 20 | async def on_capability_cap_notify_available(self, value): 21 | """ Take note of new or removed capabilities. """ 22 | return True 23 | 24 | async def on_capability_chghost_available(self, value): 25 | """ Server reply to indicate a user we are in a common channel with changed user and/or host. """ 26 | return True 27 | 28 | async def on_capability_echo_message_available(self, value): 29 | """ Echo PRIVMSG and NOTICEs back to client. """ 30 | return True 31 | 32 | async def on_capability_invite_notify_available(self, value): 33 | """ Broadcast invite messages to certain other clients. """ 34 | return True 35 | 36 | async def on_capability_userhost_in_names_available(self, value): 37 | """ Show full user!nick@host in NAMES list. We already parse it like that. """ 38 | return True 39 | 40 | async def on_capability_uhnames_available(self, value): 41 | """ Possibly outdated alias for userhost-in-names. """ 42 | return await self.on_capability_userhost_in_names_available(value) 43 | 44 | async def on_isupport_uhnames(self, value): 45 | """ Let the server know that we support UHNAMES using the old ISUPPORT method, for legacy support. """ 46 | await self.rawmsg('PROTOCTL', 'UHNAMES') 47 | 48 | ## API overrides. 49 | 50 | async def message(self, target, message): 51 | await super().message(target, message) 52 | if not self._capabilities.get('echo-message'): 53 | await self.on_message(target, self.nickname, message) 54 | if self.is_channel(target): 55 | await self.on_channel_message(target, self.nickname, message) 56 | else: 57 | await self.on_private_message(target, self.nickname, message) 58 | 59 | async def notice(self, target, message): 60 | await super().notice(target, message) 61 | if not self._capabilities.get('echo-message'): 62 | await self.on_notice(target, self.nickname, message) 63 | if self.is_channel(target): 64 | await self.on_channel_notice(target, self.nickname, message) 65 | else: 66 | await self.on_private_notice(target, self.nickname, message) 67 | 68 | ## Message handlers. 69 | 70 | async def on_raw(self, message): 71 | if 'account' in message.tags: 72 | nick, _ = self._parse_user(message.source) 73 | if nick in self.users: 74 | metadata = { 75 | 'identified': True, 76 | 'account': message.tags['account'] 77 | } 78 | await self._sync_user(nick, metadata) 79 | await super().on_raw(message) 80 | 81 | async def on_raw_chghost(self, message): 82 | """ Change user and/or host of user. """ 83 | if 'chghost' not in self._capabilities or not self._capabilities['chghost']: 84 | return 85 | 86 | nick, _ = self._parse_user(message.source) 87 | if nick not in self.users: 88 | return 89 | 90 | # Update user and host. 91 | metadata = { 92 | 'username': message.params[0], 93 | 'hostname': message.params[1] 94 | } 95 | await self._sync_user(nick, metadata) 96 | -------------------------------------------------------------------------------- /pydle/features/ircv3/ircv3_3.py: -------------------------------------------------------------------------------- 1 | ## ircv3_3.py 2 | # IRCv3.3 support (in progress). 3 | from . import ircv3_2 4 | 5 | __all__ = ['IRCv3_3Support'] 6 | 7 | 8 | class IRCv3_3Support(ircv3_2.IRCv3_2Support): 9 | """ Support for some of IRCv3.3's extensions. """ 10 | 11 | ## IRC callbacks. 12 | 13 | async def on_capability_message_tags_available(self, value): 14 | """ Indicate that we can in fact parse arbitrary tags. """ 15 | return True 16 | -------------------------------------------------------------------------------- /pydle/features/ircv3/metadata.py: -------------------------------------------------------------------------------- 1 | from . import cap 2 | 3 | VISIBLITY_ALL = '*' 4 | 5 | 6 | class MetadataSupport(cap.CapabilityNegotiationSupport): 7 | 8 | ## Internals. 9 | 10 | def _reset_attributes(self): 11 | super()._reset_attributes() 12 | 13 | self._pending['metadata'] = {} 14 | self._metadata_info = {} 15 | self._metadata_queue = [] 16 | 17 | ## IRC API. 18 | 19 | async def get_metadata(self, target): 20 | """ 21 | Return user metadata information. 22 | This is a blocking asynchronous method: it has to be called from a coroutine, as follows: 23 | 24 | metadata = await self.get_metadata('#foo') 25 | """ 26 | if target not in self._pending['metadata']: 27 | await self.rawmsg('METADATA', target, 'LIST') 28 | 29 | self._metadata_queue.append(target) 30 | self._metadata_info[target] = {} 31 | self._pending['metadata'][target] = self.eventloop.create_future() 32 | 33 | return self._pending['metadata'][target] 34 | 35 | async def set_metadata(self, target, key, value): 36 | await self.rawmsg('METADATA', target, 'SET', key, value) 37 | 38 | async def unset_metadata(self, target, key): 39 | await self.rawmsg('METADATA', target, 'SET', key) 40 | 41 | async def clear_metadata(self, target): 42 | await self.rawmsg('METADATA', target, 'CLEAR') 43 | 44 | ## Callbacks. 45 | 46 | async def on_metadata(self, target, key, value, visibility=None): 47 | pass 48 | 49 | ## Message handlers. 50 | 51 | async def on_capability_metadata_notify_available(self, value): 52 | return True 53 | 54 | async def on_raw_metadata(self, message): 55 | """ Metadata event. """ 56 | target, targetmeta = self._parse_user(message.params[0]) 57 | key, visibility, value = message.params[1:4] 58 | if visibility == VISIBLITY_ALL: 59 | visibility = None 60 | 61 | if target in self.users: 62 | await self._sync_user(target, targetmeta) 63 | await self.on_metadata(target, key, value, visibility=visibility) 64 | 65 | async def on_raw_760(self, message): 66 | """ Metadata key/value for whois. """ 67 | target, targetmeta = self._parse_user(message.params[0]) 68 | key, _, value = message.params[1:4] 69 | 70 | if target not in self._pending['whois']: 71 | return 72 | if target in self.users: 73 | await self._sync_user(target, targetmeta) 74 | 75 | self._whois_info[target].setdefault('metadata', {}) 76 | self._whois_info[target]['metadata'][key] = value 77 | 78 | async def on_raw_761(self, message): 79 | """ Metadata key/value. """ 80 | target, targetmeta = self._parse_user(message.params[0]) 81 | key, visibility = message.params[1:3] 82 | value = message.params[3] if len(message.params) > 3 else None 83 | 84 | if target not in self._pending['metadata']: 85 | return 86 | if target in self.users: 87 | await self._sync_user(target, targetmeta) 88 | 89 | self._metadata_info[target][key] = value 90 | 91 | async def on_raw_762(self, message): 92 | """ End of metadata. """ 93 | # No way to figure out whose query this belongs to, so make a best guess 94 | # it was the first one. 95 | if not self._metadata_queue: 96 | return 97 | nickname = self._metadata_queue.pop() 98 | 99 | future = self._pending['metadata'].pop(nickname) 100 | future.set_result(self._metadata_info.pop(nickname)) 101 | 102 | async def on_raw_764(self, message): 103 | """ Metadata limit reached. """ 104 | ... 105 | 106 | async def on_raw_765(self, message): 107 | """ Invalid metadata target. """ 108 | target, targetmeta = self._parse_user(message.params[0]) 109 | 110 | if target not in self._pending['metadata']: 111 | return 112 | if target in self.users: 113 | await self._sync_user(target, targetmeta) 114 | 115 | self._metadata_queue.remove(target) 116 | del self._metadata_info[target] 117 | 118 | future = self._pending['metadata'].pop(target) 119 | future.set_result(None) 120 | 121 | async def on_raw_766(self, message): 122 | """ Unknown metadata key. """ 123 | ... 124 | 125 | async def on_raw_767(self, message): 126 | """ Invalid metadata key. """ 127 | ... 128 | 129 | async def on_raw_768(self, message): 130 | """ Metadata key not set. """ 131 | ... 132 | 133 | async def on_raw_769(self, message): 134 | """ Metadata permission denied. """ 135 | ... 136 | -------------------------------------------------------------------------------- /pydle/features/ircv3/monitor.py: -------------------------------------------------------------------------------- 1 | ## monitor.py 2 | # Online status monitoring support. 3 | from .. import isupport 4 | 5 | 6 | class MonitoringSupport(isupport.ISUPPORTSupport): 7 | """ Support for monitoring the online/offline status of certain targets. """ 8 | 9 | ## Internals. 10 | 11 | def _reset_attributes(self): 12 | super()._reset_attributes() 13 | self._monitoring = set() 14 | 15 | def _destroy_user(self, nickname, channel=None, monitor_override=False): 16 | # Override _destroy_user to not remove user if they are being monitored by us. 17 | if channel: 18 | channels = [self.channels[channel]] 19 | else: 20 | channels = self.channels.values() 21 | 22 | for ch in channels: 23 | # Remove from nicklist. 24 | ch['users'].discard(nickname) 25 | 26 | # Remove from statuses. 27 | for status in self._nickname_prefixes.values(): 28 | if status in ch['modes'] and nickname in ch['modes'][status]: 29 | ch['modes'][status].remove(nickname) 30 | 31 | # If we're not in any common channels with the user anymore, we have no reliable way to keep their info up-to-date. 32 | # Remove the user. 33 | if (monitor_override or not self.is_monitoring(nickname)) and (not channel or not any(nickname in ch['users'] for ch in self.channels.values())): 34 | del self.users[nickname] 35 | 36 | ## API. 37 | 38 | async def monitor(self, target): 39 | """ Start monitoring the online status of a user. Returns whether or not the server supports monitoring. """ 40 | if 'MONITOR' in self._isupport and not self.is_monitoring(target): 41 | await self.rawmsg('MONITOR', '+', target) 42 | self._monitoring.add(target) 43 | return True 44 | return False 45 | 46 | async def unmonitor(self, target): 47 | """ Stop monitoring the online status of a user. Returns whether or not the server supports monitoring. """ 48 | if 'MONITOR' in self._isupport and self.is_monitoring(target): 49 | await self.rawmsg('MONITOR', '-', target) 50 | self._monitoring.remove(target) 51 | return True 52 | return False 53 | 54 | def is_monitoring(self, target): 55 | """ Return whether or not we are monitoring the target's online status. """ 56 | return target in self._monitoring 57 | 58 | ## Callbacks. 59 | 60 | async def on_user_online(self, nickname): 61 | """ Callback called when a monitored user appears online. """ 62 | ... 63 | 64 | async def on_user_offline(self, nickname): 65 | """ Callback called when a monitored users goes offline. """ 66 | ... 67 | 68 | ## Message handlers. 69 | 70 | async def on_capability_monitor_notify_available(self, value): 71 | return True 72 | 73 | async def on_raw_730(self, message): 74 | """ Someone we are monitoring just came online. """ 75 | for target in message.params[1].split(','): 76 | nickname, metadata = self._parse_user(target) 77 | await self._sync_user(nickname, metadata) 78 | await self.on_user_online(nickname) 79 | 80 | async def on_raw_731(self, message): 81 | """ Someone we are monitoring got offline. """ 82 | for target in message.params[1].split(','): 83 | nickname, metadata = self._parse_user(target) 84 | # May be monitoring a user we haven't seen yet 85 | if nickname in self.users: 86 | self._destroy_user(nickname, monitor_override=True) 87 | await self.on_user_offline(nickname) 88 | 89 | async def on_raw_732(self, message): 90 | """ List of users we're monitoring. """ 91 | for target in message.params[1].split(','): 92 | nickname, metadata = self._parse_user(target) 93 | self._monitoring.add(nickname) 94 | 95 | on_raw_733 = isupport.ISUPPORTSupport._ignored # End of MONITOR list. 96 | 97 | async def on_raw_734(self, message): 98 | """ Monitor list is full, can't add target. """ 99 | # Remove from monitoring list, not much else we can do. 100 | to_remove = set() 101 | for target in message.params[1].split(','): 102 | nickname, metadata = self._parse_user(target) 103 | to_remove.add(nickname) 104 | self._monitoring.difference_update(to_remove) 105 | -------------------------------------------------------------------------------- /pydle/features/ircv3/sasl.py: -------------------------------------------------------------------------------- 1 | ## sasl.py 2 | # SASL authentication support. Currently we only support PLAIN authentication. 3 | import base64 4 | from functools import partial 5 | 6 | try: 7 | import puresasl 8 | import puresasl.client 9 | except ImportError: 10 | puresasl = None 11 | 12 | from . import cap 13 | 14 | __all__ = ['SASLSupport'] 15 | 16 | 17 | RESPONSE_LIMIT = 400 18 | EMPTY_MESSAGE = '+' 19 | ABORT_MESSAGE = '*' 20 | 21 | 22 | class SASLSupport(cap.CapabilityNegotiationSupport): 23 | """ SASL authentication support. Currently limited to the PLAIN mechanism. """ 24 | SASL_TIMEOUT = 10 25 | 26 | ## Internal overrides. 27 | 28 | def __init__(self, *args, sasl_identity='', sasl_username=None, sasl_password=None, sasl_mechanism=None, **kwargs): 29 | super().__init__(*args, **kwargs) 30 | self.sasl_identity = sasl_identity 31 | self.sasl_username = sasl_username 32 | self.sasl_password = sasl_password 33 | self.sasl_mechanism = sasl_mechanism 34 | 35 | def _reset_attributes(self): 36 | super()._reset_attributes() 37 | self._sasl_client = None 38 | self._sasl_timer = None 39 | self._sasl_challenge = b'' 40 | self._sasl_mechanisms = None 41 | 42 | ## SASL functionality. 43 | 44 | async def _sasl_start(self, mechanism): 45 | """ Initiate SASL authentication. """ 46 | # The rest will be handled in on_raw_authenticate()/_sasl_respond(). 47 | await self.rawmsg('AUTHENTICATE', mechanism) 48 | # create a partial, required for our callback to get the kwarg 49 | _sasl_partial = partial(self._sasl_abort, timeout=True) 50 | self._sasl_timer = self.eventloop.call_later(self.SASL_TIMEOUT, _sasl_partial) 51 | 52 | async def _sasl_abort(self, timeout=False): 53 | """ Abort SASL authentication. """ 54 | if timeout: 55 | self.logger.error('SASL authentication timed out: aborting.') 56 | else: 57 | self.logger.error('SASL authentication aborted.') 58 | 59 | if self._sasl_timer: 60 | self._sasl_timer.cancel() 61 | 62 | self._sasl_timer = None 63 | 64 | # We're done here. 65 | await self.rawmsg('AUTHENTICATE', ABORT_MESSAGE) 66 | await self._capability_negotiated('sasl') 67 | 68 | async def _sasl_end(self): 69 | """ Finalize SASL authentication. """ 70 | if self._sasl_timer: 71 | self._sasl_timer.cancel() 72 | self._sasl_timer = None 73 | await self._capability_negotiated('sasl') 74 | 75 | async def _sasl_respond(self): 76 | """ Respond to SASL challenge with response. """ 77 | # Formulate a response. 78 | if self._sasl_client: 79 | try: 80 | response = self._sasl_client.process(self._sasl_challenge) 81 | except puresasl.SASLError: 82 | response = None 83 | 84 | if response is None: 85 | self.logger.warning('SASL challenge processing failed: aborting SASL authentication.') 86 | await self._sasl_abort() 87 | else: 88 | response = b'' 89 | 90 | response = base64.b64encode(response).decode(self.encoding) 91 | to_send = len(response) 92 | self._sasl_challenge = b'' 93 | 94 | # Send response in chunks. 95 | while to_send > 0: 96 | await self.rawmsg('AUTHENTICATE', response[:RESPONSE_LIMIT]) 97 | response = response[RESPONSE_LIMIT:] 98 | to_send -= RESPONSE_LIMIT 99 | 100 | # If our message fit exactly in SASL_RESPOSE_LIMIT-byte chunks, send an empty message to indicate we're done. 101 | if to_send == 0: 102 | await self.rawmsg('AUTHENTICATE', EMPTY_MESSAGE) 103 | 104 | ## Capability callbacks. 105 | 106 | async def on_capability_sasl_available(self, value): 107 | """ Check whether or not SASL is available. """ 108 | if value: 109 | self._sasl_mechanisms = value.upper().split(',') 110 | else: 111 | self._sasl_mechanisms = None 112 | 113 | if self.sasl_mechanism == 'EXTERNAL' or (self.sasl_username and self.sasl_password): 114 | if self.sasl_mechanism == 'EXTERNAL' or puresasl: 115 | return True 116 | self.logger.warning('SASL credentials set but puresasl module not found: not initiating SASL authentication.') 117 | return False 118 | 119 | async def on_capability_sasl_enabled(self): 120 | """ Start SASL authentication. """ 121 | if self.sasl_mechanism: 122 | if self._sasl_mechanisms and self.sasl_mechanism not in self._sasl_mechanisms: 123 | self.logger.warning('Requested SASL mechanism is not in server mechanism list: aborting SASL authentication.') 124 | return cap.failed 125 | mechanisms = [self.sasl_mechanism] 126 | else: 127 | mechanisms = self._sasl_mechanisms or ['PLAIN'] 128 | 129 | if mechanisms == ['EXTERNAL']: 130 | mechanism = 'EXTERNAL' 131 | else: 132 | self._sasl_client = puresasl.client.SASLClient(self.connection.hostname, 'irc', 133 | username=self.sasl_username, 134 | password=self.sasl_password, 135 | identity=self.sasl_identity 136 | ) 137 | 138 | try: 139 | self._sasl_client.choose_mechanism(mechanisms, allow_anonymous=False) 140 | except puresasl.SASLError: 141 | self.logger.exception('SASL mechanism choice failed: aborting SASL authentication.') 142 | return cap.FAILED 143 | mechanism = self._sasl_client.mechanism.upper() 144 | 145 | # Initialize SASL. 146 | await self._sasl_start(mechanism) 147 | # Tell caller we need more time, and to not end capability negotiation just yet. 148 | return cap.NEGOTIATING 149 | 150 | ## Message handlers. 151 | 152 | async def on_raw_authenticate(self, message): 153 | """ Received part of the authentication challenge. """ 154 | # Cancel timeout timer. 155 | if self._sasl_timer: 156 | self._sasl_timer.cancel() 157 | self._sasl_timer = None 158 | 159 | # Add response data. 160 | response = ' '.join(message.params) 161 | if response != EMPTY_MESSAGE: 162 | self._sasl_challenge += base64.b64decode(response) 163 | 164 | # If the response ain't exactly SASL_RESPONSE_LIMIT bytes long, it's the end. Process. 165 | if len(response) % RESPONSE_LIMIT > 0: 166 | await self._sasl_respond() 167 | else: 168 | # Response not done yet. Restart timer. 169 | self._sasl_timer = self.eventloop.call_later(self.SASL_TIMEOUT, self._sasl_abort(timeout=True)) 170 | 171 | on_raw_900 = cap.CapabilityNegotiationSupport._ignored # You are now logged in as... 172 | 173 | async def on_raw_903(self, message): 174 | """ SASL authentication successful. """ 175 | await self._sasl_end() 176 | 177 | async def on_raw_904(self, message): 178 | """ Invalid mechanism or authentication failed. Abort SASL. """ 179 | await self._sasl_abort() 180 | 181 | async def on_raw_905(self, message): 182 | """ Authentication failed. Abort SASL. """ 183 | await self._sasl_abort() 184 | 185 | on_raw_906 = cap.CapabilityNegotiationSupport._ignored # Completed registration while authenticating/registration aborted. 186 | on_raw_907 = cap.CapabilityNegotiationSupport._ignored # Already authenticated over SASL. 187 | -------------------------------------------------------------------------------- /pydle/features/ircv3/tags.py: -------------------------------------------------------------------------------- 1 | ## tags.py 2 | # Tagged message support. 3 | import re 4 | import pydle.client 5 | import pydle.protocol 6 | from pydle.features import rfc1459 7 | 8 | TAG_INDICATOR = '@' 9 | TAG_SEPARATOR = ';' 10 | TAG_VALUE_SEPARATOR = '=' 11 | TAGGED_MESSAGE_LENGTH_LIMIT = 1024 12 | 13 | TAG_CONVERSIONS = { 14 | r"\:": ';', 15 | r"\s": ' ', 16 | r"\\": '\\', 17 | r"\r": '\r', 18 | r"\n": '\n' 19 | } 20 | 21 | 22 | class TaggedMessage(rfc1459.RFC1459Message): 23 | 24 | def __init__(self, tags=None, **kw): 25 | super().__init__(**kw) 26 | self._kw['tags'] = tags 27 | self.__dict__.update(self._kw) 28 | 29 | @classmethod 30 | def parse(cls, line, encoding=pydle.protocol.DEFAULT_ENCODING): 31 | """ 32 | Parse given line into IRC message structure. 33 | Returns a TaggedMessage. 34 | """ 35 | valid = True 36 | # Decode message. 37 | try: 38 | message = line.decode(encoding) 39 | except UnicodeDecodeError: 40 | # Try our fallback encoding. 41 | message = line.decode(pydle.protocol.FALLBACK_ENCODING) 42 | 43 | # Sanity check for message length. 44 | if len(message) > TAGGED_MESSAGE_LENGTH_LIMIT: 45 | valid = False 46 | 47 | # Strip message separator. 48 | if message.endswith(rfc1459.protocol.LINE_SEPARATOR): 49 | message = message[:-len(rfc1459.protocol.LINE_SEPARATOR)] 50 | elif message.endswith(rfc1459.protocol.MINIMAL_LINE_SEPARATOR): 51 | message = message[:-len(rfc1459.protocol.MINIMAL_LINE_SEPARATOR)] 52 | raw = message 53 | 54 | # Parse tags. 55 | tags = {} 56 | if message.startswith(TAG_INDICATOR): 57 | message = message[len(TAG_INDICATOR):] 58 | raw_tags, message = message.split(' ', 1) 59 | 60 | for raw_tag in raw_tags.split(TAG_SEPARATOR): 61 | value = None 62 | if TAG_VALUE_SEPARATOR in raw_tag: 63 | tag, value = raw_tag.split(TAG_VALUE_SEPARATOR, 1) 64 | else: 65 | # Valueless or "missing" tag value 66 | tag = raw_tag 67 | if not value: 68 | # The tag value was either empty or missing. Per spec, they 69 | # must be treated the same. 70 | value = True 71 | 72 | # Parse escape sequences since IRC escapes != python escapes 73 | if isinstance(value, str): 74 | # convert known escapes first 75 | for escape, replacement in TAG_CONVERSIONS.items(): 76 | value = value.replace(escape, replacement) 77 | 78 | # convert other escape sequences based on the spec 79 | pattern = re.compile(r"(\\[\s\S])+") 80 | for match in pattern.finditer(value): 81 | escape = match.group() 82 | value = value.replace(escape, escape[1]) 83 | 84 | # Finally: add constructed tag to the output object. 85 | tags[tag] = value 86 | 87 | # Parse rest of message. 88 | message = super().parse(message.lstrip().encode(encoding), encoding=encoding) 89 | return TaggedMessage(_raw=raw, _valid=message._valid and valid, tags=tags, **message._kw) 90 | 91 | def construct(self, force=False): 92 | """ 93 | Construct raw IRC message and return it. 94 | """ 95 | message = super().construct(force=force) 96 | 97 | # Add tags. 98 | if self.tags: 99 | raw_tags = [] 100 | for tag, value in self.tags.items(): 101 | if value is True: 102 | raw_tags.append(tag) 103 | else: 104 | raw_tags.append(tag + TAG_VALUE_SEPARATOR + value) 105 | 106 | message = TAG_INDICATOR + TAG_SEPARATOR.join(raw_tags) + ' ' + message 107 | 108 | if len(message) > TAGGED_MESSAGE_LENGTH_LIMIT and not force: 109 | raise protocol.ProtocolViolation( 110 | 'The constructed message is too long. ({len} > {maxlen})'.format(len=len(message), 111 | maxlen=TAGGED_MESSAGE_LENGTH_LIMIT), 112 | message=message) 113 | return message 114 | 115 | 116 | class TaggedMessageSupport(rfc1459.RFC1459Support): 117 | def _create_message(self, command, *params, tags=None, **kwargs): 118 | message = super()._create_message(command, *params, **kwargs) 119 | return TaggedMessage(tags=tags or {}, **message._kw) 120 | 121 | def _parse_message(self): 122 | sep = rfc1459.protocol.MINIMAL_LINE_SEPARATOR.encode(self.encoding) 123 | message, _, data = self._receive_buffer.partition(sep) 124 | self._receive_buffer = data 125 | 126 | return TaggedMessage.parse(message + sep, encoding=self.encoding) 127 | -------------------------------------------------------------------------------- /pydle/features/isupport.py: -------------------------------------------------------------------------------- 1 | ## isupport.py 2 | # ISUPPORT (server-side IRC extension indication) support. 3 | # See: http://tools.ietf.org/html/draft-hardy-irc-isupport-00 4 | import collections 5 | import pydle.protocol 6 | from pydle.features import rfc1459 7 | 8 | __all__ = ['ISUPPORTSupport'] 9 | 10 | FEATURE_DISABLED_PREFIX = '-' 11 | BAN_EXCEPT_MODE = 'e' 12 | INVITE_EXCEPT_MODE = 'I' 13 | 14 | 15 | class ISUPPORTSupport(rfc1459.RFC1459Support): 16 | """ ISUPPORT support. """ 17 | 18 | ## Internal overrides. 19 | 20 | def _reset_attributes(self): 21 | super()._reset_attributes() 22 | self._isupport = {} 23 | self._extban_types = [] 24 | self._extban_prefix = None 25 | 26 | def _create_channel(self, channel): 27 | """ Create channel with optional ban and invite exception lists. """ 28 | super()._create_channel(channel) 29 | if 'EXCEPTS' in self._isupport: 30 | self.channels[channel]['exceptlist'] = None 31 | if 'INVEX' in self._isupport: 32 | self.channels[channel]['inviteexceptlist'] = None 33 | 34 | ## Command handlers. 35 | 36 | async def on_raw_005(self, message): 37 | """ ISUPPORT indication. """ 38 | isupport = {} 39 | 40 | # Parse response. 41 | # Strip target (first argument) and 'are supported by this server' (last argument). 42 | for feature in message.params[1:-1]: 43 | if feature.startswith(FEATURE_DISABLED_PREFIX): 44 | value = False 45 | elif '=' in feature: 46 | feature, value = feature.split('=', 1) 47 | else: 48 | value = True 49 | isupport[feature.upper()] = value 50 | 51 | # Update internal dict first. 52 | self._isupport.update(isupport) 53 | 54 | # And have callbacks update other internals. 55 | for entry, value in isupport.items(): 56 | if value is not False: 57 | # A value of True technically means there was no value supplied; correct this for callbacks. 58 | if value is True: 59 | value = None 60 | 61 | method = 'on_isupport_' + pydle.protocol.identifierify(entry) 62 | if hasattr(self, method): 63 | await getattr(self, method)(value) 64 | 65 | ## ISUPPORT handlers. 66 | 67 | async def on_isupport_awaylen(self, value): 68 | """ Away message length limit. """ 69 | self._away_message_length_limit = int(value) 70 | 71 | async def on_isupport_casemapping(self, value): 72 | """ IRC case mapping for nickname and channel name comparisons. """ 73 | if value in rfc1459.protocol.CASE_MAPPINGS: 74 | self._case_mapping = value 75 | self.channels = rfc1459.parsing.NormalizingDict(self.channels, case_mapping=value) 76 | self.users = rfc1459.parsing.NormalizingDict(self.users, case_mapping=value) 77 | 78 | async def on_isupport_channellen(self, value): 79 | """ Channel name length limit. """ 80 | self._channel_length_limit = int(value) 81 | 82 | async def on_isupport_chanlimit(self, value): 83 | """ Simultaneous channel limits for user. """ 84 | self._channel_limits = {} 85 | 86 | for entry in value.split(','): 87 | types, limit = entry.split(':') 88 | 89 | # Assign limit to channel type group and add lookup entry for type. 90 | self._channel_limits[frozenset(types)] = int(limit) 91 | for prefix in types: 92 | self._channel_limit_groups[prefix] = frozenset(types) 93 | 94 | async def on_isupport_chanmodes(self, value): 95 | """ Valid channel modes and their behaviour. """ 96 | list, param, param_set, noparams = [set(modes) for modes in value.split(',')[:4]] 97 | self._channel_modes.update(set(value.replace(',', ''))) 98 | 99 | # The reason we have to do it like this is because other ISUPPORTs (e.g. PREFIX) may update these values as well. 100 | if rfc1459.protocol.BEHAVIOUR_LIST not in self._channel_modes_behaviour: 101 | self._channel_modes_behaviour[rfc1459.protocol.BEHAVIOUR_LIST] = set() 102 | self._channel_modes_behaviour[rfc1459.protocol.BEHAVIOUR_LIST].update(list) 103 | 104 | if rfc1459.protocol.BEHAVIOUR_PARAMETER not in self._channel_modes_behaviour: 105 | self._channel_modes_behaviour[rfc1459.protocol.BEHAVIOUR_PARAMETER] = set() 106 | self._channel_modes_behaviour[rfc1459.protocol.BEHAVIOUR_PARAMETER].update(param) 107 | 108 | if rfc1459.protocol.BEHAVIOUR_PARAMETER_ON_SET not in self._channel_modes_behaviour: 109 | self._channel_modes_behaviour[rfc1459.protocol.BEHAVIOUR_PARAMETER_ON_SET] = set() 110 | self._channel_modes_behaviour[rfc1459.protocol.BEHAVIOUR_PARAMETER_ON_SET].update(param_set) 111 | 112 | if rfc1459.protocol.BEHAVIOUR_NO_PARAMETER not in self._channel_modes_behaviour: 113 | self._channel_modes_behaviour[rfc1459.protocol.BEHAVIOUR_NO_PARAMETER] = set() 114 | self._channel_modes_behaviour[rfc1459.protocol.BEHAVIOUR_NO_PARAMETER].update(noparams) 115 | 116 | async def on_isupport_chantypes(self, value): 117 | """ Channel name prefix symbols. """ 118 | if not value: 119 | value = '' 120 | self._channel_prefixes = set(value) 121 | 122 | async def on_isupport_excepts(self, value): 123 | """ Server allows ban exceptions. """ 124 | if not value: 125 | value = BAN_EXCEPT_MODE 126 | self._channel_modes.add(value) 127 | self._channel_modes_behaviour[rfc1459.protocol.BEHAVIOUR_LIST].add(value) 128 | 129 | async def on_isupport_extban(self, value): 130 | """ Extended ban prefixes. """ 131 | self._extban_prefix, types = value.split(',') 132 | self._extban_types = set(types) 133 | 134 | async def on_isupport_invex(self, value): 135 | """ Server allows invite exceptions. """ 136 | if not value: 137 | value = INVITE_EXCEPT_MODE 138 | self._channel_modes.add(value) 139 | self._channel_modes_behaviour[rfc1459.protocol.BEHAVIOUR_LIST].add(value) 140 | 141 | async def on_isupport_maxbans(self, value): 142 | """ Maximum entries in ban list. Replaced by MAXLIST. """ 143 | if 'MAXLIST' not in self._isupport: 144 | if not self._list_limits: 145 | self._list_limits = {} 146 | self._list_limits['b'] = int(value) 147 | 148 | async def on_isupport_maxchannels(self, value): 149 | """ Old version of CHANLIMIT. """ 150 | if 'CHANTYPES' in self._isupport and 'CHANLIMIT' not in self._isupport: 151 | self._channel_limits = {} 152 | 153 | prefixes = self._isupport['CHANTYPES'] 154 | # Assume the limit is for all types of channels. Make a single group for all types. 155 | self._channel_limits[frozenset(prefixes)] = int(value) 156 | for prefix in prefixes: 157 | self._channel_limit_groups[prefix] = frozenset(prefixes) 158 | 159 | async def on_isupport_maxlist(self, value): 160 | """ Limits on channel modes involving lists. """ 161 | self._list_limits = {} 162 | 163 | for entry in value.split(','): 164 | modes, limit = entry.split(':') 165 | 166 | # Assign limit to mode group and add lookup entry for mode. 167 | self._list_limits[frozenset(modes)] = int(limit) 168 | for mode in modes: 169 | self._list_limit_groups[mode] = frozenset(modes) 170 | 171 | async def on_isupport_maxpara(self, value): 172 | """ Limits to parameters given to command. """ 173 | self._command_parameter_limit = int(value) 174 | 175 | async def on_isupport_modes(self, value): 176 | """ Maximum number of variable modes to change in a single MODE command. """ 177 | self._mode_limit = int(value) 178 | 179 | async def on_isupport_monitor(self, value): 180 | self._monitor_limit = int(value) 181 | 182 | async def on_isupport_namesx(self, value): 183 | """ Let the server know we do in fact support NAMESX. Effectively the same as CAP multi-prefix. """ 184 | await self.rawmsg('PROTOCTL', 'NAMESX') 185 | 186 | async def on_isupport_network(self, value): 187 | """ IRC network name. """ 188 | self.network = value 189 | 190 | async def on_isupport_nicklen(self, value): 191 | """ Nickname length limit. """ 192 | self._nickname_length_limit = int(value) 193 | 194 | async def on_isupport_prefix(self, value): 195 | """ Nickname prefixes on channels and their associated modes. """ 196 | if not value: 197 | # No prefixes support. 198 | self._nickname_prefixes = collections.OrderedDict() 199 | return 200 | 201 | modes, prefixes = value.lstrip('(').split(')', 1) 202 | 203 | # Update valid channel modes and their behaviour as CHANMODES doesn't include PREFIX modes. 204 | self._channel_modes.update(set(modes)) 205 | if rfc1459.protocol.BEHAVIOUR_PARAMETER not in self._channel_modes_behaviour: 206 | self._channel_modes_behaviour[rfc1459.protocol.BEHAVIOUR_PARAMETER] = set() 207 | self._channel_modes_behaviour[rfc1459.protocol.BEHAVIOUR_PARAMETER].update(set(modes)) 208 | 209 | self._nickname_prefixes = collections.OrderedDict() 210 | for mode, prefix in zip(modes, prefixes): 211 | self._nickname_prefixes[prefix] = mode 212 | 213 | async def on_isupport_statusmsg(self, value): 214 | """ Support for messaging every member on a channel with given status or higher. """ 215 | self._status_message_prefixes.update(value) 216 | 217 | async def on_isupport_targmax(self, value): 218 | """ The maximum number of targets certain types of commands can affect. """ 219 | if not value: 220 | return 221 | 222 | for entry in value.split(','): 223 | command, limit = entry.split(':', 1) 224 | if not limit: 225 | continue 226 | self._target_limits[command] = int(limit) 227 | 228 | async def on_isupport_topiclen(self, value): 229 | """ Channel topic length limit. """ 230 | self._topic_length_limit = int(value) 231 | 232 | async def on_isupport_wallchops(self, value): 233 | """ Support for messaging every opped member or higher on a channel. Replaced by STATUSMSG. """ 234 | for prefix, mode in self._nickname_prefixes.items(): 235 | if mode == 'o': 236 | break 237 | else: 238 | prefix = '@' 239 | self._status_message_prefixes.add(prefix) 240 | 241 | async def on_isupport_wallvoices(self, value): 242 | """ Support for messaging every voiced member or higher on a channel. Replaced by STATUSMSG. """ 243 | for prefix, mode in self._nickname_prefixes.items(): 244 | if mode == 'v': 245 | break 246 | else: 247 | prefix = '+' 248 | self._status_message_prefixes.add(prefix) 249 | -------------------------------------------------------------------------------- /pydle/features/rfc1459/__init__.py: -------------------------------------------------------------------------------- 1 | from . import client, parsing, protocol 2 | 3 | from .client import RFC1459Support 4 | from .parsing import RFC1459Message 5 | -------------------------------------------------------------------------------- /pydle/features/rfc1459/client.py: -------------------------------------------------------------------------------- 1 | ## rfc1459.py 2 | # Basic RFC1459 stuff. 3 | import copy 4 | import datetime 5 | import ipaddress 6 | import itertools 7 | 8 | from pydle.client import BasicClient, NotInChannel, AlreadyInChannel 9 | from . import parsing, protocol 10 | 11 | 12 | class RFC1459Support(BasicClient): 13 | """ Basic RFC1459 client. """ 14 | DEFAULT_QUIT_MESSAGE = 'Quitting' 15 | 16 | ## Internals. 17 | 18 | def _reset_attributes(self): 19 | super()._reset_attributes() 20 | # Casemapping. 21 | self._case_mapping = protocol.DEFAULT_CASE_MAPPING 22 | 23 | # Limitations. 24 | self._away_message_length_limit = None 25 | self._channel_length_limit = protocol.CHANNEL_LENGTH_LIMIT 26 | self._channel_limit_groups = copy.deepcopy(protocol.CHANNEL_LIMITS_GROUPS) 27 | self._channel_limits = copy.deepcopy(protocol.CHANNEL_LIMITS) 28 | self._command_parameter_limit = protocol.PARAMETER_LIMIT 29 | self._list_limit_groups = copy.deepcopy(protocol.LIST_LIMITS_GROUPS) 30 | self._list_limits = copy.deepcopy(protocol.LIST_LIMITS) 31 | self._mode_limit = None 32 | self._nickname_length_limit = protocol.NICKNAME_LENGTH_LIMIT 33 | self._target_limits = {} 34 | self._topic_length_limit = protocol.TOPIC_LENGTH_LIMIT 35 | 36 | # Modes, prefixes. 37 | self._mode = {} 38 | self._channel_modes = set(protocol.CHANNEL_MODES) 39 | self._channel_modes_behaviour = copy.deepcopy(protocol.CHANNEL_MODES_BEHAVIOUR) 40 | self._channel_prefixes = set(protocol.CHANNEL_PREFIXES) 41 | self._nickname_prefixes = protocol.NICKNAME_PREFIXES.copy() 42 | self._status_message_prefixes = set() 43 | self._user_modes = set(protocol.USER_MODES) 44 | self._user_modes_behaviour = copy.deepcopy(protocol.USER_MODES_BEHAVIOUR) 45 | 46 | # Registration. 47 | self.registered = False 48 | self._registration_attempts = 0 49 | self._attempt_nicknames = self._nicknames[:] 50 | 51 | # Info. 52 | self._pending['whois'] = parsing.NormalizingDict(case_mapping=self._case_mapping) 53 | self._pending['whowas'] = parsing.NormalizingDict(case_mapping=self._case_mapping) 54 | self._whois_info = parsing.NormalizingDict(case_mapping=self._case_mapping) 55 | self._whowas_info = parsing.NormalizingDict(case_mapping=self._case_mapping) 56 | 57 | # Misc. 58 | self.motd = None 59 | self.channels = parsing.NormalizingDict(self.channels, case_mapping=self._case_mapping) 60 | self.users = parsing.NormalizingDict(self.users, case_mapping=self._case_mapping) 61 | 62 | def _reset_connection_attributes(self): 63 | super()._reset_connection_attributes() 64 | self.password = None 65 | 66 | def _create_channel(self, channel): 67 | super()._create_channel(channel) 68 | self.channels[channel].update({ 69 | 'modes': {}, 70 | 'topic': None, 71 | 'topic_by': None, 72 | 'topic_set': None, 73 | 'created': None, 74 | 'password': None, 75 | 'banlist': None, 76 | 'public': True 77 | }) 78 | 79 | def _create_user(self, nickname): 80 | super()._create_user(nickname) 81 | if nickname in self.users: 82 | self.users[nickname].update({ 83 | 'away': False, 84 | 'away_message': None, 85 | }) 86 | 87 | async def _rename_user(self, user, new): 88 | await super()._rename_user(user, new) 89 | 90 | # Rename in mode lists, too. 91 | for ch in self.channels.values(): 92 | for status in self._nickname_prefixes.values(): 93 | if status in ch['modes'] and user in ch['modes'][status]: 94 | ch['modes'][status].remove(user) 95 | ch['modes'][status].append(new) 96 | 97 | def _destroy_user(self, user, channel=None): 98 | if channel: 99 | channels = [self.channels[channel]] 100 | else: 101 | channels = self.channels.values() 102 | 103 | # Remove user from status list too. 104 | for ch in channels: 105 | for status in self._nickname_prefixes.values(): 106 | if status in ch['modes'] and user in ch['modes'][status]: 107 | ch['modes'][status].remove(user) 108 | 109 | def _parse_user(self, data): 110 | if data: 111 | nickname, username, host = parsing.parse_user(data) 112 | 113 | metadata = {'nickname': nickname} 114 | if username: 115 | metadata['username'] = username 116 | if host: 117 | metadata['hostname'] = host 118 | else: 119 | return None, {} 120 | return nickname, metadata 121 | 122 | def _parse_user_modes(self, user, modes, current=None): 123 | if current is None: 124 | current = self.users[user]['modes'] 125 | return parsing.parse_modes(modes, current, behaviour=self._user_modes_behaviour) 126 | 127 | def _parse_channel_modes(self, channel, modes, current=None): 128 | if current is None: 129 | current = self.channels[channel]['modes'] 130 | return parsing.parse_modes(modes, current, behaviour=self._channel_modes_behaviour) 131 | 132 | def _format_host_range(self, host, range, allow_everything=False): 133 | # IPv4? 134 | try: 135 | addr = ipaddress.IPv4Network(host, strict=False) 136 | max = 4 if allow_everything else 3 137 | 138 | # Round up subnet to nearest octet. 139 | subnet = addr.prefixlen + (8 - addr.prefixlen % 8) 140 | # Remove range mask. 141 | subnet -= min(range, max) * 8 142 | 143 | rangeaddr = addr.supernet(new_prefix=subnet).exploded.split('/', 1)[0] 144 | return rangeaddr.replace('0', '*') 145 | except ValueError: 146 | pass 147 | 148 | # IPv6? 149 | try: 150 | addr = ipaddress.IPv6Network(host, strict=False) 151 | max = 4 if allow_everything else 3 152 | 153 | # Round up subnet to nearest 32-et. 154 | subnet = addr.prefixlen + (32 - addr.prefixlen % 32) 155 | # Remove range mask. 156 | subnet -= min(range, max) * 32 157 | 158 | rangeaddr = addr.supernet(new_prefix=subnet).exploded.split('/', 1)[0] 159 | return rangeaddr.replace(':0000', ':*') 160 | except ValueError: 161 | pass 162 | 163 | # Host? 164 | if '.' in host: 165 | # Split pieces. 166 | pieces = host.split('.') 167 | max = len(pieces) 168 | if not allow_everything: 169 | max -= 1 170 | 171 | # Figure out how many to mask. 172 | to_mask = min(range, max) 173 | # Mask pieces. 174 | pieces[:to_mask] = '*' * to_mask 175 | return '.'.join(pieces) 176 | 177 | # Wat. 178 | if allow_everything and range >= 4: 179 | return '*' 180 | return host 181 | 182 | ## Connection. 183 | 184 | async def connect(self, hostname=None, port=None, password=None, **kwargs): 185 | port = port or protocol.DEFAULT_PORT 186 | 187 | # Connect... 188 | await super().connect(hostname, port, **kwargs) 189 | 190 | # Check if a password was provided and we don't already have one 191 | if password is not None and not self.password: 192 | # if so, set the password. 193 | self.password = password 194 | # And initiate the IRC connection. 195 | await self._register() 196 | 197 | async def _register(self): 198 | """ Perform IRC connection registration. """ 199 | if self.registered: 200 | return 201 | self._registration_attempts += 1 202 | 203 | # Don't throttle during registration, most ircds don't care for flooding during registration, 204 | # and it might speed it up significantly. 205 | self.connection.throttle = False 206 | 207 | # Password first. 208 | if self.password: 209 | await self.rawmsg('PASS', self.password) 210 | 211 | # Then nickname... 212 | await self.set_nickname(self._attempt_nicknames.pop(0)) 213 | # And now for the rest of the user information. 214 | await self.rawmsg('USER', self.username, '0', '*', self.realname) 215 | 216 | async def _registration_completed(self, message): 217 | """ We're connected and registered. Receive proper nickname and emit fake NICK message. """ 218 | if not self.registered: 219 | # Re-enable throttling. 220 | self.registered = True 221 | self.connection.throttle = True 222 | 223 | target = message.params[0] 224 | fakemsg = self._create_message('NICK', target, source=self.nickname) 225 | await self.on_raw_nick(fakemsg) 226 | 227 | ## Message handling. 228 | 229 | def _has_message(self): 230 | """ Whether or not we have messages available for processing. """ 231 | sep = protocol.MINIMAL_LINE_SEPARATOR.encode(self.encoding) 232 | return sep in self._receive_buffer 233 | 234 | def _create_message(self, command, *params, **kwargs): 235 | return parsing.RFC1459Message(command, params, **kwargs) 236 | 237 | def _parse_message(self): 238 | sep = protocol.MINIMAL_LINE_SEPARATOR.encode(self.encoding) 239 | message, _, data = self._receive_buffer.partition(sep) 240 | self._receive_buffer = data 241 | return parsing.RFC1459Message.parse(message + sep, encoding=self.encoding) 242 | 243 | ## IRC API. 244 | 245 | async def set_nickname(self, nickname): 246 | """ 247 | Set nickname to given nickname. 248 | Users should only rely on the nickname actually being changed when receiving an on_nick_change callback. 249 | """ 250 | await self.rawmsg('NICK', nickname) 251 | 252 | async def join(self, channel, password=None): 253 | """ Join channel, optionally with password. """ 254 | if self.in_channel(channel): 255 | raise AlreadyInChannel(channel) 256 | 257 | if password: 258 | await self.rawmsg('JOIN', channel, password) 259 | else: 260 | await self.rawmsg('JOIN', channel) 261 | 262 | async def part(self, channel, message=None): 263 | """ Leave channel, optionally with message. """ 264 | if not self.in_channel(channel): 265 | raise NotInChannel(channel) 266 | 267 | # Message seems to be an extension to the spec. 268 | if message: 269 | await self.rawmsg('PART', channel, message) 270 | else: 271 | await self.rawmsg('PART', channel) 272 | 273 | async def kick(self, channel, target, reason=None): 274 | """ Kick user from channel. """ 275 | if not self.in_channel(channel): 276 | raise NotInChannel(channel) 277 | 278 | if reason: 279 | await self.rawmsg('KICK', channel, target, reason) 280 | else: 281 | await self.rawmsg('KICK', channel, target) 282 | 283 | async def ban(self, channel, target, range=0): 284 | """ 285 | Ban user from channel. Target can be either a user or a host. 286 | This command will not kick: use kickban() for that. 287 | range indicates the IP/host range to ban: 0 means ban only the IP/host, 288 | 1+ means ban that many 'degrees' (up to 3 for IP addresses) of the host for range bans. 289 | """ 290 | if target in self.users: 291 | host = self.users[target]['hostname'] 292 | else: 293 | host = target 294 | 295 | host = self._format_host_range(host, range) 296 | mask = self._format_host_mask('*', '*', host) 297 | await self.rawmsg('MODE', channel, '+b', mask) 298 | 299 | async def unban(self, channel, target, range=0): 300 | """ 301 | Unban user from channel. Target can be either a user or a host. 302 | See ban documentation for the range parameter. 303 | """ 304 | if target in self.users: 305 | host = self.users[target]['hostname'] 306 | else: 307 | host = target 308 | 309 | host = self._format_host_range(host, range) 310 | mask = self._format_host_mask('*', '*', host) 311 | await self.rawmsg('MODE', channel, '-b', mask) 312 | 313 | async def kickban(self, channel, target, reason=None, range=0): 314 | """ 315 | Kick and ban user from channel. 316 | """ 317 | await self.ban(channel, target, range) 318 | await self.kick(channel, target, reason) 319 | 320 | async def quit(self, message=None): 321 | """ Quit network. """ 322 | if message is None: 323 | message = self.DEFAULT_QUIT_MESSAGE 324 | 325 | await self.rawmsg('QUIT', message) 326 | await self.disconnect(expected=True) 327 | 328 | async def cycle(self, channel): 329 | """ Rejoin channel. """ 330 | if not self.in_channel(channel): 331 | raise NotInChannel(channel) 332 | 333 | password = self.channels[channel]['password'] 334 | await self.part(channel) 335 | await self.join(channel, password) 336 | 337 | async def message(self, target, message): 338 | """ Message channel or user. """ 339 | hostmask = self._format_user_mask(self.nickname) 340 | # Leeway. 341 | chunklen = protocol.MESSAGE_LENGTH_LIMIT - len( 342 | '{hostmask} PRIVMSG {target} :'.format(hostmask=hostmask, target=target)) - 25 343 | 344 | for line in message.replace('\r', '').split('\n'): 345 | for chunk in chunkify(line, chunklen): 346 | # Some IRC servers respond with "412 Bot :No text to send" on empty messages. 347 | await self.rawmsg('PRIVMSG', target, chunk or ' ') 348 | 349 | async def notice(self, target, message): 350 | """ Notice channel or user. """ 351 | hostmask = self._format_user_mask(self.nickname) 352 | # Leeway. 353 | chunklen = protocol.MESSAGE_LENGTH_LIMIT - len( 354 | '{hostmask} NOTICE {target} :'.format(hostmask=hostmask, target=target)) - 25 355 | 356 | for line in message.replace('\r', '').split('\n'): 357 | for chunk in chunkify(line, chunklen): 358 | await self.rawmsg('NOTICE', target, chunk) 359 | 360 | async def set_mode(self, target, *modes): 361 | """ 362 | Set mode on target. 363 | Users should only rely on the mode actually being changed when receiving an on_{channel,user}_mode_change callback. 364 | """ 365 | if self.is_channel(target) and not self.in_channel(target): 366 | raise NotInChannel(target) 367 | 368 | await self.rawmsg('MODE', target, *modes) 369 | 370 | async def set_topic(self, channel, topic): 371 | """ 372 | Set topic on channel. 373 | Users should only rely on the topic actually being changed when receiving an on_topic_change callback. 374 | """ 375 | if not self.is_channel(channel): 376 | raise ValueError('Not a channel: {}'.format(channel)) 377 | if not self.in_channel(channel): 378 | raise NotInChannel(channel) 379 | 380 | await self.rawmsg('TOPIC', channel, topic) 381 | 382 | async def away(self, message): 383 | """ Mark self as away. """ 384 | await self.rawmsg('AWAY', message) 385 | 386 | async def back(self): 387 | """ Mark self as not away. """ 388 | await self.rawmsg('AWAY') 389 | 390 | async def whois(self, nickname): 391 | """ 392 | Return information about user. 393 | This is an blocking asynchronous method: it has to be called from a coroutine, as follows: 394 | 395 | info = await self.whois('Nick') 396 | """ 397 | # Some IRCDs are wonky and send strange responses for spaces in nicknames. 398 | # We just check if there's a space in the nickname -- if there is, 399 | # then we immediately set the future's result to None and don't bother checking. 400 | if protocol.ARGUMENT_SEPARATOR.search(nickname) is not None: 401 | result = self.eventloop.create_future() 402 | result.set_result(None) 403 | return result 404 | 405 | if nickname not in self._pending['whois']: 406 | await self.rawmsg('WHOIS', nickname) 407 | self._whois_info[nickname] = { 408 | 'oper': False, 409 | 'idle': 0, 410 | 'away': False, 411 | 'away_message': None 412 | } 413 | 414 | # Create a future for when the WHOIS requests succeeds. 415 | self._pending['whois'][nickname] = self.eventloop.create_future() 416 | 417 | return await self._pending['whois'][nickname] 418 | 419 | async def whowas(self, nickname): 420 | """ 421 | Return information about offline user. 422 | This is an blocking asynchronous method: it has to be called from a coroutine, as follows: 423 | 424 | info = await self.whowas('Nick') 425 | """ 426 | # Same treatment as nicknames in whois. 427 | if protocol.ARGUMENT_SEPARATOR.search(nickname) is not None: 428 | result = self.eventloop.create_future() 429 | result.set_result(None) 430 | return result 431 | 432 | if nickname not in self._pending['whowas']: 433 | await self.rawmsg('WHOWAS', nickname) 434 | self._whowas_info[nickname] = {} 435 | 436 | # Create a future for when the WHOWAS requests succeeds. 437 | self._pending['whowas'][nickname] = self.eventloop.create_future() 438 | 439 | return await self._pending['whowas'][nickname] 440 | 441 | ## IRC helpers. 442 | 443 | def normalize(self, input): 444 | return parsing.normalize(input, case_mapping=self._case_mapping) 445 | 446 | def is_channel(self, chan): 447 | return any(chan.startswith(prefix) for prefix in self._channel_prefixes) 448 | 449 | def is_same_nick(self, left, right): 450 | """ Check if given nicknames are equal in the server's case mapping. """ 451 | return self.normalize(left) == self.normalize(right) 452 | 453 | def is_same_channel(self, left, right): 454 | """ Check if given nicknames are equal in the server's case mapping. """ 455 | return self.normalize(left) == self.normalize(right) 456 | 457 | ## Overloadable callbacks. 458 | 459 | async def on_connect(self): 460 | # Auto-join channels. 461 | for channel in self._autojoin_channels: 462 | await self.join(channel) 463 | 464 | # super call 465 | await super().on_connect() 466 | 467 | async def on_invite(self, channel, by): 468 | """ Callback called when the client was invited into a channel by someone. """ 469 | ... 470 | 471 | async def on_user_invite(self, target, channel, by): 472 | """ Callback called when another user was invited into a channel by someone. """ 473 | ... 474 | 475 | async def on_join(self, channel, user): 476 | """ Callback called when a user, possibly the client, has joined the channel. """ 477 | ... 478 | 479 | async def on_kill(self, target, by, reason): 480 | """ Callback called when a user, possibly the client, was killed from the server. """ 481 | ... 482 | 483 | async def on_kick(self, channel, target, by, reason=None): 484 | """ Callback called when a user, possibly the client, was kicked from a channel. """ 485 | ... 486 | 487 | async def on_mode_change(self, channel, modes, by): 488 | """ Callback called when the mode on a channel was changed. """ 489 | ... 490 | 491 | async def on_user_mode_change(self, modes): 492 | """ Callback called when a user mode change occurred for the client. """ 493 | ... 494 | 495 | async def on_message(self, target, by, message): 496 | """ Callback called when the client received a message. """ 497 | ... 498 | 499 | async def on_channel_message(self, target, by, message): 500 | """ Callback received when the client received a message in a channel. """ 501 | ... 502 | 503 | async def on_private_message(self, target, by, message): 504 | """ Callback called when the client received a message in private. """ 505 | ... 506 | 507 | async def on_nick_change(self, old, new): 508 | """ Callback called when a user, possibly the client, changed their nickname. """ 509 | ... 510 | 511 | async def on_notice(self, target, by, message): 512 | """ Callback called when the client received a notice. """ 513 | ... 514 | 515 | async def on_channel_notice(self, target, by, message): 516 | """ Callback called when the client received a notice in a channel. """ 517 | ... 518 | 519 | async def on_private_notice(self, target, by, message): 520 | """ Callback called when the client received a notice in private. """ 521 | ... 522 | 523 | async def on_part(self, channel, user, message=None): 524 | """ Callback called when a user, possibly the client, left a channel. """ 525 | ... 526 | 527 | async def on_topic_change(self, channel, message, by): 528 | """ Callback called when the topic for a channel was changed. """ 529 | ... 530 | 531 | async def on_quit(self, user, message=None): 532 | """ Callback called when a user, possibly the client, left the network. """ 533 | ... 534 | 535 | ## Callback handlers. 536 | 537 | async def on_raw_error(self, message): 538 | """ Server encountered an error and will now close the connection. """ 539 | error = protocol.ServerError(' '.join(message.params)) 540 | await self.on_data_error(error) 541 | 542 | async def on_raw_pong(self, message): 543 | self.logger.debug('>> PONG received') 544 | 545 | async def on_raw_invite(self, message): 546 | """ INVITE command. """ 547 | nick, metadata = self._parse_user(message.source) 548 | await self._sync_user(nick, metadata) 549 | 550 | target, channel = message.params 551 | target, metadata = self._parse_user(target) 552 | 553 | if self.is_same_nick(self.nickname, target): 554 | await self.on_invite(channel, nick) 555 | else: 556 | await self.on_user_invite(target, channel, nick) 557 | 558 | async def on_raw_join(self, message): 559 | """ JOIN command. """ 560 | nick, metadata = self._parse_user(message.source) 561 | await self._sync_user(nick, metadata) 562 | 563 | channels = message.params[0].split(',') 564 | if self.is_same_nick(self.nickname, nick): 565 | # Add to our channel list, we joined here. 566 | for channel in channels: 567 | if not self.in_channel(channel): 568 | self._create_channel(channel) 569 | 570 | # Request channel mode from IRCd. 571 | await self.rawmsg('MODE', channel) 572 | else: 573 | # Add user to channel user list. 574 | for channel in channels: 575 | if self.in_channel(channel): 576 | self.channels[channel]['users'].add(nick) 577 | 578 | for channel in channels: 579 | await self.on_join(channel, nick) 580 | 581 | async def on_raw_kick(self, message): 582 | """ KICK command. """ 583 | kicker, kickermeta = self._parse_user(message.source) 584 | await self._sync_user(kicker, kickermeta) 585 | 586 | if len(message.params) > 2: 587 | channels, targets, reason = message.params 588 | else: 589 | channels, targets = message.params 590 | reason = None 591 | 592 | channels = channels.split(',') 593 | targets = targets.split(',') 594 | 595 | for channel, target in itertools.product(channels, targets): 596 | target, targetmeta = self._parse_user(target) 597 | await self._sync_user(target, targetmeta) 598 | 599 | if self.is_same_nick(target, self.nickname): 600 | self._destroy_channel(channel) 601 | else: 602 | # Update nick list on channel. 603 | if self.in_channel(channel): 604 | self._destroy_user(target, channel) 605 | 606 | await self.on_kick(channel, target, kicker, reason) 607 | 608 | async def on_raw_kill(self, message): 609 | """ KILL command. """ 610 | by, bymeta = self._parse_user(message.source) 611 | target, targetmeta = self._parse_user(message.params[0]) 612 | reason = message.params[1] 613 | 614 | await self._sync_user(target, targetmeta) 615 | if by in self.users: 616 | await self._sync_user(by, bymeta) 617 | 618 | await self.on_kill(target, by, reason) 619 | if self.is_same_nick(self.nickname, target): 620 | await self.disconnect(expected=False) 621 | else: 622 | self._destroy_user(target) 623 | 624 | async def on_raw_mode(self, message): 625 | """ MODE command. """ 626 | nick, metadata = self._parse_user(message.source) 627 | target, modes = message.params[0], message.params[1:] 628 | 629 | await self._sync_user(nick, metadata) 630 | if self.is_channel(target): 631 | if self.in_channel(target): 632 | # Parse modes. 633 | self.channels[target]['modes'] = self._parse_channel_modes(target, modes) 634 | 635 | await self.on_mode_change(target, modes, nick) 636 | else: 637 | target, targetmeta = self._parse_user(target) 638 | await self._sync_user(target, targetmeta) 639 | 640 | # Update own modes. 641 | if self.is_same_nick(self.nickname, nick): 642 | self._mode = self._parse_user_modes(nick, modes, current=self._mode) 643 | 644 | await self.on_user_mode_change(modes) 645 | 646 | async def on_raw_nick(self, message): 647 | """ NICK command. """ 648 | nick, metadata = self._parse_user(message.source) 649 | new = message.params[0] 650 | 651 | await self._sync_user(nick, metadata) 652 | # Acknowledgement of nickname change: set it internally, too. 653 | # Alternatively, we were force nick-changed. Nothing much we can do about it. 654 | if self.is_same_nick(self.nickname, nick): 655 | self.nickname = new 656 | 657 | # Go through all user lists and replace. 658 | await self._rename_user(nick, new) 659 | 660 | # Call handler. 661 | await self.on_nick_change(nick, new) 662 | 663 | async def on_raw_notice(self, message): 664 | """ NOTICE command. """ 665 | nick, metadata = self._parse_user(message.source) 666 | target, message = message.params 667 | 668 | await self._sync_user(nick, metadata) 669 | 670 | await self.on_notice(target, nick, message) 671 | if self.is_channel(target): 672 | await self.on_channel_notice(target, nick, message) 673 | else: 674 | await self.on_private_notice(target, nick, message) 675 | 676 | async def on_raw_part(self, message): 677 | """ PART command. """ 678 | nick, metadata = self._parse_user(message.source) 679 | channels = message.params[0].split(',') 680 | if len(message.params) > 1: 681 | reason = message.params[1] 682 | else: 683 | reason = None 684 | 685 | await self._sync_user(nick, metadata) 686 | if self.is_same_nick(self.nickname, nick): 687 | # We left the channel. Remove from channel list. :( 688 | for channel in channels: 689 | if self.in_channel(channel): 690 | await self.on_part(channel, nick, reason) 691 | self._destroy_channel(channel) 692 | else: 693 | # Someone else left. Remove them. 694 | for channel in channels: 695 | await self.on_part(channel, nick, reason) 696 | self._destroy_user(nick, channel) 697 | 698 | async def on_raw_ping(self, message): 699 | """ PING command. """ 700 | # Respond with a pong. 701 | await self.rawmsg('PONG', *message.params) 702 | 703 | async def on_raw_privmsg(self, message): 704 | """ PRIVMSG command. """ 705 | nick, metadata = self._parse_user(message.source) 706 | target, message = message.params 707 | 708 | await self._sync_user(nick, metadata) 709 | 710 | await self.on_message(target, nick, message) 711 | if self.is_channel(target): 712 | await self.on_channel_message(target, nick, message) 713 | else: 714 | await self.on_private_message(target, nick, message) 715 | 716 | async def on_raw_quit(self, message): 717 | """ QUIT command. """ 718 | nick, metadata = self._parse_user(message.source) 719 | 720 | await self._sync_user(nick, metadata) 721 | if message.params: 722 | reason = message.params[0] 723 | else: 724 | reason = None 725 | 726 | await self.on_quit(nick, reason) 727 | # Remove user from database. 728 | if not self.is_same_nick(self.nickname, nick): 729 | self._destroy_user(nick) 730 | # Else, we quit. 731 | elif self.connected: 732 | await self.disconnect() 733 | 734 | async def on_raw_topic(self, message): 735 | """ TOPIC command. """ 736 | setter, settermeta = self._parse_user(message.source) 737 | target, topic = message.params 738 | 739 | await self._sync_user(setter, settermeta) 740 | 741 | # Update topic in our own channel list. 742 | if self.in_channel(target): 743 | self.channels[target]['topic'] = topic 744 | self.channels[target]['topic_by'] = setter 745 | self.channels[target]['topic_set'] = datetime.datetime.now() 746 | 747 | await self.on_topic_change(target, topic, setter) 748 | 749 | ## Numeric responses. 750 | 751 | # Since RFC1459 specifies no specific banner message upon completion of registration, 752 | # take any of the below commands as an indication that registration succeeded. 753 | 754 | on_raw_001 = _registration_completed # Welcome message. 755 | on_raw_002 = _registration_completed # Server host. 756 | on_raw_003 = _registration_completed # Server creation time. 757 | 758 | async def on_raw_004(self, message): 759 | """ Basic server information. """ 760 | target, hostname, ircd, user_modes, channel_modes = message.params[:5] 761 | 762 | # Set valid channel and user modes. 763 | self._channel_modes = set(channel_modes) 764 | self._user_modes = set(user_modes) 765 | 766 | on_raw_008 = _registration_completed # Server notice mask. 767 | on_raw_042 = _registration_completed # Unique client ID. 768 | on_raw_250 = _registration_completed # Connection statistics. 769 | on_raw_251 = _registration_completed # Amount of users online. 770 | on_raw_252 = _registration_completed # Amount of operators online. 771 | on_raw_253 = _registration_completed # Amount of unknown connections. 772 | on_raw_254 = _registration_completed # Amount of channels. 773 | on_raw_255 = _registration_completed # Amount of local users and servers. 774 | on_raw_265 = _registration_completed # Amount of local users. 775 | on_raw_266 = _registration_completed # Amount of global users. 776 | 777 | async def on_raw_301(self, message): 778 | """ User is away. """ 779 | target, nickname, message = message.params 780 | info = { 781 | 'away': True, 782 | 'away_message': message 783 | } 784 | 785 | if nickname in self.users: 786 | await self._sync_user(nickname, info) 787 | if nickname in self._pending['whois']: 788 | self._whois_info[nickname].update(info) 789 | 790 | async def on_raw_311(self, message): 791 | """ WHOIS user info. """ 792 | target, nickname, username, hostname, _, realname = message.params 793 | info = { 794 | 'username': username, 795 | 'hostname': hostname, 796 | 'realname': realname 797 | } 798 | 799 | await self._sync_user(nickname, info) 800 | if nickname in self._pending['whois']: 801 | self._whois_info[nickname].update(info) 802 | 803 | async def on_raw_312(self, message): 804 | """ WHOIS server info. """ 805 | target, nickname, server, serverinfo = message.params 806 | info = { 807 | 'server': server, 808 | 'server_info': serverinfo 809 | } 810 | 811 | if nickname in self._pending['whois']: 812 | self._whois_info[nickname].update(info) 813 | if nickname in self._pending['whowas']: 814 | self._whowas_info[nickname].update(info) 815 | 816 | async def on_raw_313(self, message): 817 | """ WHOIS operator info. """ 818 | target, nickname = message.params[:2] 819 | info = { 820 | 'oper': True 821 | } 822 | 823 | if nickname in self._pending['whois']: 824 | self._whois_info[nickname].update(info) 825 | 826 | async def on_raw_314(self, message): 827 | """ WHOWAS user info. """ 828 | target, nickname, username, hostname, _, realname = message.params 829 | info = { 830 | 'username': username, 831 | 'hostname': hostname, 832 | 'realname': realname 833 | } 834 | 835 | if nickname in self._pending['whowas']: 836 | self._whowas_info[nickname].update(info) 837 | 838 | on_raw_315 = BasicClient._ignored # End of /WHO list. 839 | 840 | async def on_raw_317(self, message): 841 | """ WHOIS idle time. """ 842 | target, nickname, idle_time = message.params[:3] 843 | info = { 844 | 'idle': int(idle_time), 845 | } 846 | 847 | if nickname in self._pending['whois']: 848 | self._whois_info[nickname].update(info) 849 | 850 | async def on_raw_318(self, message): 851 | """ End of /WHOIS list. """ 852 | target, nickname = message.params[:2] 853 | 854 | # Mark future as done. 855 | if nickname in self._pending['whois']: 856 | future = self._pending['whois'].pop(nickname) 857 | future.set_result(self._whois_info[nickname]) 858 | 859 | async def on_raw_319(self, message): 860 | """ WHOIS active channels. """ 861 | target, nickname, channels = message.params[:3] 862 | channels = {channel.lstrip() for channel in channels.strip().split(' ')} 863 | info = { 864 | 'channels': channels 865 | } 866 | 867 | if nickname in self._pending['whois']: 868 | self._whois_info[nickname].update(info) 869 | 870 | async def on_raw_324(self, message): 871 | """ Channel mode. """ 872 | target, channel = message.params[:2] 873 | modes = message.params[2:] 874 | if not self.in_channel(channel): 875 | return 876 | 877 | self.channels[channel]['modes'] = self._parse_channel_modes(channel, modes) 878 | 879 | async def on_raw_329(self, message): 880 | """ Channel creation time. """ 881 | target, channel, timestamp = message.params 882 | if not self.in_channel(channel): 883 | return 884 | 885 | self.channels[channel]['created'] = datetime.datetime.fromtimestamp(int(timestamp)) 886 | 887 | async def on_raw_332(self, message): 888 | """ Current topic on channel join. """ 889 | target, channel, topic = message.params 890 | if not self.in_channel(channel): 891 | return 892 | 893 | self.channels[channel]['topic'] = topic 894 | 895 | async def on_raw_333(self, message): 896 | """ Topic setter and time on channel join. """ 897 | target, channel, setter, timestamp = message.params 898 | if not self.in_channel(channel): 899 | return 900 | 901 | # No need to sync user since this is most likely outdated info. 902 | self.channels[channel]['topic_by'] = self._parse_user(setter)[0] 903 | self.channels[channel]['topic_set'] = datetime.datetime.fromtimestamp(int(timestamp)) 904 | 905 | async def on_raw_353(self, message): 906 | """ Response to /NAMES. """ 907 | target, visibility, channel, names = message.params 908 | if not self.in_channel(channel): 909 | return 910 | 911 | # Set channel visibility. 912 | if visibility == protocol.PUBLIC_CHANNEL_SIGIL: 913 | self.channels[channel]['public'] = True 914 | elif visibility in (protocol.PRIVATE_CHANNEL_SIGIL, protocol.SECRET_CHANNEL_SIGIL): 915 | self.channels[channel]['public'] = False 916 | 917 | # Update channel user list. 918 | for entry in names.split(' '): 919 | statuses = [] 920 | # Make entry safe for _parse_user(). 921 | safe_entry = entry.lstrip(''.join(self._nickname_prefixes.keys())) 922 | # Parse entry and update database. 923 | nick, metadata = self._parse_user(safe_entry) 924 | if not nick: 925 | # nonsense nickname 926 | continue 927 | await self._sync_user(nick, metadata) 928 | 929 | # Get prefixes. 930 | prefixes = set(entry.replace(safe_entry, '')) 931 | 932 | # Check, record and strip status prefixes. 933 | for prefix, status in self._nickname_prefixes.items(): 934 | # Add to list of statuses by user. 935 | if prefix in prefixes: 936 | statuses.append(status) 937 | 938 | # Add user to user list. 939 | self.channels[channel]['users'].add(nick) 940 | # And to channel modes.. 941 | for status in statuses: 942 | if status not in self.channels[channel]['modes']: 943 | self.channels[channel]['modes'][status] = [] 944 | self.channels[channel]['modes'][status].append(nick) 945 | 946 | on_raw_366 = BasicClient._ignored # End of /NAMES list. 947 | 948 | async def on_raw_375(self, message): 949 | """ Start message of the day. """ 950 | await self._registration_completed(message) 951 | self.motd = message.params[1] + '\n' 952 | 953 | async def on_raw_372(self, message): 954 | """ Append message of the day. """ 955 | self.motd += message.params[1] + '\n' 956 | 957 | async def on_raw_376(self, message): 958 | """ End of message of the day. """ 959 | self.motd += message.params[1] + '\n' 960 | 961 | # MOTD is done, let's tell our bot the connection is ready. 962 | await self.on_connect() 963 | 964 | async def on_raw_401(self, message): 965 | """ No such nick/channel. """ 966 | nickname = message.params[1] 967 | 968 | # Remove nickname from whois requests if it involves one of ours. 969 | if nickname in self._pending['whois']: 970 | future = self._pending['whois'].pop(nickname) 971 | future.set_result(None) 972 | del self._whois_info[nickname] 973 | 974 | async def on_raw_402(self, message): 975 | """ No such server. """ 976 | return await self.on_raw_401(message) 977 | 978 | async def on_raw_422(self, message): 979 | """ MOTD is missing. """ 980 | await self._registration_completed(message) 981 | self.motd = None 982 | await self.on_connect() 983 | 984 | async def on_raw_421(self, message): 985 | """ Server responded with 'unknown command'. """ 986 | self.logger.warning('Server responded with "Unknown command: %s"', message.params[0]) 987 | 988 | async def on_raw_432(self, message): 989 | """ Erroneous nickname. """ 990 | if not self.registered: 991 | # Nothing else we can do than try our next nickname. 992 | await self.on_raw_433(message) 993 | 994 | async def on_raw_433(self, message): 995 | """ Nickname in use. """ 996 | if not self.registered: 997 | self._registration_attempts += 1 998 | # Attempt to set new nickname. 999 | if self._attempt_nicknames: 1000 | await self.set_nickname(self._attempt_nicknames.pop(0)) 1001 | else: 1002 | await self.set_nickname( 1003 | self._nicknames[0] + '_' * (self._registration_attempts - len(self._nicknames))) 1004 | 1005 | on_raw_436 = BasicClient._ignored # Nickname collision, issued right before the server kills us. 1006 | 1007 | async def on_raw_451(self, message): 1008 | """ We have to register first before doing X. """ 1009 | self.logger.warning('Attempted to send non-registration command before being registered.') 1010 | 1011 | on_raw_451 = BasicClient._ignored # You have to register first. 1012 | on_raw_462 = BasicClient._ignored # You may not re-register. 1013 | 1014 | 1015 | ## Helpers. 1016 | 1017 | def chunkify(message, chunksize): 1018 | if not message: 1019 | yield message 1020 | else: 1021 | while message: 1022 | chunk = message[:chunksize] 1023 | message = message[chunksize:] 1024 | yield chunk 1025 | -------------------------------------------------------------------------------- /pydle/features/rfc1459/parsing.py: -------------------------------------------------------------------------------- 1 | ## parsing.py 2 | # RFC1459 parsing and construction. 3 | import collections.abc 4 | import pydle.protocol 5 | from . import protocol 6 | 7 | 8 | class RFC1459Message(pydle.protocol.Message): 9 | def __init__(self, command, params, source=None, _raw=None, _valid=True, **kw): 10 | self._kw = kw 11 | self._kw['command'] = command 12 | self._kw['params'] = params 13 | self._kw['source'] = source 14 | self._valid = _valid 15 | self._raw = _raw 16 | self.__dict__.update(self._kw) 17 | 18 | @classmethod 19 | def parse(cls, line, encoding=pydle.protocol.DEFAULT_ENCODING): 20 | """ 21 | Parse given line into IRC message structure. 22 | Returns a Message. 23 | """ 24 | valid = True 25 | 26 | # Decode message. 27 | try: 28 | message = line.decode(encoding) 29 | except UnicodeDecodeError: 30 | # Try our fallback encoding. 31 | message = line.decode(pydle.protocol.FALLBACK_ENCODING) 32 | 33 | # Sanity check for message length. 34 | if len(message) > protocol.MESSAGE_LENGTH_LIMIT: 35 | valid = False 36 | 37 | # Strip message separator. 38 | if message.endswith(protocol.LINE_SEPARATOR): 39 | message = message[:-len(protocol.LINE_SEPARATOR)] 40 | elif message.endswith(protocol.MINIMAL_LINE_SEPARATOR): 41 | message = message[:-len(protocol.MINIMAL_LINE_SEPARATOR)] 42 | 43 | # Sanity check for forbidden characters. 44 | if any(ch in message for ch in protocol.FORBIDDEN_CHARACTERS): 45 | valid = False 46 | 47 | # Extract message sections. 48 | # Format: (:source)? command parameter* 49 | if message.startswith(':'): 50 | parts = protocol.ARGUMENT_SEPARATOR.split(message[1:], 2) 51 | else: 52 | parts = [None] + protocol.ARGUMENT_SEPARATOR.split(message, 1) 53 | 54 | if len(parts) == 3: 55 | source, command, raw_params = parts 56 | elif len(parts) == 2: 57 | source, command = parts 58 | raw_params = '' 59 | else: 60 | raise pydle.protocol.ProtocolViolation('Improper IRC message format: not enough elements.', message=message) 61 | 62 | # Sanity check for command. 63 | if not protocol.COMMAND_PATTERN.match(command): 64 | valid = False 65 | 66 | # Extract parameters properly. 67 | # Format: (word|:sentence)* 68 | 69 | # Only parameter is a 'trailing' sentence. 70 | if raw_params.startswith(protocol.TRAILING_PREFIX): 71 | params = [raw_params[len(protocol.TRAILING_PREFIX):]] 72 | # We have a sentence in our parameters. 73 | elif ' ' + protocol.TRAILING_PREFIX in raw_params: 74 | index = raw_params.find(' ' + protocol.TRAILING_PREFIX) 75 | 76 | # Get all single-word parameters. 77 | params = protocol.ARGUMENT_SEPARATOR.split(raw_params[:index].rstrip(' ')) 78 | # Extract last parameter as sentence 79 | params.append(raw_params[index + len(protocol.TRAILING_PREFIX) + 1:]) 80 | # We have some parameters, but no sentences. 81 | elif raw_params: 82 | params = protocol.ARGUMENT_SEPARATOR.split(raw_params) 83 | # No parameters. 84 | else: 85 | params = [] 86 | 87 | # Commands can be either [a-zA-Z]+ or [0-9]+. 88 | # In the former case, force it to uppercase. 89 | # In the latter case (a numeric command), try to represent it as such. 90 | try: 91 | command = int(command) 92 | except ValueError: 93 | command = command.upper() 94 | 95 | # Return parsed message. 96 | return RFC1459Message(command, params, source=source, _valid=valid, _raw=message) 97 | 98 | def construct(self, force=False): 99 | """ Construct a raw IRC message. """ 100 | # Sanity check for command. 101 | command = str(self.command) 102 | if not protocol.COMMAND_PATTERN.match(command) and not force: 103 | raise pydle.protocol.ProtocolViolation('The constructed command does not follow the command pattern ({pat})'.format(pat=protocol.COMMAND_PATTERN.pattern), message=command) 104 | message = command.upper() 105 | 106 | # Add parameters. 107 | if not self.params: 108 | message += ' ' 109 | for idx, param in enumerate(self.params): 110 | # Trailing parameter? 111 | if not param or ' ' in param or param[0] == ':': 112 | if idx + 1 < len(self.params) and not force: 113 | raise pydle.protocol.ProtocolViolation('Only the final parameter of an IRC message can be trailing and thus contain spaces, or start with a colon.', message=param) 114 | message += ' ' + protocol.TRAILING_PREFIX + param 115 | # Regular parameter. 116 | else: 117 | message += ' ' + param 118 | 119 | # Prepend source. 120 | if self.source: 121 | message = ':' + self.source + ' ' + message 122 | 123 | # Sanity check for characters. 124 | if any(ch in message for ch in protocol.FORBIDDEN_CHARACTERS) and not force: 125 | raise pydle.protocol.ProtocolViolation('The constructed message contains forbidden characters ({chs}).'.format(chs=', '.join(protocol.FORBIDDEN_CHARACTERS)), message=message) 126 | 127 | # Sanity check for length. 128 | message += protocol.LINE_SEPARATOR 129 | if len(message) > protocol.MESSAGE_LENGTH_LIMIT and not force: 130 | raise pydle.protocol.ProtocolViolation('The constructed message is too long. ({len} > {maxlen})'.format(len=len(message), maxlen=protocol.MESSAGE_LENGTH_LIMIT), message=message) 131 | 132 | return message 133 | 134 | 135 | def normalize(input, case_mapping=protocol.DEFAULT_CASE_MAPPING): 136 | """ Normalize input according to case mapping. """ 137 | if case_mapping not in protocol.CASE_MAPPINGS: 138 | raise pydle.protocol.ProtocolViolation('Unknown case mapping ({})'.format(case_mapping)) 139 | 140 | input = input.lower() 141 | 142 | if case_mapping in ('rfc1459', 'rfc1459-strict'): 143 | input = input.replace('{', '[').replace('}', ']').replace('|', '\\') 144 | if case_mapping == 'rfc1459': 145 | input = input.replace('~', '^') 146 | 147 | return input 148 | 149 | 150 | class NormalizingDict(collections.abc.MutableMapping): 151 | """ A dict that normalizes entries according to the given case mapping. """ 152 | def __init__(self, *args, case_mapping): 153 | self.storage = {} 154 | self.case_mapping = case_mapping 155 | self.update(dict(*args)) 156 | 157 | def __getitem__(self, key): 158 | if not isinstance(key, str): 159 | raise KeyError(key) 160 | return self.storage[normalize(key, case_mapping=self.case_mapping)] 161 | 162 | def __setitem__(self, key, value): 163 | if not isinstance(key, str): 164 | raise KeyError(key) 165 | self.storage[normalize(key, case_mapping=self.case_mapping)] = value 166 | 167 | def __delitem__(self, key): 168 | if not isinstance(key, str): 169 | raise KeyError(key) 170 | del self.storage[normalize(key, case_mapping=self.case_mapping)] 171 | 172 | def __iter__(self): 173 | return iter(self.storage) 174 | 175 | def __len__(self): 176 | return len(self.storage) 177 | 178 | def __repr__(self): 179 | return '{mod}.{cls}({dict}, case_mapping={cm})'.format( 180 | mod=__name__, cls=self.__class__.__name__, 181 | dict=self.storage, cm=self.case_mapping) 182 | 183 | 184 | # Parsing. 185 | 186 | def parse_user(raw): 187 | """ Parse nick(!user(@host)?)? structure. """ 188 | nick = raw 189 | user = None 190 | host = None 191 | 192 | # Attempt to extract host. 193 | if protocol.HOST_SEPARATOR in raw: 194 | raw, host = raw.split(protocol.HOST_SEPARATOR) 195 | # Attempt to extract user. 196 | if protocol.USER_SEPARATOR in raw: 197 | nick, user = raw.split(protocol.USER_SEPARATOR) 198 | 199 | return nick, user, host 200 | 201 | 202 | def parse_modes(modes, current, behaviour): 203 | """ Parse mode change string(s) and return updated dictionary. """ 204 | current = current.copy() 205 | modes = modes[:] 206 | 207 | # Iterate in a somewhat odd way over the list because we want to modify it during iteration. 208 | i = 0 209 | while i < len(modes): 210 | piece = modes[i] 211 | add = True 212 | sigiled = False 213 | 214 | for mode in piece: 215 | # Set mode to addition or deletion of modes. 216 | if mode == '+': 217 | add = True 218 | sigiled = True 219 | continue 220 | if mode == '-': 221 | add = False 222 | sigiled = True 223 | continue 224 | 225 | # Find mode behaviour. 226 | for type, affected in behaviour.items(): 227 | if mode in affected: 228 | break 229 | else: 230 | # If we don't have a behaviour for this mode, assume it has no parameters... 231 | type = protocol.BEHAVIOUR_NO_PARAMETER 232 | 233 | # Don't parse modes that are meant for list retrieval. 234 | if type == protocol.BEHAVIOUR_LIST and not sigiled: 235 | continue 236 | 237 | # Do we require a parameter? 238 | if type in (protocol.BEHAVIOUR_PARAMETER, protocol.BEHAVIOUR_LIST) or (type == protocol.BEHAVIOUR_PARAMETER_ON_SET and add): 239 | # Do we _have_ a parameter? 240 | if i + 1 == len(modes): 241 | raise pydle.protocol.ProtocolViolation('Attempted to parse mode with parameter ({s}{mode}) but no parameters left in mode list.'.format( 242 | mode=mode, s='+' if add else '-'), ' '.join(modes)) 243 | param = modes.pop(i + 1) 244 | 245 | # Now update the actual mode dict with our new values. 246 | if type in (protocol.BEHAVIOUR_PARAMETER, protocol.BEHAVIOUR_LIST): 247 | # Add/remove parameter from list. 248 | if add: 249 | if mode not in current: 250 | current[mode] = [] 251 | current[mode].append(param) 252 | else: 253 | if mode in current and param in current[mode]: 254 | current[mode].remove(param) 255 | elif type == protocol.BEHAVIOUR_PARAMETER_ON_SET and add: 256 | # Simply set parameter. 257 | current[mode] = param 258 | else: 259 | # Simply add/remove option. 260 | if add: 261 | current[mode] = True 262 | else: 263 | if mode in current: 264 | del current[mode] 265 | i += 1 266 | 267 | return current 268 | -------------------------------------------------------------------------------- /pydle/features/rfc1459/protocol.py: -------------------------------------------------------------------------------- 1 | ## protocol.py 2 | # RFC1459 protocol constants. 3 | import re 4 | import collections 5 | from pydle.client import Error 6 | 7 | 8 | class ServerError(Error): 9 | pass 10 | 11 | 12 | # While this *technically* is supposed to be 143, I've yet to see a server that actually uses those. 13 | DEFAULT_PORT = 6667 14 | 15 | ## Limits. 16 | 17 | CHANNEL_LIMITS_GROUPS = { 18 | '#': frozenset('#&'), 19 | '&': frozenset('#&') 20 | } 21 | CHANNEL_LIMITS = { 22 | frozenset('#&'): 10 23 | } 24 | LIST_LIMITS_GROUPS = { 25 | 'b': frozenset('b') 26 | } 27 | LIST_LIMITS = { 28 | frozenset('b'): None 29 | } 30 | PARAMETER_LIMIT = 15 31 | MESSAGE_LENGTH_LIMIT = 512 32 | CHANNEL_LENGTH_LIMIT = 200 33 | NICKNAME_LENGTH_LIMIT = 8 34 | TOPIC_LENGTH_LIMIT = 450 35 | 36 | ## Defaults. 37 | 38 | BEHAVIOUR_NO_PARAMETER = 'noparam' 39 | BEHAVIOUR_PARAMETER = 'param' 40 | BEHAVIOUR_PARAMETER_ON_SET = 'param_set' 41 | BEHAVIOUR_LIST = 'list' 42 | 43 | CHANNEL_MODES = {'o', 'p', 's', 'i', 't', 'n', 'b', 'v', 'm', 'r', 'k', 'l'} 44 | CHANNEL_MODES_BEHAVIOUR = { 45 | BEHAVIOUR_LIST: {'b'}, 46 | BEHAVIOUR_PARAMETER: {'o', 'v'}, 47 | BEHAVIOUR_PARAMETER_ON_SET: {'k', 'l'}, 48 | BEHAVIOUR_NO_PARAMETER: {'p', 's', 'i', 't', 'n', 'm', 'r'} 49 | } 50 | CHANNEL_PREFIXES = {'#', '&'} 51 | CASE_MAPPINGS = {'ascii', 'rfc1459', 'strict-rfc1459'} 52 | DEFAULT_CASE_MAPPING = 'rfc1459' 53 | NICKNAME_PREFIXES = collections.OrderedDict([ 54 | ('@', 'o'), 55 | ('+', 'v') 56 | ]) 57 | USER_MODES = {'i', 'w', 's', 'o'} 58 | # Maybe one day, user modes will have parameters... 59 | USER_MODES_BEHAVIOUR = { 60 | BEHAVIOUR_NO_PARAMETER: {'i', 'w', 's', 'o'} 61 | } 62 | 63 | ## Message parsing. 64 | 65 | LINE_SEPARATOR = '\r\n' 66 | MINIMAL_LINE_SEPARATOR = '\n' 67 | 68 | FORBIDDEN_CHARACTERS = {'\r', '\n', '\0'} 69 | USER_SEPARATOR = '!' 70 | HOST_SEPARATOR = '@' 71 | 72 | PRIVATE_CHANNEL_SIGIL = '@' 73 | SECRET_CHANNEL_SIGIL = '*' 74 | PUBLIC_CHANNEL_SIGIL = '=' 75 | 76 | ARGUMENT_SEPARATOR = re.compile(' +', re.UNICODE) 77 | COMMAND_PATTERN = re.compile('^([a-zA-Z]+|[0-9]+)$', re.UNICODE) 78 | TRAILING_PREFIX = ':' 79 | -------------------------------------------------------------------------------- /pydle/features/rpl_whoishost/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Adds support for RPL_WHOISHOST (reply type 378) 3 | """ 4 | from .rpl_whoishost import RplWhoisHostSupport 5 | 6 | __all__ = ["RplWhoisHostSupport"] 7 | -------------------------------------------------------------------------------- /pydle/features/rpl_whoishost/rpl_whoishost.py: -------------------------------------------------------------------------------- 1 | from pydle.features.rfc1459 import RFC1459Support 2 | 3 | 4 | class RplWhoisHostSupport(RFC1459Support): 5 | """ Adds support for RPL_WHOISHOST messages (378) """ 6 | 7 | async def on_raw_378(self, message): 8 | """ handles a RPL_WHOISHOST message """ 9 | _, target, data = message.params 10 | data = data.split(" ") 11 | target = message.params[1] 12 | ip_addr = data[-1] 13 | host = data[-2] 14 | 15 | meta = {"real_ip_address": ip_addr, "real_hostname": host} 16 | await self._sync_user(target, meta) 17 | if target in self._whois_info: 18 | self._whois_info[target]["real_ip_address"] = ip_addr 19 | self._whois_info[target]["real_hostname"] = host 20 | 21 | async def whois(self, nickname): 22 | info = await super().whois(nickname) 23 | if info is None: 24 | return info 25 | info.setdefault("real_ip_address", None) 26 | info.setdefault("real_hostname", None) 27 | return info 28 | -------------------------------------------------------------------------------- /pydle/features/tls.py: -------------------------------------------------------------------------------- 1 | ## tls.py 2 | # TLS support. 3 | import pydle.protocol 4 | from pydle.features import rfc1459 5 | from .. import connection 6 | 7 | __all__ = ['TLSSupport'] 8 | 9 | DEFAULT_TLS_PORT = 6697 10 | 11 | 12 | class TLSSupport(rfc1459.RFC1459Support): 13 | """ 14 | TLS support. 15 | 16 | Pass tls_client_cert, tls_client_cert_key and optionally tls_client_cert_password to have pydle send a client certificate 17 | upon TLS connections. 18 | """ 19 | 20 | ## Internal overrides. 21 | 22 | def __init__(self, *args, tls_client_cert=None, tls_client_cert_key=None, tls_client_cert_password=None, **kwargs): 23 | super().__init__(*args, **kwargs) 24 | self.tls_client_cert = tls_client_cert 25 | self.tls_client_cert_key = tls_client_cert_key 26 | self.tls_client_cert_password = tls_client_cert_password 27 | 28 | async def connect(self, hostname=None, port=None, tls=False, **kwargs): 29 | """ Connect to a server, optionally over TLS. See pydle.features.RFC1459Support.connect for misc parameters. """ 30 | if not port: 31 | if tls: 32 | port = DEFAULT_TLS_PORT 33 | else: 34 | port = rfc1459.protocol.DEFAULT_PORT 35 | return await super().connect(hostname, port, tls=tls, **kwargs) 36 | 37 | async def _connect(self, hostname, port, reconnect=False, password=None, encoding=pydle.protocol.DEFAULT_ENCODING, channels=None, tls=False, tls_verify=False, source_address=None): 38 | """ Connect to IRC server, optionally over TLS. """ 39 | self.password = password 40 | 41 | # Create connection if we can't reuse it. 42 | if not reconnect: 43 | self._autojoin_channels = channels or [] 44 | self.connection = connection.Connection(hostname, port, 45 | source_address=source_address, 46 | tls=tls, tls_verify=tls_verify, 47 | tls_certificate_file=self.tls_client_cert, 48 | tls_certificate_keyfile=self.tls_client_cert_key, 49 | tls_certificate_password=self.tls_client_cert_password, 50 | eventloop=self.eventloop) 51 | self.encoding = encoding 52 | 53 | # Connect. 54 | await self.connection.connect() 55 | 56 | ## API. 57 | 58 | async def whois(self, nickname): 59 | info = await super().whois(nickname) 60 | if info is None: 61 | return info 62 | info.setdefault('secure', False) 63 | return info 64 | 65 | ## Message callbacks. 66 | 67 | async def on_raw_671(self, message): 68 | """ WHOIS: user is connected securely. """ 69 | target, nickname = message.params[:2] 70 | info = { 71 | 'secure': True 72 | } 73 | 74 | if nickname in self._whois_info: 75 | self._whois_info[nickname].update(info) 76 | -------------------------------------------------------------------------------- /pydle/features/whox.py: -------------------------------------------------------------------------------- 1 | ## whox.py 2 | # WHOX support. 3 | from pydle.features import isupport, account 4 | 5 | NO_ACCOUNT = '0' 6 | # Maximum of 3 characters because Charybdis stupidity. The ASCII values of 'pydle' added together. 7 | WHOX_IDENTIFIER = '542' 8 | 9 | 10 | class WHOXSupport(isupport.ISUPPORTSupport, account.AccountSupport): 11 | 12 | ## Overrides. 13 | 14 | async def on_raw_join(self, message): 15 | """ Override JOIN to send WHOX. """ 16 | await super().on_raw_join(message) 17 | nick, metadata = self._parse_user(message.source) 18 | channels = message.params[0].split(',') 19 | 20 | if self.is_same_nick(self.nickname, nick): 21 | # We joined. 22 | if 'WHOX' in self._isupport and self._isupport['WHOX']: 23 | # Get more relevant channel info thanks to WHOX. 24 | await self.rawmsg('WHO', ','.join(channels), '%tnurha,{id}'.format(id=WHOX_IDENTIFIER)) 25 | else: 26 | # Find account name of person. 27 | pass 28 | 29 | async def _create_user(self, nickname): 30 | super()._create_user(nickname) 31 | if self.registered and 'WHOX' not in self._isupport: 32 | await self.whois(nickname) 33 | 34 | async def on_raw_354(self, message): 35 | """ WHOX results have arrived. """ 36 | # Is the message for us? 37 | target, identifier = message.params[:2] 38 | if identifier != WHOX_IDENTIFIER: 39 | return 40 | 41 | # Great. Extract relevant information. 42 | metadata = { 43 | 'nickname': message.params[4], 44 | 'username': message.params[2], 45 | 'realname': message.params[6], 46 | 'hostname': message.params[3], 47 | } 48 | if message.params[5] != NO_ACCOUNT: 49 | metadata['identified'] = True 50 | metadata['account'] = message.params[5] 51 | 52 | await self._sync_user(metadata['nickname'], metadata) 53 | -------------------------------------------------------------------------------- /pydle/protocol.py: -------------------------------------------------------------------------------- 1 | ## protocol.py 2 | # IRC implementation-agnostic constants/helpers. 3 | import re 4 | from abc import abstractmethod 5 | 6 | DEFAULT_ENCODING = 'utf-8' 7 | FALLBACK_ENCODING = 'iso-8859-1' 8 | 9 | 10 | ## Errors. 11 | 12 | class ProtocolViolation(Exception): 13 | """ An error that occurred while parsing or constructing an IRC message that violates the IRC protocol. """ 14 | def __init__(self, msg, message): 15 | super().__init__(msg) 16 | self.irc_message = message 17 | 18 | 19 | ## Bases. 20 | 21 | class Message: 22 | """ Abstract message class. Messages must inherit from this class. """ 23 | @classmethod 24 | @abstractmethod 25 | def parse(cls, line, encoding=DEFAULT_ENCODING): 26 | """ Parse data into IRC message. Return a Message instance or raise an error. """ 27 | raise NotImplementedError() 28 | 29 | @abstractmethod 30 | def construct(self, force=False): 31 | """ Convert message into raw IRC command. If `force` is True, don't attempt to check message validity. """ 32 | raise NotImplementedError() 33 | 34 | def __str__(self): 35 | return self.construct() 36 | 37 | ## Misc. 38 | 39 | 40 | def identifierify(name): 41 | """ Clean up name so it works for a Python identifier. """ 42 | name = name.lower() 43 | name = re.sub('[^a-z0-9]', '_', name) 44 | return name 45 | -------------------------------------------------------------------------------- /pydle/utils/__init__.py: -------------------------------------------------------------------------------- 1 | from . import irccat, run 2 | -------------------------------------------------------------------------------- /pydle/utils/_args.py: -------------------------------------------------------------------------------- 1 | ## _args.py 2 | # Common argument parsing code. 3 | import argparse 4 | import functools 5 | import logging 6 | import pydle 7 | 8 | 9 | def client_from_args(name, description, default_nick='Bot', cls=pydle.Client): 10 | # Parse some arguments. 11 | parser = argparse.ArgumentParser(name, description=description, add_help=False, 12 | epilog='This program is part of {package}.'.format(package=pydle.__name__)) 13 | 14 | meta = parser.add_argument_group('Meta') 15 | meta.add_argument('-h', '--help', action='help', help='What you are reading right now.') 16 | meta.add_argument('-v', '--version', action='version', version='{package}/%(prog)s {ver}'.format(package=pydle.__name__, ver=pydle.__version__), help='Dump version number.') 17 | meta.add_argument('-V', '--verbose', help='Be verbose in warnings and errors.', action='store_true', default=False) 18 | meta.add_argument('-d', '--debug', help='Show debug output.', action='store_true', default=False) 19 | 20 | conn = parser.add_argument_group('Connection') 21 | conn.add_argument('server', help='The server to connect to.', metavar='SERVER') 22 | conn.add_argument('-p', '--port', help='The port to use. (default: 6667, 6697 (TLS))') 23 | conn.add_argument('-P', '--password', help='Server password.', metavar='PASS') 24 | conn.add_argument('--tls', help='Use TLS. (default: no)', action='store_true', default=False) 25 | conn.add_argument('--verify-tls', help='Verify TLS certificate sent by server. (default: no)', action='store_true', default=False) 26 | conn.add_argument('-e', '--encoding', help='Connection encoding. (default: UTF-8)', default='utf-8', metavar='ENCODING') 27 | 28 | init = parser.add_argument_group('Initialization') 29 | init.add_argument('-n', '--nickname', help='Nickname. Can be set multiple times to set fallback nicknames. (default: {})'.format(default_nick), action='append', dest='nicknames', default=[], metavar='NICK') 30 | init.add_argument('-u', '--username', help='Username. (default: derived from nickname)', metavar='USER') 31 | init.add_argument('-r', '--realname', help='Realname (GECOS). (default: derived from nickname)', metavar='REAL') 32 | init.add_argument('-c', '--channel', help='Channel to automatically join. Can be set multiple times for multiple channels.', action='append', dest='channels', default=[], metavar='CHANNEL') 33 | 34 | auth = parser.add_argument_group('Authentication') 35 | auth.add_argument('--sasl-identity', help='Identity to use for SASL authentication. (default: )', default='', metavar='SASLIDENT') 36 | auth.add_argument('--sasl-username', help='Username to use for SASL authentication.', metavar='SASLUSER') 37 | auth.add_argument('--sasl-password', help='Password to use for SASL authentication.', metavar='SASLPASS') 38 | auth.add_argument('--sasl-mechanism', help='Mechanism to use for SASL authentication.', metavar='SASLMECH') 39 | auth.add_argument('--tls-client-cert', help='TLS client certificate to use.', metavar='CERT') 40 | auth.add_argument('--tls-client-cert-keyfile', help='Keyfile to use for TLS client cert.', metavar='KEYFILE') 41 | 42 | args = parser.parse_args() 43 | 44 | # Set nicknames straight. 45 | if not args.nicknames: 46 | nick = default_nick 47 | fallback = [] 48 | else: 49 | nick = args.nicknames.pop(0) 50 | fallback = args.nicknames 51 | 52 | # Set log level. 53 | if args.debug: 54 | log_level = logging.DEBUG 55 | elif not args.verbose: 56 | log_level = logging.ERROR 57 | 58 | logging.basicConfig(level=log_level) 59 | 60 | # Setup client and connect. 61 | client = cls(nickname=nick, fallback_nicknames=fallback, username=args.username, realname=args.realname, 62 | sasl_identity=args.sasl_identity, sasl_username=args.sasl_username, sasl_password=args.sasl_password, sasl_mechanism=args.sasl_mechanism, 63 | tls_client_cert=args.tls_client_cert, tls_client_cert_key=args.tls_client_cert_keyfile) 64 | 65 | connect = functools.partial(client.connect, 66 | hostname=args.server, port=args.port, password=args.password, encoding=args.encoding, 67 | channels=args.channels, tls=args.tls, tls_verify=args.verify_tls 68 | ) 69 | 70 | return client, connect 71 | -------------------------------------------------------------------------------- /pydle/utils/irccat.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | ## irccat.py 3 | # Simple threaded irccat implementation, using pydle. 4 | import sys 5 | import logging 6 | import asyncio 7 | 8 | from .. import Client, __version__ 9 | from . import _args 10 | 11 | 12 | class IRCCat(Client): 13 | """ irccat. Takes raw messages on stdin, dumps raw messages to stdout. Life has never been easier. """ 14 | 15 | def __init__(self, *args, **kwargs): 16 | super().__init__(*args, **kwargs) 17 | self.async_stdin = None 18 | 19 | async def _send(self, data): 20 | await super()._send(data) 21 | 22 | async def process_stdin(self): 23 | """ Yes. """ 24 | loop = asyncio.get_event_loop() 25 | 26 | self.async_stdin = asyncio.StreamReader() 27 | reader_protocol = asyncio.StreamReaderProtocol(self.async_stdin) 28 | await loop.connect_read_pipe(lambda: reader_protocol, sys.stdin) 29 | 30 | while True: 31 | line = await self.async_stdin.readline() 32 | if not line: 33 | break 34 | await self.raw(line.decode('utf-8')) 35 | 36 | await self.quit('EOF') 37 | 38 | async def on_raw(self, message): 39 | print(message._raw) 40 | await super().on_raw(message) 41 | 42 | async def on_ctcp_version(self, source, target, contents): 43 | await self.ctcp_reply(source, 'VERSION', 'pydle-irccat v{}'.format(__version__)) 44 | 45 | 46 | async def _main(): 47 | # Create client. 48 | irccat, connect = _args.client_from_args('irccat', default_nick='irccat', 49 | description='Process raw IRC messages from stdin, dump received IRC messages to stdout.', 50 | cls=IRCCat) 51 | await connect() 52 | while True: 53 | await irccat.process_stdin() 54 | 55 | 56 | def main(): 57 | # Setup logging. 58 | logging.basicConfig(format='!! %(levelname)s: %(message)s') 59 | asyncio.get_event_loop().run_until_complete(_main()) 60 | 61 | 62 | if __name__ == '__main__': 63 | main() 64 | -------------------------------------------------------------------------------- /pydle/utils/run.py: -------------------------------------------------------------------------------- 1 | ## run.py 2 | # Run client. 3 | import asyncio 4 | from . import _args 5 | 6 | 7 | def main(): 8 | client, connect = _args.client_from_args('pydle', description='pydle IRC library.') 9 | loop = asyncio.get_event_loop() 10 | asyncio.ensure_future(connect(), loop=loop) 11 | loop.run_forever() 12 | 13 | 14 | if __name__ == '__main__': 15 | main() 16 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "pydle" 3 | version = "1.0.1" 4 | description = "A compact, flexible, and standards-abiding IRC library for python3." 5 | authors = ["Shiz "] 6 | repository = "https://github.com/Shizmob/pydle" 7 | keywords = ["irc", "library","python3","compact","flexible"] 8 | license = "BSD" 9 | 10 | [tool.poetry.dependencies] 11 | python = ">=3.6,<3.10" 12 | 13 | [tool.poetry.dependencies.pure-sasl] 14 | version = "^0.6.2" 15 | optional = true 16 | 17 | # Stuff needed for development, but not for install&usage 18 | [tool.poetry.dev-dependencies] 19 | sphinx-rtd-theme = "^1.0.0" 20 | Sphinx = "^5.0.2" 21 | 22 | 23 | [tool.poetry.extras] 24 | sasl = ["pure-sasl"] 25 | 26 | [tool.poetry.scripts] 27 | pydle = "pydle.utils.run:main" 28 | pydle-irccat = 'pydle.utils.irccat:main' 29 | 30 | [build-system] 31 | requires = ["poetry-core>=1.0.0"] 32 | build-backend = "poetry.core.masonry.api" 33 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | asyncio_mode = auto 3 | markers = 4 | meta: mark a test as meta 5 | slow: mark a test as sssslllooowwww 6 | ircv3: mark a test as related to v3 of the IRC standard 7 | unit: mark a test as relating to the unit -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pure-sasl >=0.1.6 2 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shizmob/pydle/21dedc6679a5fcaab5c7fda9fc63d93676d89b04/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pytest 3 | 4 | 5 | def pytest_addoption(parser): 6 | # Add option to skip meta (test suite-testing) tests. 7 | parser.addoption( 8 | "--skip-meta", action="store_true", help="skip test suite-testing tests" 9 | ) 10 | # Add option to skip slow tests. 11 | parser.addoption("--skip-slow", action="store_true", help="skip slow tests") 12 | # Add option to skip real life tests. 13 | parser.addoption("--skip-real", action="store_true", help="skip real life tests") 14 | 15 | 16 | def pytest_runtest_setup(item): 17 | if "meta" in item.keywords and item.config.getoption("--skip-meta"): 18 | pytest.skip("skipping meta test (--skip-meta given)") 19 | if "slow" in item.keywords and item.config.getoption("--skip-slow"): 20 | pytest.skip("skipping slow test (--skip-slow given)") 21 | 22 | if "real" in item.keywords: 23 | if item.config.getoption("--skip-real"): 24 | pytest.skip("skipping real life test (--skip-real given)") 25 | if not os.getenv("PYDLE_TESTS_REAL_HOST") or not os.getenv( 26 | "PYDLE_TESTS_REAL_PORT" 27 | ): 28 | pytest.skip("skipping real life test (no real server given)") 29 | -------------------------------------------------------------------------------- /tests/fixtures.py: -------------------------------------------------------------------------------- 1 | import pydle 2 | from .mocks import MockServer, MockClient 3 | 4 | 5 | def with_client(*features, connected=True, **options): 6 | if not features: 7 | features = (pydle.client.BasicClient,) 8 | if features not in with_client.classes: 9 | with_client.classes[features] = pydle.featurize(MockClient, *features) 10 | 11 | def inner(f): 12 | async def run(): 13 | server = MockServer() 14 | client = with_client.classes[features]( 15 | "TestcaseRunner", mock_server=server, **options 16 | ) 17 | if connected: 18 | await client.connect("mock://local", 1337) 19 | 20 | run.__name__ = f.__name__ 21 | return run 22 | 23 | return inner 24 | 25 | 26 | with_client.classes = {} 27 | -------------------------------------------------------------------------------- /tests/mocks.py: -------------------------------------------------------------------------------- 1 | import json 2 | import pydle 3 | 4 | try: 5 | from unittest.mock import Mock 6 | except: 7 | from mock import Mock 8 | 9 | 10 | class MockServer: 11 | """ 12 | A mock server that will receive data and messages from the client, 13 | and can send its own data and messages. 14 | """ 15 | 16 | def __init__(self): 17 | self.connection = None 18 | self.recvbuffer = "" 19 | self.msgbuffer = [] 20 | 21 | def receive(self, *args, **kwargs): 22 | self.msgbuffer.append((args, kwargs)) 23 | 24 | def receivedata(self, data): 25 | self.recvbuffer += data 26 | 27 | def received(self, *args, **kwargs): 28 | if (args, kwargs) in self.msgbuffer: 29 | self.msgbuffer.remove((args, kwargs)) 30 | return True 31 | return False 32 | 33 | def receiveddata(self, data): 34 | if data in self.recvbuffer: 35 | self.recvbuffer.replace(data, "", 1) 36 | return True 37 | return False 38 | 39 | async def send(self, *args, **kwargs): 40 | msg = self.connection._mock_client._create_message(*args, **kwargs) 41 | await self.connection._mock_client.on_raw(msg) 42 | 43 | def sendraw(self, data): 44 | self.connection._mock_client.on_data(data) 45 | 46 | 47 | class MockClient(pydle.client.BasicClient): 48 | """A client that subtitutes its own connection for a mock connection to MockServer.""" 49 | 50 | def __init__(self, *args, mock_server=None, **kwargs): 51 | self._mock_server = mock_server 52 | self._mock_logger = Mock() 53 | super().__init__(*args, **kwargs) 54 | 55 | @property 56 | def logger(self): 57 | return self._mock_logger 58 | 59 | @logger.setter 60 | def logger(self, val): 61 | pass 62 | 63 | async def _connect(self, hostname, port, *args, **kwargs): 64 | self.connection = MockConnection( 65 | hostname, 66 | port, 67 | mock_client=self, 68 | mock_server=self._mock_server, 69 | eventloop=self.eventloop, 70 | ) 71 | await self.connection.connect() 72 | await self.on_connect() 73 | 74 | async def raw(self, data): 75 | self.connection._mock_server.receivedata(data) 76 | 77 | async def rawmsg(self, *args, **kwargs): 78 | self.connection._mock_server.receive(*args, **kwargs) 79 | 80 | def _create_message(self, *args, **kwargs): 81 | return MockMessage(*args, **kwargs) 82 | 83 | def _has_message(self): 84 | return b"\r\n" in self._receive_buffer 85 | 86 | def _parse_message(self): 87 | message, _, data = self._receive_buffer.partition(b"\r\n") 88 | self._receive_buffer = data 89 | return MockMessage.parse(message + b"\r\n", encoding=self.encoding) 90 | 91 | 92 | class MockConnection(pydle.connection.Connection): 93 | """A mock connection between a client and a server.""" 94 | 95 | def __init__(self, *args, mock_client=None, mock_server=None, **kwargs): 96 | super().__init__(*args, **kwargs) 97 | self._mock_connected = False 98 | self._mock_server = mock_server 99 | self._mock_client = mock_client 100 | 101 | def on(self, *args, **kwargs): 102 | pass 103 | 104 | def off(self, *args, **kwargs): 105 | pass 106 | 107 | @property 108 | def connected(self): 109 | return self._mock_connected 110 | 111 | async def connect(self, *args, **kwargs): 112 | self._mock_server.connection = self 113 | self._mock_connected = True 114 | 115 | async def disconnect(self, *args, **kwargs): 116 | self._mock_server.connection = None 117 | self._mock_connected = False 118 | 119 | 120 | class MockMessage(pydle.protocol.Message): 121 | def __init__(self, command, *params, source=None, **kw): 122 | self.command = command 123 | self.params = params 124 | self.source = source 125 | self.kw = kw 126 | self._valid = True 127 | 128 | @classmethod 129 | def parse(cls, line, encoding=pydle.protocol.DEFAULT_ENCODING): 130 | # Decode message. 131 | line = line.strip() 132 | try: 133 | message = line.decode(encoding) 134 | except UnicodeDecodeError: 135 | # Try our fallback encoding. 136 | message = line.decode(pydle.protocol.FALLBACK_ENCODING) 137 | 138 | try: 139 | val = json.loads(message) 140 | except: 141 | raise pydle.protocol.ProtocolViolation("Invalid JSON") 142 | 143 | return MockMessage( 144 | val["command"], *val["params"], source=val["source"], **val["kw"] 145 | ) 146 | 147 | def construct(self): 148 | return ( 149 | json.dumps( 150 | { 151 | "command": self.command, 152 | "params": self.params, 153 | "source": self.source, 154 | "kw": self.kw, 155 | } 156 | ) 157 | + "\r\n" 158 | ) 159 | -------------------------------------------------------------------------------- /tests/test__fixtures.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from pytest import mark 3 | import pydle 4 | from .fixtures import with_client 5 | from .mocks import MockClient, MockServer, MockConnection 6 | 7 | 8 | @pytest.mark.asyncio 9 | @mark.meta 10 | @with_client(connected=False) 11 | def test_fixtures_with_client(server, client): 12 | assert isinstance(server, MockServer) 13 | assert isinstance(client, MockClient) 14 | assert ( 15 | client.__class__.__mro__[1] is MockClient 16 | ), "MockClient should be first in method resolution order" 17 | 18 | assert not client.connected 19 | 20 | 21 | @pytest.mark.asyncio 22 | @mark.meta 23 | @with_client(pydle.features.RFC1459Support, connected=False) 24 | def test_fixtures_with_client_features(server, client): 25 | assert isinstance(client, MockClient) 26 | assert ( 27 | client.__class__.__mro__[1] is MockClient 28 | ), "MockClient should be first in method resolution order" 29 | assert isinstance(client, pydle.features.RFC1459Support) 30 | 31 | 32 | @pytest.mark.asyncio 33 | @mark.meta 34 | @with_client(username="test_runner") 35 | def test_fixtures_with_client_options(server, client): 36 | assert client.username == "test_runner" 37 | 38 | 39 | @pytest.mark.asyncio 40 | @mark.meta 41 | @with_client() 42 | async def test_fixtures_with_client_connected(server, client): 43 | assert client.connected 44 | assert isinstance(client.eventloop) 45 | assert isinstance(client.connection, MockConnection) 46 | assert isinstance(client.connection.eventloop) 47 | assert client.eventloop is client.connection.eventloop 48 | -------------------------------------------------------------------------------- /tests/test__mocks.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from pytest import mark 3 | import pydle 4 | from .fixtures import with_client 5 | from .mocks import Mock, MockConnection 6 | 7 | 8 | class Passed: 9 | def __init__(self): 10 | self._passed = False 11 | 12 | def __bool__(self): 13 | return self._passed 14 | 15 | def set(self): 16 | self._passed = True 17 | 18 | def reset(self): 19 | self._passed = False 20 | 21 | 22 | ## Client. 23 | 24 | 25 | @pytest.mark.asyncio 26 | @mark.meta 27 | @with_client(connected=False) 28 | async def test_mock_client_connect(server, client): 29 | assert not client.connected 30 | client.on_connect = Mock() 31 | await client.connect("mock://local", 1337) 32 | 33 | assert client.connected 34 | assert client.on_connect.called 35 | 36 | client.disconnect() 37 | assert not client.connected 38 | 39 | 40 | @pytest.mark.asyncio 41 | @mark.meta 42 | @with_client() 43 | async def test_mock_client_send(server, client): 44 | await client.raw("benis") 45 | assert server.receiveddata("benis") 46 | await client.rawmsg("INSTALL", "Gentoo") 47 | assert server.received("INSTALL", "Gentoo") 48 | 49 | 50 | @pytest.mark.asyncio 51 | @mark.meta 52 | @with_client(pydle.features.RFC1459Support) 53 | async def test_mock_client_receive(server, client): 54 | client.on_raw = Mock() 55 | server.send("PING", "test") 56 | assert client.on_raw.called 57 | 58 | message = client.on_raw.call_args[0][0] 59 | assert isinstance(message, pydle.protocol.Message) 60 | assert message.source is None 61 | assert message.command == "PING" 62 | assert message.params == ("test",) 63 | 64 | 65 | ## Connection. 66 | 67 | 68 | @pytest.mark.asyncio 69 | @mark.meta 70 | async def test_mock_connection_connect(): 71 | serv = Mock() 72 | conn = MockConnection("mock.local", port=1337, mock_server=serv) 73 | 74 | await conn.connect() 75 | assert conn.connected 76 | assert serv.connection is conn 77 | 78 | 79 | @pytest.mark.asyncio 80 | @mark.meta 81 | async def test_mock_connection_disconnect(): 82 | serv = Mock() 83 | conn = MockConnection("mock.local", port=1337, mock_server=serv) 84 | 85 | await conn.connect() 86 | await conn.disconnect() 87 | assert not conn.connected 88 | -------------------------------------------------------------------------------- /tests/test_client.py: -------------------------------------------------------------------------------- 1 | import time 2 | import pytest 3 | from pytest import raises, mark 4 | import pydle 5 | from .fixtures import with_client 6 | from .mocks import Mock 7 | 8 | pydle.client.PING_TIMEOUT = 10 9 | 10 | 11 | ## Initialization. 12 | 13 | 14 | @pytest.mark.asyncio 15 | @with_client(invalid_kwarg=False) 16 | def test_client_superfluous_arguments(server, client): 17 | assert client.logger.warning.called 18 | 19 | 20 | ## Connection. 21 | @pytest.mark.asyncio 22 | @with_client() 23 | async def test_client_reconnect(server, client): 24 | await client.disconnect(expected=True) 25 | assert not client.connected 26 | 27 | await client.connect(reconnect=True) 28 | assert client.connected 29 | 30 | 31 | @pytest.mark.asyncio 32 | @mark.slow 33 | @with_client() 34 | async def test_client_unexpected_disconnect_reconnect(server, client): 35 | client._reconnect_delay = Mock(return_value=0) 36 | await client.disconnect(expected=False) 37 | assert client._reconnect_delay.called 38 | 39 | time.sleep(0.1) 40 | assert client.connected 41 | 42 | 43 | @pytest.mark.asyncio 44 | @with_client() 45 | async def test_client_unexpected_reconnect_give_up(server, client): 46 | client.RECONNECT_ON_ERROR = False 47 | await client.disconnect(expected=False) 48 | assert not client.connected 49 | 50 | 51 | @pytest.mark.asyncio 52 | @mark.slow 53 | @with_client() 54 | async def test_client_unexpected_disconnect_reconnect_delay(server, client): 55 | client._reconnect_delay = Mock(return_value=1) 56 | await client.disconnect(expected=False) 57 | 58 | assert not client.connected 59 | time.sleep(1.1) 60 | assert client.connected 61 | 62 | 63 | @pytest.mark.asyncio 64 | @with_client() 65 | def test_client_reconnect_delay_calculation(server, client): 66 | client.RECONNECT_DELAYED = False 67 | assert client._reconnect_delay() == 0 68 | 69 | client.RECONNECT_DELAYED = True 70 | for expected_delay in client.RECONNECT_DELAYS: 71 | delay = client._reconnect_delay() 72 | assert delay == expected_delay 73 | 74 | client._reconnect_attempts += 1 75 | 76 | assert client._reconnect_delay() == client.RECONNECT_DELAYS[-1] 77 | 78 | 79 | @pytest.mark.asyncio 80 | @with_client() 81 | async def test_client_disconnect_on_connect(server, client): 82 | client.disconnect = Mock() 83 | 84 | await client.connect("mock://local", 1337) 85 | assert client.connected 86 | assert client.disconnect.called 87 | 88 | 89 | @pytest.mark.asyncio 90 | @with_client(connected=False) 91 | async def test_client_connect_invalid_params(server, client): 92 | with raises(ValueError): 93 | await client.connect() 94 | with raises(ValueError): 95 | await client.connect(port=1337) 96 | 97 | 98 | @pytest.mark.asyncio 99 | @mark.slow 100 | @with_client() 101 | async def test_client_timeout(server, client): 102 | client.on_data_error = Mock() 103 | time.sleep(pydle.client.BasicClient.READ_TIMEOUT + 1) 104 | 105 | assert client.on_data_error.called 106 | assert isinstance(client.on_data_error.call_args[0][0], TimeoutError) 107 | 108 | 109 | @pytest.mark.asyncio 110 | @with_client(connected=False) 111 | async def test_client_server_tag(server, client): 112 | assert client.server_tag is None 113 | 114 | await client.connect("Mock.local", 1337) 115 | assert client.server_tag == "mock" 116 | await client.disconnect() 117 | 118 | await client.connect("irc.mock.local", 1337) 119 | assert client.server_tag == "mock" 120 | await client.disconnect() 121 | 122 | await client.connect("mock", 1337) 123 | assert client.server_tag == "mock" 124 | await client.disconnect() 125 | 126 | await client.connect("127.0.0.1", 1337) 127 | assert client.server_tag == "127.0.0.1" 128 | 129 | client.network = "MockNet" 130 | assert client.server_tag == "mocknet" 131 | await client.disconnect() 132 | 133 | 134 | ## Messages. 135 | 136 | 137 | @pytest.mark.asyncio 138 | @with_client() 139 | async def test_client_message(server, client): 140 | client.on_raw_install = Mock() 141 | await server.send("INSTALL", "gentoo") 142 | assert client.on_raw_install.called 143 | 144 | message = client.on_raw_install.call_args[0][0] 145 | assert isinstance(message, pydle.protocol.Message) 146 | assert message.command == "INSTALL" 147 | assert message.params == ("gentoo",) 148 | 149 | 150 | @pytest.mark.asyncio 151 | @with_client() 152 | async def test_client_unknown(server, client): 153 | client.on_unknown = Mock() 154 | await server.send("INSTALL", "gentoo") 155 | assert client.on_unknown.called 156 | -------------------------------------------------------------------------------- /tests/test_client_channels.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from .fixtures import with_client 3 | 4 | 5 | @pytest.mark.asyncio 6 | @with_client() 7 | def test_client_same_channel(server, client): 8 | assert client.is_same_channel("#lobby", "#lobby") 9 | assert not client.is_same_channel("#lobby", "#support") 10 | assert not client.is_same_channel("#lobby", "jilles") 11 | 12 | 13 | @pytest.mark.asyncio 14 | @with_client() 15 | def test_client_in_channel(server, client): 16 | client._create_channel("#lobby") 17 | assert client.in_channel("#lobby") 18 | 19 | 20 | @pytest.mark.asyncio 21 | @with_client() 22 | def test_client_is_channel(server, client): 23 | # Test always true... 24 | assert client.is_channel("#lobby") 25 | assert client.is_channel("WiZ") 26 | assert client.is_channel("irc.fbi.gov") 27 | 28 | 29 | @pytest.mark.asyncio 30 | @with_client() 31 | def test_channel_creation(server, client): 32 | client._create_channel("#pydle") 33 | assert "#pydle" in client.channels 34 | assert client.channels["#pydle"]["users"] == set() 35 | 36 | 37 | @pytest.mark.asyncio 38 | @with_client() 39 | def test_channel_destruction(server, client): 40 | client._create_channel("#pydle") 41 | client._destroy_channel("#pydle") 42 | assert "#pydle" not in client.channels 43 | 44 | 45 | @pytest.mark.asyncio 46 | @with_client() 47 | async def test_channel_user_destruction(server, client): 48 | client._create_channel("#pydle") 49 | await client._create_user("WiZ") 50 | client.channels["#pydle"]["users"].add("WiZ") 51 | 52 | client._destroy_channel("#pydle") 53 | assert "#pydle" not in client.channels 54 | assert "WiZ" not in client.users 55 | -------------------------------------------------------------------------------- /tests/test_client_users.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from .fixtures import with_client 3 | 4 | 5 | @pytest.mark.asyncio 6 | @with_client() 7 | def test_client_same_nick(server, client): 8 | assert client.is_same_nick("WiZ", "WiZ") 9 | assert not client.is_same_nick("WiZ", "jilles") 10 | assert not client.is_same_nick("WiZ", "wiz") 11 | 12 | 13 | @pytest.mark.asyncio 14 | @with_client() 15 | async def test_user_creation(server, client): 16 | await client._create_user("WiZ") 17 | assert "WiZ" in client.users 18 | assert client.users["WiZ"]["nickname"] == "WiZ" 19 | 20 | 21 | @pytest.mark.asyncio 22 | @with_client() 23 | async def test_user_invalid_creation(server, client): 24 | await client._create_user("irc.fbi.gov") 25 | assert "irc.fbi.gov" not in client.users 26 | 27 | 28 | @pytest.mark.asyncio 29 | @with_client() 30 | async def test_user_renaming(server, client): 31 | await client._create_user("WiZ") 32 | await client._rename_user("WiZ", "jilles") 33 | 34 | assert "WiZ" not in client.users 35 | assert "jilles" in client.users 36 | assert client.users["jilles"]["nickname"] == "jilles" 37 | 38 | 39 | @pytest.mark.asyncio 40 | @with_client() 41 | async def test_user_renaming_creation(server, client): 42 | await client._rename_user("null", "WiZ") 43 | 44 | assert "WiZ" in client.users 45 | assert "null" not in client.users 46 | 47 | 48 | @pytest.mark.asyncio 49 | @with_client() 50 | async def test_user_renaming_invalid_creation(server, client): 51 | await client._rename_user("null", "irc.fbi.gov") 52 | 53 | assert "irc.fbi.gov" not in client.users 54 | assert "null" not in client.users 55 | 56 | 57 | @pytest.mark.asyncio 58 | @with_client() 59 | async def test_user_renaming_channel_users(server, client): 60 | await client._create_user("WiZ") 61 | client._create_channel("#lobby") 62 | client.channels["#lobby"]["users"].add("WiZ") 63 | 64 | await client._rename_user("WiZ", "jilles") 65 | assert "WiZ" not in client.channels["#lobby"]["users"] 66 | assert "jilles" in client.channels["#lobby"]["users"] 67 | 68 | 69 | @pytest.mark.asyncio 70 | @with_client() 71 | async def test_user_deletion(server, client): 72 | await client._create_user("WiZ") 73 | client._destroy_user("WiZ") 74 | 75 | assert "WiZ" not in client.users 76 | 77 | 78 | @pytest.mark.asyncio 79 | @with_client() 80 | async def test_user_channel_deletion(server, client): 81 | client._create_channel("#lobby") 82 | await client._create_user("WiZ") 83 | client.channels["#lobby"]["users"].add("WiZ") 84 | 85 | client._destroy_user("WiZ", "#lobby") 86 | assert "WiZ" not in client.users 87 | assert client.channels["#lobby"]["users"] == set() 88 | 89 | 90 | @pytest.mark.asyncio 91 | @with_client() 92 | async def test_user_channel_incomplete_deletion(server, client): 93 | client._create_channel("#lobby") 94 | client._create_channel("#foo") 95 | await client._create_user("WiZ") 96 | client.channels["#lobby"]["users"].add("WiZ") 97 | client.channels["#foo"]["users"].add("WiZ") 98 | 99 | client._destroy_user("WiZ", "#lobby") 100 | assert "WiZ" in client.users 101 | assert client.channels["#lobby"]["users"] == set() 102 | 103 | 104 | @pytest.mark.asyncio 105 | @with_client() 106 | async def test_user_synchronization(server, client): 107 | await client._create_user("WiZ") 108 | await client._sync_user("WiZ", {"hostname": "og.irc.developer"}) 109 | 110 | assert client.users["WiZ"]["hostname"] == "og.irc.developer" 111 | 112 | 113 | @pytest.mark.asyncio 114 | @with_client() 115 | async def test_user_synchronization_creation(server, client): 116 | await client._sync_user("WiZ", {}) 117 | assert "WiZ" in client.users 118 | 119 | 120 | @pytest.mark.asyncio 121 | @with_client() 122 | async def test_user_invalid_synchronization(server, client): 123 | await client._sync_user("irc.fbi.gov", {}) 124 | assert "irc.fbi.gov" not in client.users 125 | 126 | 127 | @pytest.mark.asyncio 128 | @with_client() 129 | async def test_user_mask_format(server, client): 130 | await client._create_user("WiZ") 131 | assert client._format_user_mask("WiZ") == "WiZ!*@*" 132 | 133 | await client._sync_user("WiZ", {"username": "wiz"}) 134 | assert client._format_user_mask("WiZ") == "WiZ!wiz@*" 135 | 136 | await client._sync_user("WiZ", {"hostname": "og.irc.developer"}) 137 | assert client._format_user_mask("WiZ") == "WiZ!wiz@og.irc.developer" 138 | 139 | await client._sync_user("WiZ", {"username": None}) 140 | assert client._format_user_mask("WiZ") == "WiZ!*@og.irc.developer" 141 | -------------------------------------------------------------------------------- /tests/test_featurize.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | import pydle 4 | from .fixtures import with_client 5 | 6 | 7 | def with_errorcheck_client(*features): 8 | def inner(f): 9 | def run(): 10 | try: 11 | return with_client(*features, connected=False)(f)() 12 | except TypeError as e: 13 | assert False, e 14 | 15 | run.__name__ = f.__name__ 16 | return run 17 | 18 | return inner 19 | 20 | 21 | def assert_mro(client, *features): 22 | # Skip FeaturizedClient, MockClient, pydle.BasicClient and object classes. 23 | assert client.__class__.__mro__[2:-2] == features 24 | 25 | 26 | class FeatureClass(pydle.BasicClient): 27 | pass 28 | 29 | 30 | class SubFeatureClass(FeatureClass): 31 | pass 32 | 33 | 34 | class SubBFeatureClass(FeatureClass): 35 | pass 36 | 37 | 38 | class DiamondFeatureClass(SubBFeatureClass, SubFeatureClass): 39 | pass 40 | 41 | 42 | @pytest.mark.asyncio 43 | @with_errorcheck_client() 44 | def test_featurize_basic(server, client): 45 | assert_mro(client) 46 | 47 | 48 | @pytest.mark.asyncio 49 | @with_errorcheck_client(FeatureClass) 50 | def test_featurize_multiple(server, client): 51 | assert_mro(client, FeatureClass) 52 | 53 | 54 | @pytest.mark.asyncio 55 | @with_errorcheck_client(SubFeatureClass) 56 | def test_featurize_inheritance(server, client): 57 | assert_mro(client, SubFeatureClass, FeatureClass) 58 | 59 | 60 | @pytest.mark.asyncio 61 | @with_errorcheck_client(FeatureClass, SubFeatureClass) 62 | def test_featurize_inheritance_ordering(server, client): 63 | assert_mro(client, SubFeatureClass, FeatureClass) 64 | 65 | 66 | @pytest.mark.asyncio 67 | @with_errorcheck_client(SubBFeatureClass, SubFeatureClass, DiamondFeatureClass) 68 | def test_featurize_inheritance_diamond(server, client): 69 | assert_mro( 70 | client, DiamondFeatureClass, SubBFeatureClass, SubFeatureClass, FeatureClass 71 | ) 72 | -------------------------------------------------------------------------------- /tests/test_ircv3.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from pydle.features import ircv3 4 | 5 | pytestmark = [pytest.mark.unit, pytest.mark.ircv3] 6 | 7 | 8 | @pytest.mark.parametrize( 9 | "payload, expected", 10 | [ 11 | ( 12 | rb"@empty=;missing :irc.example.com NOTICE #channel :Message", 13 | {"empty": True, "missing": True}, 14 | ), 15 | ( 16 | rb"@+example=raw+:=,escaped\:\s\\ :irc.example.com NOTICE #channel :Message", 17 | {"+example": """raw+:=,escaped; \\"""}, 18 | ), 19 | ( 20 | rb"@+example=\foo\bar :irc.example.com NOTICE #channel :Message", 21 | {"+example": "foobar"}, 22 | ), 23 | ( 24 | rb"@msgid=796~1602221579~51;account=user123 :user123!user123@(ip) PRIVMSG #user123 :ping", 25 | {"msgid": "796~1602221579~51", "account": "user123"}, 26 | ), 27 | ( 28 | rb"@inspircd.org/service;inspircd.org/bot :ChanServ!services@services.(domain) MODE #user123 +qo user123 :user123", 29 | {"inspircd.org/service": True, r"inspircd.org/bot": True}, 30 | ), 31 | ], 32 | ) 33 | def test_tagged_message_escape_sequences(payload, expected): 34 | message = ircv3.tags.TaggedMessage.parse(payload) 35 | 36 | assert message.tags == expected 37 | -------------------------------------------------------------------------------- /tests/test_misc.py: -------------------------------------------------------------------------------- 1 | """ 2 | test_misc.py ~ Testing of Misc. Functions 3 | 4 | Designed for those simple functions that don't need their own dedicated test files 5 | But we want to hit them anyways 6 | """ 7 | from pydle.protocol import identifierify 8 | 9 | 10 | def test_identifierify(): 11 | good_name = identifierify("MyVerySimpleName") 12 | bad_name = identifierify("I'mASpec!äl/Name!_") 13 | assert good_name == "myverysimplename" 14 | assert bad_name == "i_maspec__l_name__" 15 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py32,py33 3 | 4 | [testenv] 5 | deps = 6 | coverage 7 | pytest 8 | pytest-cov 9 | commands = 10 | pip install -r requirements.txt 11 | py.test --cov pydle --cov-config .coveragerc --cov-report term-missing . 12 | 13 | [pytest] 14 | markers = 15 | slow: may take several seconds or more to complete. 16 | meta: tests the test suite itself. 17 | real: tests pydle against a real server. Requires PYDLE_TESTS_REAL_HOST and PYDLE_TESTS_REAL_PORT environment variables. 18 | unit: unit tests 19 | --------------------------------------------------------------------------------