├── .coveragerc ├── .cvsignore ├── .editorconfig ├── .github ├── FUNDING.yml └── workflows │ └── main.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .readthedocs.yaml ├── NEWS.rst ├── README.rst ├── SECURITY.md ├── conftest.py ├── docs ├── conf.py ├── history.rst ├── index.rst ├── irc.rst └── irc.tests.rst ├── irc ├── __init__.py ├── bot.py ├── client.py ├── client_aio.py ├── codes.txt ├── connection.py ├── ctcp.py ├── dict.py ├── events.py ├── features.py ├── message.py ├── modes.py ├── rfc.py ├── rfc2812.txt ├── schedule.py ├── server.py ├── strings.py └── tests │ ├── __init__.py │ ├── test_bot.py │ ├── test_client.py │ └── test_client_aio.py ├── mypy.ini ├── pyproject.toml ├── pytest.ini ├── ruff.toml ├── scripts ├── dccreceive.py ├── dccsend.py ├── irccat-aio.py ├── irccat.py ├── irccat2-aio.py ├── irccat2.py ├── sasl-ssl-cat.py ├── servermap.py ├── ssl-cat.py └── testbot.py ├── towncrier.toml └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = 3 | # leading `*/` for pytest-dev/pytest-cov#456 4 | */.tox/* 5 | disable_warnings = 6 | couldnt-parse 7 | 8 | [report] 9 | show_missing = True 10 | exclude_also = 11 | # Exclude common false positives per 12 | # https://coverage.readthedocs.io/en/latest/excluding.html#advanced-exclusion 13 | # Ref jaraco/skeleton#97 and jaraco/skeleton#135 14 | class .*\bProtocol\): 15 | if TYPE_CHECKING: 16 | -------------------------------------------------------------------------------- /.cvsignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = tab 6 | indent_size = 4 7 | insert_final_newline = true 8 | end_of_line = lf 9 | 10 | [*.py] 11 | indent_style = space 12 | max_line_length = 88 13 | 14 | [*.{yml,yaml}] 15 | indent_style = space 16 | indent_size = 2 17 | 18 | [*.rst] 19 | indent_style = space 20 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | tidelift: pypi/irc 2 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | 3 | on: 4 | merge_group: 5 | push: 6 | branches-ignore: 7 | # temporary GH branches relating to merge queues (jaraco/skeleton#93) 8 | - gh-readonly-queue/** 9 | tags: 10 | # required if branches-ignore is supplied (jaraco/skeleton#103) 11 | - '**' 12 | pull_request: 13 | workflow_dispatch: 14 | 15 | permissions: 16 | contents: read 17 | 18 | env: 19 | # Environment variable to support color support (jaraco/skeleton#66) 20 | FORCE_COLOR: 1 21 | 22 | # Suppress noisy pip warnings 23 | PIP_DISABLE_PIP_VERSION_CHECK: 'true' 24 | PIP_NO_WARN_SCRIPT_LOCATION: 'true' 25 | 26 | # Ensure tests can sense settings about the environment 27 | TOX_OVERRIDE: >- 28 | testenv.pass_env+=GITHUB_*,FORCE_COLOR 29 | 30 | 31 | jobs: 32 | test: 33 | strategy: 34 | # https://blog.jaraco.com/efficient-use-of-ci-resources/ 35 | matrix: 36 | python: 37 | - "3.9" 38 | - "3.13" 39 | platform: 40 | - ubuntu-latest 41 | - macos-latest 42 | - windows-latest 43 | include: 44 | - python: "3.10" 45 | platform: ubuntu-latest 46 | - python: "3.11" 47 | platform: ubuntu-latest 48 | - python: "3.12" 49 | platform: ubuntu-latest 50 | - python: "3.14" 51 | platform: ubuntu-latest 52 | - python: pypy3.10 53 | platform: ubuntu-latest 54 | runs-on: ${{ matrix.platform }} 55 | continue-on-error: ${{ matrix.python == '3.14' }} 56 | steps: 57 | - uses: actions/checkout@v4 58 | - name: Install build dependencies 59 | # Install dependencies for building packages on pre-release Pythons 60 | # jaraco/skeleton#161 61 | if: matrix.python == '3.14' && matrix.platform == 'ubuntu-latest' 62 | run: | 63 | sudo apt update 64 | sudo apt install -y libxml2-dev libxslt-dev 65 | - name: Setup Python 66 | uses: actions/setup-python@v5 67 | with: 68 | python-version: ${{ matrix.python }} 69 | allow-prereleases: true 70 | - name: Install tox 71 | run: python -m pip install tox 72 | - name: Run 73 | run: tox 74 | 75 | collateral: 76 | strategy: 77 | fail-fast: false 78 | matrix: 79 | job: 80 | - diffcov 81 | - docs 82 | runs-on: ubuntu-latest 83 | steps: 84 | - uses: actions/checkout@v4 85 | with: 86 | fetch-depth: 0 87 | - name: Setup Python 88 | uses: actions/setup-python@v5 89 | with: 90 | python-version: 3.x 91 | - name: Install tox 92 | run: python -m pip install tox 93 | - name: Eval ${{ matrix.job }} 94 | run: tox -e ${{ matrix.job }} 95 | 96 | check: # This job does nothing and is only used for the branch protection 97 | if: always() 98 | 99 | needs: 100 | - test 101 | - collateral 102 | 103 | runs-on: ubuntu-latest 104 | 105 | steps: 106 | - name: Decide whether the needed jobs succeeded or failed 107 | uses: re-actors/alls-green@release/v1 108 | with: 109 | jobs: ${{ toJSON(needs) }} 110 | 111 | release: 112 | permissions: 113 | contents: write 114 | needs: 115 | - check 116 | if: github.event_name == 'push' && contains(github.ref, 'refs/tags/') 117 | runs-on: ubuntu-latest 118 | 119 | steps: 120 | - uses: actions/checkout@v4 121 | - name: Setup Python 122 | uses: actions/setup-python@v5 123 | with: 124 | python-version: 3.x 125 | - name: Install tox 126 | run: python -m pip install tox 127 | - name: Run 128 | run: tox -e release 129 | env: 130 | TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} 131 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 132 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | .cache 3 | .eggs 4 | .pytest_cache/ 5 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/astral-sh/ruff-pre-commit 3 | rev: v0.9.9 4 | hooks: 5 | - id: ruff 6 | args: [--fix, --unsafe-fixes] 7 | - id: ruff-format 8 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | python: 3 | install: 4 | - path: . 5 | extra_requirements: 6 | - doc 7 | 8 | sphinx: 9 | configuration: docs/conf.py 10 | 11 | # required boilerplate readthedocs/readthedocs.org#10401 12 | build: 13 | os: ubuntu-lts-latest 14 | tools: 15 | python: latest 16 | # post-checkout job to ensure the clone isn't shallow jaraco/skeleton#114 17 | jobs: 18 | post_checkout: 19 | - git fetch --unshallow || true 20 | -------------------------------------------------------------------------------- /NEWS.rst: -------------------------------------------------------------------------------- 1 | v20.5.0 2 | ======= 3 | 4 | Features 5 | -------- 6 | 7 | - Refactored commands processing so now every command has a numeric and a name. Programs reliant on numeric values not yet defined can do so without breaking once they are defined. (#214) 8 | 9 | 10 | v20.4.3 11 | ======= 12 | 13 | Bugfixes 14 | -------- 15 | 16 | - Unconditionally close the socket, even if shutdown fails. (#224) 17 | 18 | 19 | v20.4.2 20 | ======= 21 | 22 | Bugfixes 23 | -------- 24 | 25 | - Stop excluding scripts. Prevents docs and scripts from being installed. (#231) 26 | 27 | 28 | v20.4.1 29 | ======= 30 | 31 | Bugfixes 32 | -------- 33 | 34 | - Fix SSL wrapper usage example in ``Factory`` docstring. (#228) 35 | 36 | 37 | v20.4.0 38 | ======= 39 | 40 | Features 41 | -------- 42 | 43 | - Replace deprecated ssl.wrap_socket with SSLContext.wrap_socket and update examples in connection.py docs. (#216) 44 | 45 | 46 | v20.3.1 47 | ======= 48 | 49 | No significant changes. 50 | 51 | 52 | v20.3.0 53 | ======= 54 | 55 | Features 56 | -------- 57 | 58 | - Added support for SASL login. (#195) 59 | 60 | 61 | Bugfixes 62 | -------- 63 | 64 | - Better handling of escape sequences in message tags. (#205) 65 | 66 | 67 | v20.2.0 68 | ======= 69 | 70 | Features 71 | -------- 72 | 73 | - Require Python 3.8 or later. 74 | 75 | 76 | v20.1.1 77 | ======= 78 | 79 | * #213: Pinned against jaraco.text 3.10 due to change in interface. 80 | 81 | v20.1.0 82 | ======= 83 | 84 | * #196: In irc.bot, avoid hanging idle when the first connection 85 | attempt fails. 86 | 87 | v20.0.0 88 | ======= 89 | 90 | * ``SingleServerIRCBot`` no longer accepts ``reconnection_interval`` 91 | as a parameter. 92 | 93 | * Added server support for NOTICE commands. 94 | 95 | * Require Python 3.7 or later. 96 | 97 | v19.0.1 98 | ======= 99 | 100 | * #176: Fix issues with version number reporting. Restored version 101 | version number reporting in bot and client. 102 | 103 | v19.0.0 104 | ======= 105 | 106 | * ``irc.client`` no longer exposes a ``VERSION`` or ``VERSION_STRING``. 107 | To get the version, call ``importlib.metadata.version('irc')`` directly. 108 | 109 | v18.0.0 110 | ======= 111 | 112 | * Require Python 3.6 or later. 113 | 114 | 17.1 115 | ==== 116 | 117 | * Rely on 118 | `importlib_metadata `_ 119 | for loading version from metadata. Removes implicit dependency on 120 | setuptools and pkg_resources. 121 | 122 | * #158: The AsyncIO server now accepts a connection factory to 123 | enable features like SSL and IPv6 support. 124 | 125 | * #155: ``SimpleIRCClient`` now has a ``dcc`` method for initiating 126 | and associating a DCCConnection object with the client. 127 | ``DCCConnection.listen`` now accepts a ``address`` parameter. 128 | Deprecated ``SimpleIRCClient.dcc_listen`` and 129 | ``SimpleIRCClient.dcc_connect`` in favor of the better separation 130 | of concerns. Clients should replace:: 131 | 132 | client.dcc_connect(addr, port, type) 133 | client.dcc_listen(type) 134 | 135 | with:: 136 | 137 | client.dcc(type).connect(addr, port) 138 | client.dcc(type).listen() 139 | 140 | 141 | 17.0 142 | ==== 143 | 144 | * Removed ``irc.buffer`` module, deprecated in 14.2. 145 | * #153: Drop support for Python 3.3 and 2.7. 146 | 147 | 16.4 148 | ==== 149 | 150 | * Long Term Service release for Python 2.7. 151 | * #149: ``AioConnection.connect`` moved to coroutine, added 152 | disconnect handling for AsyncIO. 153 | 154 | 16.3 155 | ==== 156 | 157 | * #140: Methods now use 'connection' and 'event' for parameter names. 158 | 159 | * #135 via #144: Added AsyncIO implementation. 160 | 161 | 16.2.1 162 | ====== 163 | 164 | * Package refresh and cleanup. 165 | 166 | 16.2 167 | ==== 168 | 169 | * #133: In ``irc.server``, add support for ISON. 170 | 171 | 16.1 172 | ==== 173 | 174 | * #131: Add ``Connection.encode`` and ``Connection.transmit_encoding`` 175 | to enable encodings other than UTF-8 to be used when transmitting 176 | text. 177 | 178 | 16.0 179 | ==== 180 | 181 | * Removed deprecated ``execute_*`` methods on ``Connection`` 182 | and ``Reactor`` as introduced in 15.0. 183 | 184 | * Fixed link in README. 185 | 186 | 15.1.1 187 | ====== 188 | 189 | * New ``send_items`` method takes star args for simplicity 190 | in the syntax and usage. 191 | 192 | 15.1 193 | ==== 194 | 195 | * Introduce ``ServerConnection.send_items``, consolidating 196 | common behavior across many methods previously calling 197 | ``send_raw``. 198 | 199 | 15.0.6 200 | ====== 201 | 202 | * Now publish `documentation `_ 203 | to Read The Docs. 204 | 205 | 15.0.5 206 | ====== 207 | 208 | * #119: Handle broken pipe exception in IRCClient _send() (server.py). 209 | 210 | 15.0.4 211 | ====== 212 | 213 | * #116: Correct invocation of execute_every. 214 | 215 | 15.0.3 216 | ====== 217 | 218 | * #115: Fix AttributeError in ``execute_at`` in scheduling 219 | support. 220 | 221 | 15.0.2 222 | ====== 223 | 224 | * #113: Use preferred scheduler in the bot implementation. 225 | 226 | 15.0.1 227 | ====== 228 | 229 | * Deprecated calls to Connection.execute_* 230 | and Reactor.execute_*. Instead, call the 231 | equivalently-named methods on the reactor's 232 | scheduler. 233 | 234 | 15.0 235 | ==== 236 | 237 | * The event scheduling functionality has been decoupled 238 | from the client.Reactor object. Now the reactor will 239 | construct a Scheduler from the scheduler_class property, 240 | which must be an instance of irc.schedule.IScheduler. 241 | 242 | The ``_on_schedule`` parameter is no longer accepted 243 | to the Reactor class. Implementations requiring a 244 | signal during scheduling should hook into the ``add`` 245 | method of the relevant scheduler class. 246 | 247 | * Moved the underlying scheduler implementation to 248 | `tempora `_, allowing 249 | it to be re-used for other purposes. 250 | 251 | 14.2.2 252 | ====== 253 | 254 | * Issue #98: Add an ugly hack to force ``build_sphinx`` 255 | command to have the requisite libraries to build 256 | module documentation. 257 | 258 | 14.2.1 259 | ====== 260 | 261 | * Issue #97: Restore ``irc.buffer`` module for 262 | compatibility. 263 | * Issue #95: Update docs to remove missing or 264 | deprecated modules. 265 | * Issue #96: Declare Gitter support as a badge in the 266 | docs. 267 | 268 | 14.2 269 | ==== 270 | 271 | * Moved buffer module to `jaraco.stream 272 | `_ for 273 | use in other packages. 274 | 275 | 14.1 276 | ==== 277 | 278 | * ``SingleServerIRCBot`` now accepts a ``recon`` 279 | parameter implementing a ReconnectStrategy. The new 280 | default strategy is ExponentialBackoff, implementing an 281 | exponential backoff with jitter. 282 | The ``reconnection_interval`` parameter is now deprecated 283 | but retained for compatibility. To customize the minimum 284 | time before reconnect, create a custom ExponentialBackoff 285 | instance or create another ReconnectStrategy object and 286 | pass that as the ``recon`` parameter. The 287 | ``reconnection_interval`` parameter will be removed in 288 | future versions. 289 | * Issue #82: The ``ExponentialBackoff`` implementation 290 | now protects from multiple scheduled reconnects, avoiding 291 | the issue where reconnect attempts accumulate 292 | exponentially when the bot is immediately disconnected 293 | by the server. 294 | 295 | 14.0 296 | ==== 297 | 298 | * Dropped deprecated constructor 299 | ``connection.Factory.from_legacy_params``. Use the 300 | natural constructor instead. 301 | * Issue #83: ``connection.Factory`` no longer attempts 302 | to bind before connect unless a bind address is specified. 303 | 304 | 13.3.1 305 | ====== 306 | 307 | * Now remove mode for owners, halfops, and admins when the user 308 | is removed from a channel. 309 | * Refactored the Channel class implementation for cleaner, less 310 | repetitive code. 311 | * Expanded tests coverage for Channel class. 312 | 313 | 13.3 314 | ==== 315 | 316 | * Issue #75: In ``irc.bot``, add support for tracking admin 317 | status (mode 'a') in channels. Use ``channel.is_admin`` 318 | or ``channel.admins`` to identify admin users for a channel. 319 | 320 | * Removed deprecated irc.logging module. 321 | 322 | 13.2 323 | ==== 324 | 325 | * Moved hosting to github. 326 | 327 | 13.1.1 328 | ====== 329 | 330 | * Issue #67: Fix infinite recursion for ``irc.strings.IRCFoldedCase`` 331 | and ``irc.strings.lower``. 332 | 333 | 13.1 334 | ==== 335 | 336 | * Issue #64: ISUPPORT PREFIX now retains the order of 337 | permissions for each prefix. 338 | 339 | 13.0 340 | ==== 341 | 342 | * Updated ``schedule`` module to properly support timezone aware 343 | times and use them by default. Clients that rely on the timezone 344 | naïve datetimes may restore the old behavior by overriding the 345 | ``schedule.now`` and ``schedule.from_timestamp`` functions 346 | like so: 347 | 348 | schedule.from_timestamp = datetime.datetime.fromtimestamp 349 | schedule.now = datetime.datetime.now 350 | 351 | Clients that were previously patching 352 | ``schedule.DelayedCommand.now`` will need to instead patch 353 | the aforementioned module-global methods. The 354 | classmethod technique was a poor interface for effectively 355 | controlling timezone awareness, so was likely unused. Please 356 | file a ticket with the project for support with your client 357 | as needed. 358 | 359 | 12.4.2 360 | ====== 361 | 362 | * Bump to jaraco.functools 1.5 to throttler failures in Python 2. 363 | 364 | 12.4 365 | ==== 366 | 367 | * Moved ``Throttler`` class to `jaraco.functools 368 | `_ 1.4. 369 | 370 | 12.3 371 | ==== 372 | 373 | * Pull Request #33: Fix apparent escaping issue with IRCv3 tags. 374 | 375 | 12.2 376 | ==== 377 | 378 | * Pull Request #32: Add numeric for WHOX reply. 379 | * Issue #62 and Pull Request #34: Add support for tags in message 380 | processing and ``Event`` class. 381 | 382 | 12.1.2 383 | ====== 384 | 385 | * Issue #59: Fixed broken references to irc.client members. 386 | * Issue #60: Fix broken initialization of ``irc.server.IRCClient`` on 387 | Python 2. 388 | 389 | 12.1.1 390 | ====== 391 | 392 | * Issue #57: Better handling of Python 3 in testbot.py script. 393 | 394 | 12.1 395 | ==== 396 | 397 | * Remove changelog from package metadata. 398 | 399 | 12.0 400 | ==== 401 | 402 | * Remove dependency on jaraco.util. Instead depend on surgical packages. 403 | * Deprecated ``irc.logging`` in favor of ``jaraco.logging``. 404 | * Dropped support for Python 3.2. 405 | 406 | 11.1.1 407 | ====== 408 | 409 | * Issue #55: Correct import error on Python 2.7. 410 | 411 | 11.1 412 | ==== 413 | 414 | * Decoding errors now log a warning giving a reference to the ``Decoding 415 | Input`` section of the readme. 416 | 417 | 11.0 418 | ==== 419 | 420 | * Renamed ``irc.client.Manifold`` to ``irc.client.Reactor``. Reactor better 421 | reflects the implementation as a `reactor pattern < 422 | `_. 423 | This name makes it's function much more clear and inline with standard 424 | terminology. 425 | * Removed deprecated ``manifold`` and ``irclibobj`` properties from Connection. 426 | Use ``reactor`` instead. 427 | * Removed deprecated ``ircobj`` from ``SimpleIRCClient``. Use ``reactor`` 428 | instead. 429 | 430 | 10.1 431 | ==== 432 | 433 | * Added ``ServerConnection.as_nick``, a context manager to set a nick for the 434 | duration of the context. 435 | 436 | 10.0 437 | ==== 438 | 439 | * Dropped support for Python 2.6. 440 | * Dropped ``irc.client.LineBuffer`` and ``irc.client.DecodingBuffer`` 441 | (available in ``irc.client.buffer``). 442 | * Renamed ``irc.client.IRC`` to ``irc.client.Manifold`` to provide a clearer 443 | name for that object. Clients supporting 8.6 and later can use the 444 | ``Manifold`` name. Latest clients must use the ``Manifold`` name. 445 | * Renamed ``irc.client.Connection.irclibobj`` property to ``manifold``. The 446 | property is still exposed as ``irclibobj`` for compatibility but will be 447 | removed in a future version. 448 | * Removed unused ``irc.client.mask_matches`` function. 449 | * Removed unused ``irc.client.nick_characters``. 450 | * Added extra numerics for 'whoisaccount' and 'cannotknock'. 451 | 452 | 9.0 453 | === 454 | 455 | * Issue #46: The ``whois`` command now accepts a single string or iterable for 456 | the target. 457 | * NickMask now returns ``None`` when user, host, or userhost are not present. 458 | Previously, an ``IndexError`` was raised. 459 | See `Pull Request #26 `_ 460 | for details. 461 | 462 | 8.9 463 | === 464 | 465 | Documentation is now published at https://pythonhosted.org/irc. 466 | 467 | 8.8 468 | === 469 | 470 | * Issue #35: Removed the mutex during process_once. 471 | * Issue #37: Deprecated buffer.LineBuffer for Python 3. 472 | 473 | 8.7 474 | === 475 | 476 | * Issue #34: Introduced ``buffer.LenientDecodingLineBuffer`` for handling 477 | input in a more lenient way, preferring UTF-8 but falling back to latin-1 478 | if the content cannot be decoded as UTF-8. To enable it by default for 479 | your application, set it as the default decoder:: 480 | 481 | irc.client.ServerConnection.buffer_class = irc.buffer.LenientDecodingLineBuffer 482 | 483 | 8.6 484 | === 485 | 486 | * Introduced 'Manifold' as an alias for irc.client.IRC. This better name will 487 | replace the IRC name in a future version. 488 | * Introduced the 'manifold' property of SimpleIRCClient as an alias for 489 | ircobj. 490 | * Added 'manifold_class' property to the client.SimpleIRCClient to allow 491 | consumers to provide a customized Manifold. 492 | 493 | 8.5.4 494 | ===== 495 | 496 | * Issue #32: Add logging around large DCC messages to facilitate 497 | troubleshooting. 498 | * Issue #31: Fix error in connection wrapper for SSL example. 499 | 500 | 8.5.3 501 | ===== 502 | 503 | * Issue #28: Fix TypeError in version calculation in irc.bot CTCP version. 504 | 505 | 8.5.2 506 | ===== 507 | 508 | * Updated DCC send and receive scripts (Issue #27). 509 | 510 | 8.5.1 511 | ===== 512 | 513 | * Fix timestamp support in ``schedule.DelayedCommand`` construction. 514 | 515 | 8.5 516 | === 517 | 518 | * ``irc.client.NickMask`` is now a Unicode object on Python 2. Fixes issue 519 | reported in pull request #19. 520 | * Issue #24: Added `DCCConnection.send_bytes` for transmitting binary data. 521 | `privmsg` remains to support transmitting text. 522 | 523 | 8.4 524 | === 525 | 526 | * Code base now runs natively on Python 2 and Python 3, but requires `six 527 | `_ to be installed. 528 | * Issue #25: Rate-limiting has been updated to be finer grained (preventing 529 | bursts exceeding the limit following idle periods). 530 | 531 | 8.3.2 532 | ===== 533 | 534 | * Issue #22: Catch error in bot.py on NAMREPLY when nick is not in any visible 535 | channel. 536 | 537 | 8.3.1 538 | ===== 539 | 540 | * Fixed encoding errors in server on Python 3. 541 | 542 | 8.3 543 | === 544 | 545 | * Added a ``set_keepalive`` method to the ServerConnection. Sends a periodic 546 | PING message every indicated interval. 547 | 548 | 8.2 549 | === 550 | 551 | * Added support for throttling send_raw messages via the ServerConnection 552 | object. For example, on any connection object: 553 | 554 | connection.set_rate_limit(30) 555 | 556 | That would set the rate limit to 30 Hz (30 per second). Thanks to Jason 557 | Kendall for the suggestion and bug fixes. 558 | 559 | 8.1.2 560 | ===== 561 | 562 | * Fix typo in `client.NickMask`. 563 | 564 | 8.1.1 565 | ===== 566 | 567 | * Fix typo in bot.py. 568 | 569 | 8.1 570 | === 571 | 572 | * Issue #15: Added client support for ISUPPORT directives on server 573 | connections. Now, each ServerConnection has a `features` attribute which 574 | reflects the features supported by the server. See the docs for 575 | `irc.features` for details about the implementation. 576 | 577 | 8.0.1 578 | ===== 579 | 580 | * Issue #14: Fix errors when handlers of the same priority are added under 581 | Python 3. This also fixes the unintended behavior of allowing handlers of 582 | the same priority to compare as unequal. 583 | 584 | 8.0 585 | === 586 | 587 | This release brings several backward-incompatible changes to the scheduled 588 | commands. 589 | 590 | * Refactored implementation of schedule classes. No longer do they override 591 | the datetime constructor, but now only provide suitable classmethods for 592 | construction in various forms. 593 | * Removed backward-compatible references from irc.client. 594 | * Remove 'arguments' parameter from scheduled commands. 595 | 596 | Clients that reference the schedule classes from irc.client or that construct 597 | them from the basic constructor will need to update to use the new class 598 | methods:: 599 | 600 | - DelayedCommand -> DelayedCommand.after 601 | - PeriodicCommand -> PeriodicCommand.after 602 | 603 | Arguments may no longer be passed to the 'function' callback, but one is 604 | encouraged instead to use functools.partial to attach parameters to the 605 | callback. For example:: 606 | 607 | DelayedCommand.after(3, func, ('a', 10)) 608 | 609 | becomes:: 610 | 611 | func = functools.partial(func, 'a', 10) 612 | DelayedCommand.after(3, func) 613 | 614 | This mode puts less constraints on the both the handler and the caller. For 615 | example, a caller can now pass keyword arguments instead:: 616 | 617 | func = functools.partial(func, name='a', quantity=10) 618 | DelayedCommand.after(3, func) 619 | 620 | Readability, maintainability, and usability go up. 621 | 622 | 7.1.2 623 | ===== 624 | 625 | * Issue #13: TypeError on Python 3 when constructing PeriodicCommand (and thus 626 | execute_every). 627 | 628 | 7.1.1 629 | ===== 630 | 631 | * Fixed regression created in 7.0 where PeriodicCommandFixedDelay would only 632 | cause the first command to be scheduled, but not subsequent ones. 633 | 634 | 7.1 635 | === 636 | 637 | * Moved scheduled command classes to irc.schedule module. Kept references for 638 | backwards-compatibility. 639 | 640 | 7.0 641 | === 642 | 643 | * PeriodicCommand now raises a ValueError if it's created with a negative or 644 | zero delay (meaning all subsequent commands are immediately due). This fixes 645 | #12. 646 | * Renamed the parameters to the IRC object. If you use a custom event loop 647 | and your code constructs the IRC object with keyword parameters, you will 648 | need to update your code to use the new names, so:: 649 | 650 | IRC(fn_to_add_socket=adder, fn_to_remove_socket=remover, fn_to_add_timeout=timeout) 651 | 652 | becomes:: 653 | 654 | IRC(on_connect=adder, on_disconnect=remover, on_schedule=timeout) 655 | 656 | If you don't use a custom event loop or you pass the parameters 657 | positionally, no change is necessary. 658 | 659 | 6.0.1 660 | ===== 661 | 662 | * Fixed some unhandled exceptions in server client connections when the client 663 | would disconnect in response to messages sent after select was called. 664 | 665 | 6.0 666 | === 667 | 668 | * Moved `LineBuffer` and `DecodingLineBuffer` from client to buffer module. 669 | Backward-compatible references have been kept for now. 670 | * Removed daemon mode and log-to-file options for server. 671 | * Miscellaneous bugfixes in server. 672 | 673 | 5.1.1 674 | ===== 675 | 676 | * Fix error in 2to3 conversion on irc/server.py (issue #11). 677 | 678 | 5.1 679 | === 680 | 681 | The IRC library is now licensed under the MIT license. 682 | 683 | * Added irc/server.py, based on hircd by Ferry Boender. 684 | * Added support for CAP command (pull request #10), thanks to Danneh Oaks. 685 | 686 | 5.0 687 | === 688 | 689 | Another backward-incompatible change. In irc 5.0, many of the unnecessary 690 | getter functions have been removed and replaced with simple attributes. This 691 | change addresses issue #2. In particular: 692 | 693 | - Connection._get_socket() -> Connection.socket (including subclasses) 694 | - Event.eventtype() -> Event.type 695 | - Event.source() -> Event.source 696 | - Event.target() -> Event.target 697 | - Event.arguments() -> Event.arguments 698 | 699 | The `nm_to_*` functions were removed. Instead, use the NickMask class 700 | attributes. 701 | 702 | These deprecated function aliases were removed from irc.client:: 703 | 704 | - parse_nick_modes -> modes.parse_nick_modes 705 | - parse_channel_modes -> modes.parse_channel_modes 706 | - generated_events -> events.generated 707 | - protocol_events -> events.protocol 708 | - numeric_events -> events.numeric 709 | - all_events -> events.all 710 | - irc_lower -> strings.lower 711 | 712 | Also, the parameter name when constructing an event was renamed from 713 | `eventtype` to simply `type`. 714 | 715 | 4.0 716 | === 717 | 718 | * Removed deprecated arguments to ServerConnection.connect. See notes on the 719 | 3.3 release on how to use the connect_factory parameter if your application 720 | requires ssl, ipv6, or other connection customization. 721 | 722 | 3.6.1 723 | ===== 724 | 725 | * Filter out disconnected sockets when processing input. 726 | 727 | 3.6 728 | === 729 | 730 | * Created two new exceptions in `irc.client`: `MessageTooLong` and 731 | `InvalidCharacters`. 732 | * Use explicit exceptions instead of ValueError when sending data. 733 | 734 | 3.5 735 | === 736 | 737 | * SingleServerIRCBot now accepts keyword arguments which are passed through 738 | to the `ServerConnection.connect` method. One can use this to use SSL for 739 | connections:: 740 | 741 | factory = irc.connection.Factory(wrapper=ssl.wrap_socket) 742 | bot = irc.bot.SingleServerIRCBot(..., connect_factory = factory) 743 | 744 | 745 | 3.4.2 746 | ===== 747 | 748 | * Issue #6: Fix AttributeError when legacy parameters are passed to 749 | `ServerConnection.connect`. 750 | * Issue #7: Fix TypeError on `iter(LineBuffer)`. 751 | 752 | 3.4.1 753 | ===== 754 | 755 | 3.4 never worked - the decoding customization feature was improperly 756 | implemented and never tested. 757 | 758 | * The ServerConnection now allows custom classes to be supplied to customize 759 | the decoding of incoming lines. For example, to disable the decoding of 760 | incoming lines, 761 | replace the `buffer_class` on the ServerConnection with a version that 762 | passes through the lines directly:: 763 | 764 | irc.client.ServerConnection.buffer_class = irc.client.LineBuffer 765 | 766 | This fixes #5. 767 | 768 | 3.4 769 | === 770 | 771 | *Broken Release* 772 | 773 | 3.3 774 | === 775 | 776 | * Added `connection` module with a Factory for creating socket connections. 777 | * Added `connect_factory` parameter to the ServerConnection. 778 | 779 | It's now possible to create connections with custom SSL parameters or other 780 | socket wrappers. For example, to create a connection with a custom SSL cert:: 781 | 782 | import ssl 783 | import irc.client 784 | import irc.connection 785 | import functools 786 | 787 | irc = irc.client.IRC() 788 | server = irc.server() 789 | wrapper = functools.partial(ssl.wrap_socket, ssl_cert=my_cert()) 790 | server.connect(connect_factory = irc.connection.Factory(wrapper=wrapper)) 791 | 792 | With this release, many of the parameters to `ServerConnection.connect` are 793 | now deprecated: 794 | 795 | - localaddress 796 | - localport 797 | - ssl 798 | - ipv6 799 | 800 | Instead, one should pass the appropriate values to a `connection.Factory` 801 | instance and pass that factory to the .connect method. Backwards-compatibility 802 | will be maintained for these parameters until the release of irc 4.0. 803 | 804 | 3.2.3 805 | ===== 806 | 807 | * Restore Python 2.6 compatibility. 808 | 809 | 3.2.2 810 | ===== 811 | 812 | * Protect from UnicodeDecodeError when decoding data on the wire when data is 813 | not properly encoded in ASCII or UTF-8. 814 | 815 | 3.2.1 816 | ===== 817 | 818 | * Additional branch protected by mutex. 819 | 820 | 3.2 821 | === 822 | 823 | * Implemented thread safety via a reentrant lock guarding shared state in IRC 824 | objects. 825 | 826 | 3.1.1 827 | ===== 828 | 829 | * Fix some issues with bytes/unicode on Python 3 830 | 831 | 3.1 832 | === 833 | 834 | * Distribute using setuptools rather than paver. 835 | * Minor tweaks for Python 3 support. Now installs on Python 3. 836 | 837 | 3.0.1 838 | ===== 839 | 840 | * Added error checking when sending a message - for both message length and 841 | embedded carriage returns. Fixes #4. 842 | * Updated README. 843 | 844 | 3.0 845 | === 846 | 847 | * Improved Unicode support. Fixes failing tests and errors lowering Unicode 848 | channel names. 849 | * Sourceforge 18 - The ServerConnection and DCCConnection now encode any 850 | strings as UTF-8 before transmitting. 851 | * Sourceforge 17 - Updated strings.FoldedCase to support comparison against 852 | objects of other types. 853 | * Shutdown the sockets before closing. 854 | 855 | Applications that are currently encoding unicode as UTF-8 before passing the 856 | strings to `ServerConnection.send_raw` need to be updated to send Unicode 857 | or ASCII. 858 | 859 | 2.0.4 860 | ===== 861 | 862 | This release officially deprecates 2.0.1-2.0.3 in favor of 3.0. 863 | 864 | * Re-release of irc 2.0 (without the changes from 2.0.1-2.0.3) for 865 | correct compatibility indication. 866 | 867 | 2.0 868 | === 869 | 870 | * DelayedCommands now use the local time for calculating 'at' and 'due' 871 | times. This will be more friendly for simple servers. Servers that expect 872 | UTC times should either run in UTC or override DelayedCommand.now to 873 | return an appropriate time object for 'now'. For example:: 874 | 875 | def startup_bot(): 876 | irc.client.DelayedCommand.now = irc.client.DelayedCommand.utcnow 877 | ... 878 | 879 | 1.1 880 | === 881 | 882 | * Added irc.client.PeriodicCommandFixedDelay. Schedule this command 883 | to have a function executed at a specific time and then at periodic 884 | intervals thereafter. 885 | 886 | 1.0 887 | === 888 | 889 | * Removed `irclib` and `ircbot` legacy modules. 890 | 891 | 0.9 892 | === 893 | 894 | * Fix file saving using dccreceive.py on Windows. Fixes Sourceforge 6. 895 | * Created NickMask class from nm_to_* functions. Now if a source is 896 | a NickMask, one can access the .nick, .host, and .user attributes. 897 | * Use correct attribute for saved connect args. Fixes Sourceforge 16. 898 | 899 | 0.8 900 | === 901 | 902 | * Added ServerConnection.reconnect method. Fixes Sourceforge 13. 903 | 904 | 0.7.1 905 | ===== 906 | 907 | * Added missing events. Fixes Sourceforge 12. 908 | 909 | 0.7 910 | === 911 | 912 | * Moved functionality from irclib module to irc.client module. 913 | * Moved functionality from ircbot module to irc.bot module. 914 | * Retained irclib and ircbot modules for backward-compatibility. These 915 | will be removed in 1.0. 916 | * Renamed project to simply 'irc'. 917 | 918 | To support the new module structure, simply replace references to the irclib 919 | module with irc.client and ircbot module with irc.bot. This project will 920 | support that interface through all versions of irc 1.x, so if you've made 921 | these changes, you can safely depend on `irc >= 0.7, <2.0dev`. 922 | 923 | 0.6.3 924 | ===== 925 | 926 | * Fixed failing test where DelayedCommands weren't being sorted properly. 927 | DelayedCommand a now subclass of the DateTime object, where the command's 928 | due time is the datetime. Fixed issue Sourceforge 15. 929 | 930 | 0.6.2 931 | ===== 932 | 933 | * Fixed incorrect usage of Connection.execute_delayed (again). 934 | 935 | 0.6.0 936 | ===== 937 | 938 | * Minimum Python requirement is now Python 2.6. Python 2.3 and earlier should use 0.5.0 939 | or earlier. 940 | * Removed incorrect usage of Connection.execute_delayed. Added Connection.execute_every. 941 | Fixed Sourceforge 8. 942 | * Use new-style classes. 943 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | .. image:: https://img.shields.io/pypi/v/irc.svg 2 | :target: https://pypi.org/project/irc 3 | 4 | .. image:: https://img.shields.io/pypi/pyversions/irc.svg 5 | 6 | .. image:: https://github.com/jaraco/irc/actions/workflows/main.yml/badge.svg 7 | :target: https://github.com/jaraco/irc/actions?query=workflow%3A%22tests%22 8 | :alt: tests 9 | 10 | .. image:: https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json 11 | :target: https://github.com/astral-sh/ruff 12 | :alt: Ruff 13 | 14 | .. image:: https://readthedocs.org/projects/irc/badge/?version=latest 15 | :target: https://irc.readthedocs.io/en/latest/?badge=latest 16 | 17 | .. image:: https://img.shields.io/badge/skeleton-2025-informational 18 | :target: https://blog.jaraco.com/skeleton 19 | 20 | .. image:: https://badges.gitter.im/jaraco/irc.svg 21 | :alt: Join the chat at https://gitter.im/jaraco/irc 22 | :target: https://gitter.im/jaraco/irc?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge 23 | 24 | .. image:: https://tidelift.com/badges/github/jaraco/irc 25 | :target: https://tidelift.com/subscription/pkg/pypi-irc?utm_source=pypi-irc&utm_medium=referral&utm_campaign=readme 26 | 27 | Full-featured Python IRC library for Python. 28 | 29 | - `Project home `_ 30 | - `Docs `_ 31 | - `History `_ 32 | 33 | Overview 34 | ======== 35 | 36 | This library provides a low-level implementation of the IRC protocol for 37 | Python. It provides an event-driven IRC client framework. It has 38 | a fairly thorough support for the basic IRC protocol, CTCP, and DCC 39 | connections. 40 | 41 | In order to understand how to make an IRC client, it's best to read up first 42 | on the `IRC specifications 43 | `_. 44 | 45 | Client Features 46 | =============== 47 | 48 | The main features of the IRC client framework are: 49 | 50 | * Abstraction of the IRC protocol. 51 | * Handles multiple simultaneous IRC server connections. 52 | * Handles server PONGing transparently. 53 | * Messages to the IRC server are done by calling methods on an IRC 54 | connection object. 55 | * Messages from an IRC server triggers events, which can be caught 56 | by event handlers. 57 | * Multiple options for reading from and writing to an IRC server: 58 | you can use sockets in an internal ``select()`` loop OR use 59 | Python3's asyncio event loop 60 | * Functions can be registered to execute at specified times by the 61 | event-loop. 62 | * Decodes CTCP tagging correctly (hopefully); I haven't seen any 63 | other IRC client implementation that handles the CTCP 64 | specification subtleties. 65 | * A kind of simple, single-server, object-oriented IRC client class 66 | that dispatches events to instance methods is included. 67 | * DCC connection support. 68 | 69 | Current limitations: 70 | 71 | * The IRC protocol shines through the abstraction a bit too much. 72 | * Data is not written asynchronously to the server (and DCC peers), 73 | i.e. the ``write()`` may block if the TCP buffers are stuffed. 74 | * Like most projects, documentation is lacking ... 75 | * DCC is not currently implemented in the asyncio-based version 76 | 77 | Unfortunately, this library isn't as well-documented as I would like 78 | it to be. I think the best way to get started is to read and 79 | understand the example program ``irccat``, which is included in the 80 | distribution. 81 | 82 | The following modules might be of interest: 83 | 84 | * ``irc.client`` 85 | 86 | The library itself. Read the code along with comments and 87 | docstrings to get a grip of what it does. Use it at your own risk 88 | and read the source, Luke! 89 | 90 | * ``irc.client_aio`` 91 | 92 | All the functionality of the above library, but utilizing 93 | Python 3's native asyncio library for the core event loop. 94 | Interface/API is otherwise functionally identical to the classes 95 | in ``irc.client`` 96 | 97 | * ``irc.bot`` 98 | 99 | An IRC bot implementation. 100 | 101 | * ``irc.server`` 102 | 103 | A basic IRC server implementation. Suitable for testing, but not 104 | intended as a production service. 105 | 106 | Invoke the server with ``python -m irc.server``. 107 | 108 | Examples 109 | ======== 110 | 111 | Example scripts in the scripts directory: 112 | 113 | * ``irccat`` 114 | 115 | A simple example of how to use the IRC client. ``irccat`` reads 116 | text from stdin and writes it to a specified user or channel on 117 | an IRC server. 118 | 119 | * ``irccat2`` 120 | 121 | The same as above, but using the ``SimpleIRCClient`` class. 122 | 123 | * ``aio_irccat`` 124 | 125 | Same as above, but uses the asyncio-based event loop in 126 | ``AioReactor`` instead of the ``select()`` based ``Reactor``. 127 | 128 | 129 | * ``aio_irccat2`` 130 | 131 | Same as above, but using the ``AioSimpleIRCClient`` class 132 | 133 | 134 | * ``servermap`` 135 | 136 | Another simple example. ``servermap`` connects to an IRC server, 137 | finds out what other IRC servers there are in the net and prints 138 | a tree-like map of their interconnections. 139 | 140 | * ``testbot`` 141 | 142 | An example bot that uses the ``SingleServerIRCBot`` class from 143 | ``irc.bot``. The bot enters a channel and listens for commands in 144 | private messages or channel traffic. It also accepts DCC 145 | invitations and echos back sent DCC chat messages. 146 | 147 | * ``dccreceive`` 148 | 149 | Receives a file over DCC. 150 | 151 | * ``dccsend`` 152 | 153 | Sends a file over DCC. 154 | 155 | 156 | NOTE: If you're running one of the examples on a unix command line, you need 157 | to escape the ``#`` symbol in the channel. For example, use ``\\#test`` or 158 | ``"#test"`` instead of ``#test``. 159 | 160 | 161 | Scheduling Events 162 | ================= 163 | 164 | The library includes a default event Scheduler as 165 | ``irc.schedule.DefaultScheduler``, 166 | but this scheduler can be replaced with any other scheduler. For example, 167 | to use the `schedule `_ package, 168 | include it 169 | in your dependencies and install it into the IRC library as so: 170 | 171 | class ScheduleScheduler(irc.schedule.IScheduler): 172 | def execute_every(self, period, func): 173 | schedule.every(period).do(func) 174 | 175 | def execute_at(self, when, func): 176 | schedule.at(when).do(func) 177 | 178 | def execute_after(self, delay, func): 179 | raise NotImplementedError("Not supported") 180 | 181 | def run_pending(self): 182 | schedule.run_pending() 183 | 184 | irc.client.Reactor.scheduler_class = ScheduleScheduler 185 | 186 | 187 | Decoding Input 188 | ============== 189 | 190 | By default, the IRC library attempts to decode all incoming streams as 191 | UTF-8, even though the IRC spec stipulates that no specific encoding can be 192 | expected. Since assuming UTF-8 is not reasonable in the general case, the IRC 193 | library provides options to customize decoding of input by customizing the 194 | ``ServerConnection`` class. The ``buffer_class`` attribute on the 195 | ``ServerConnection`` determines which class is used for buffering lines from the 196 | input stream, using the ``buffer`` module in `jaraco.stream 197 | `_. By default it is 198 | ``buffer.DecodingLineBuffer``, but may be 199 | re-assigned with another class, following the interface of ``buffer.LineBuffer``. 200 | The ``buffer_class`` attribute may be assigned for all instances of 201 | ``ServerConnection`` by overriding the class attribute. 202 | 203 | For example: 204 | 205 | .. code:: python 206 | 207 | from jaraco.stream import buffer 208 | 209 | irc.client.ServerConnection.buffer_class = buffer.LenientDecodingLineBuffer 210 | 211 | The ``LenientDecodingLineBuffer`` attempts UTF-8 but falls back to latin-1, which 212 | will avoid ``UnicodeDecodeError`` in all cases (but may produce unexpected 213 | behavior if an IRC user is using another encoding). 214 | 215 | The buffer may be overridden on a per-instance basis (as long as it's 216 | overridden before the connection is established): 217 | 218 | .. code:: python 219 | 220 | server = irc.client.Reactor().server() 221 | server.buffer_class = buffer.LenientDecodingLineBuffer 222 | server.connect() 223 | 224 | Alternatively, some clients may still want to decode the input using a 225 | different encoding. To decode all input as latin-1 (which decodes any input), 226 | use the following: 227 | 228 | .. code:: python 229 | 230 | irc.client.ServerConnection.buffer_class.encoding = "latin-1" 231 | 232 | Or decode to UTF-8, but use a replacement character for unrecognized byte 233 | sequences: 234 | 235 | .. code:: python 236 | 237 | irc.client.ServerConnection.buffer_class.errors = "replace" 238 | 239 | Or, to simply ignore all input that cannot be decoded: 240 | 241 | .. code:: python 242 | 243 | class IgnoreErrorsBuffer(buffer.DecodingLineBuffer): 244 | def handle_exception(self): 245 | pass 246 | 247 | 248 | irc.client.ServerConnection.buffer_class = IgnoreErrorsBuffer 249 | 250 | The library requires text for message 251 | processing, so a decoding buffer must be used. Clients 252 | must use one of the above techniques for decoding input to text. 253 | 254 | Notes and Contact Info 255 | ====================== 256 | 257 | Enjoy. 258 | 259 | Maintainer: 260 | Jason R. Coombs 261 | 262 | Original Author: 263 | Joel Rosdahl 264 | 265 | Copyright © 1999-2002 Joel Rosdahl 266 | Copyright © 2011-2016 Jason R. Coombs 267 | Copyright © 2009 Ferry Boender 268 | 269 | For Enterprise 270 | ============== 271 | 272 | Available as part of the Tidelift Subscription. 273 | 274 | This project and the maintainers of thousands of other packages are working with Tidelift to deliver one enterprise subscription that covers all of the open source you use. 275 | 276 | `Learn more `_. 277 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Contact 2 | 3 | To report a security vulnerability, please use the [Tidelift security contact](https://tidelift.com/security). Tidelift will coordinate the fix and disclosure. 4 | -------------------------------------------------------------------------------- /conftest.py: -------------------------------------------------------------------------------- 1 | collect_ignore = ["setup.py"] 2 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | extensions = [ 4 | 'sphinx.ext.autodoc', 5 | 'jaraco.packaging.sphinx', 6 | ] 7 | 8 | master_doc = "index" 9 | html_theme = "furo" 10 | 11 | # Link dates and other references in the changelog 12 | extensions += ['rst.linker'] 13 | link_files = { 14 | '../NEWS.rst': dict( 15 | using=dict(GH='https://github.com'), 16 | replace=[ 17 | dict( 18 | pattern=r'(Issue #|\B#)(?P\d+)', 19 | url='{package_url}/issues/{issue}', 20 | ), 21 | dict( 22 | pattern=r'(?m:^((?Pv?\d+(\.\d+){1,2}))\n[-=]+\n)', 23 | with_scm='{text}\n{rev[timestamp]:%d %b %Y}\n', 24 | ), 25 | dict( 26 | pattern=r'PEP[- ](?P\d+)', 27 | url='https://peps.python.org/pep-{pep_number:0>4}/', 28 | ), 29 | dict( 30 | pattern=r"(Sourceforge )(?P\d+)", 31 | url="https://sourceforge.net/p/python-irclib/bugs/{sf_issue}/", 32 | ), 33 | ], 34 | ) 35 | } 36 | 37 | # Be strict about any broken references 38 | nitpicky = True 39 | nitpick_ignore: list[tuple[str, str]] = [] 40 | 41 | nitpick_ignore = [ 42 | ('py:class', 'irc.client.Base'), # an inline superclass 43 | ('py:class', 'jaraco.stream.buffer.DecodingLineBuffer'), # undocumented 44 | ] 45 | 46 | # Include Python intersphinx mapping to prevent failures 47 | # jaraco/skeleton#51 48 | extensions += ['sphinx.ext.intersphinx'] 49 | intersphinx_mapping = { 50 | 'python': ('https://docs.python.org/3', None), 51 | } 52 | 53 | # Preserve authored syntax for defaults 54 | autodoc_preserve_defaults = True 55 | 56 | # Add support for linking usernames, PyPI projects, Wikipedia pages 57 | github_url = 'https://github.com/' 58 | extlinks = { 59 | 'user': (f'{github_url}%s', '@%s'), 60 | 'pypi': ('https://pypi.org/project/%s', '%s'), 61 | 'wiki': ('https://wikipedia.org/wiki/%s', '%s'), 62 | } 63 | extensions += ['sphinx.ext.extlinks'] 64 | 65 | # local 66 | 67 | extensions += ['jaraco.tidelift'] 68 | 69 | extensions += ['sphinx.ext.viewcode'] 70 | 71 | for dependency in 'jaraco.text tempora jaraco.collections'.split(): 72 | rtd_name = dependency.replace('.', '') 73 | url = f'https://{rtd_name}.readthedocs.io/en/latest' 74 | intersphinx_mapping.update({dependency: (url, None)}) 75 | -------------------------------------------------------------------------------- /docs/history.rst: -------------------------------------------------------------------------------- 1 | :tocdepth: 2 2 | 3 | .. _changes: 4 | 5 | History 6 | ******* 7 | 8 | .. include:: ../NEWS (links).rst 9 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Welcome to |project| documentation! 2 | =================================== 3 | 4 | .. sidebar-links:: 5 | :home: 6 | :pypi: 7 | 8 | .. toctree:: 9 | :maxdepth: 1 10 | 11 | history 12 | irc 13 | 14 | .. tidelift-referral-banner:: 15 | 16 | .. include:: ../README.rst 17 | 18 | 19 | Indices and tables 20 | ================== 21 | 22 | * :ref:`genindex` 23 | * :ref:`modindex` 24 | * :ref:`search` 25 | 26 | -------------------------------------------------------------------------------- /docs/irc.rst: -------------------------------------------------------------------------------- 1 | irc package 2 | =========== 3 | 4 | Subpackages 5 | ----------- 6 | 7 | .. toctree:: 8 | 9 | irc.tests 10 | 11 | Submodules 12 | ---------- 13 | 14 | irc.bot module 15 | -------------- 16 | 17 | .. automodule:: irc.bot 18 | :members: 19 | :undoc-members: 20 | :show-inheritance: 21 | 22 | irc.client module 23 | ----------------- 24 | 25 | .. automodule:: irc.client 26 | :members: 27 | :undoc-members: 28 | :show-inheritance: 29 | 30 | irc.connection module 31 | --------------------- 32 | 33 | .. automodule:: irc.connection 34 | :members: 35 | :undoc-members: 36 | :show-inheritance: 37 | 38 | irc.ctcp module 39 | --------------- 40 | 41 | .. automodule:: irc.ctcp 42 | :members: 43 | :undoc-members: 44 | :show-inheritance: 45 | 46 | irc.dict module 47 | --------------- 48 | 49 | .. automodule:: irc.dict 50 | :members: 51 | :undoc-members: 52 | :show-inheritance: 53 | 54 | irc.events module 55 | ----------------- 56 | 57 | .. automodule:: irc.events 58 | :members: 59 | :undoc-members: 60 | :show-inheritance: 61 | 62 | irc.features module 63 | ------------------- 64 | 65 | .. automodule:: irc.features 66 | :members: 67 | :undoc-members: 68 | :show-inheritance: 69 | 70 | irc.modes module 71 | ---------------- 72 | 73 | .. automodule:: irc.modes 74 | :members: 75 | :undoc-members: 76 | :show-inheritance: 77 | 78 | irc.rfc module 79 | -------------- 80 | 81 | .. automodule:: irc.rfc 82 | :members: 83 | :undoc-members: 84 | :show-inheritance: 85 | 86 | irc.schedule module 87 | ------------------- 88 | 89 | .. automodule:: irc.schedule 90 | :members: 91 | :undoc-members: 92 | :show-inheritance: 93 | 94 | irc.server module 95 | ----------------- 96 | 97 | .. automodule:: irc.server 98 | :members: 99 | :undoc-members: 100 | :show-inheritance: 101 | 102 | irc.strings module 103 | ------------------ 104 | 105 | .. automodule:: irc.strings 106 | :members: 107 | :undoc-members: 108 | :show-inheritance: 109 | 110 | 111 | Module contents 112 | --------------- 113 | 114 | .. automodule:: irc 115 | :members: 116 | :undoc-members: 117 | :show-inheritance: 118 | -------------------------------------------------------------------------------- /docs/irc.tests.rst: -------------------------------------------------------------------------------- 1 | irc.tests package 2 | ================= 3 | 4 | Submodules 5 | ---------- 6 | 7 | irc.tests.test_bot module 8 | ------------------------- 9 | 10 | .. automodule:: irc.tests.test_bot 11 | :members: 12 | :undoc-members: 13 | :show-inheritance: 14 | 15 | irc.tests.test_client module 16 | ---------------------------- 17 | 18 | .. automodule:: irc.tests.test_client 19 | :members: 20 | :undoc-members: 21 | :show-inheritance: 22 | 23 | 24 | Module contents 25 | --------------- 26 | 27 | .. automodule:: irc.tests 28 | :members: 29 | :undoc-members: 30 | :show-inheritance: 31 | -------------------------------------------------------------------------------- /irc/__init__.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | 3 | try: 4 | from importlib import metadata 5 | except ImportError: 6 | import importlib_metadata as metadata # type: ignore[no-redef] 7 | 8 | 9 | def _get_version(): 10 | with contextlib.suppress(Exception): 11 | return metadata.version('irc') 12 | return 'unknown' 13 | -------------------------------------------------------------------------------- /irc/bot.py: -------------------------------------------------------------------------------- 1 | """ 2 | Simple IRC bot library. 3 | 4 | This module contains a single-server IRC bot class that can be used to 5 | write simpler bots. 6 | """ 7 | 8 | import abc 9 | import collections 10 | import itertools 11 | import random 12 | import sys 13 | 14 | import more_itertools 15 | 16 | import irc.client 17 | import irc.modes 18 | 19 | from .dict import IRCDict 20 | 21 | 22 | class ServerSpec: 23 | """ 24 | An IRC server specification. 25 | 26 | >>> spec = ServerSpec('localhost') 27 | >>> spec.host 28 | 'localhost' 29 | >>> spec.port 30 | 6667 31 | >>> spec.password 32 | 33 | >>> spec = ServerSpec('127.0.0.1', 6697, 'fooP455') 34 | >>> spec.password 35 | 'fooP455' 36 | """ 37 | 38 | def __init__(self, host, port=6667, password=None): 39 | self.host = host 40 | self.port = port 41 | self.password = password 42 | 43 | def __repr__(self): 44 | return "".format( 45 | self.host, 46 | self.port, 47 | "with password" if self.password else "without password", 48 | ) 49 | 50 | @classmethod 51 | def ensure(cls, input): 52 | spec = cls(*input) if isinstance(input, (list, tuple)) else input 53 | assert isinstance(spec, cls) 54 | return spec 55 | 56 | 57 | class ReconnectStrategy(metaclass=abc.ABCMeta): 58 | """ 59 | An abstract base class describing the interface used by 60 | SingleServerIRCBot for handling reconnect following 61 | disconnect events. 62 | """ 63 | 64 | @abc.abstractmethod 65 | def run(self, bot): 66 | """ 67 | Invoked by the bot on disconnect. Here 68 | a strategy can determine how to react to a 69 | disconnect. 70 | """ 71 | 72 | 73 | class ExponentialBackoff(ReconnectStrategy): 74 | """ 75 | A ReconnectStrategy implementing exponential backoff 76 | with jitter. 77 | """ 78 | 79 | min_interval = 60 80 | max_interval = 300 81 | 82 | def __init__(self, **attrs): 83 | vars(self).update(attrs) 84 | assert 0 <= self.min_interval <= self.max_interval 85 | self._check_scheduled = False 86 | self.attempt_count = itertools.count(1) 87 | 88 | def run(self, bot): 89 | self.bot = bot 90 | 91 | if self._check_scheduled: 92 | return 93 | 94 | # calculate interval in seconds based on connection attempts 95 | intvl = 2 ** next(self.attempt_count) - 1 96 | 97 | # limit the max interval 98 | intvl = min(intvl, self.max_interval) 99 | 100 | # add jitter and truncate to integer seconds 101 | intvl = int(intvl * random.random()) 102 | 103 | # limit the min interval 104 | intvl = max(intvl, self.min_interval) 105 | 106 | self.bot.reactor.scheduler.execute_after(intvl, self.check) 107 | self._check_scheduled = True 108 | 109 | def check(self): 110 | self._check_scheduled = False 111 | if not self.bot.connection.is_connected(): 112 | self.run(self.bot) 113 | self.bot.jump_server() 114 | 115 | 116 | missing = object() 117 | 118 | 119 | class SingleServerIRCBot(irc.client.SimpleIRCClient): 120 | r"""A single-server IRC bot class. 121 | 122 | The bot tries to reconnect if it is disconnected. 123 | 124 | The bot keeps track of the channels it has joined, the other 125 | clients that are present in the channels and which of those that 126 | have operator or voice modes. The "database" is kept in the 127 | self.channels attribute, which is an IRCDict of Channels. 128 | 129 | Arguments: 130 | 131 | server_list -- A list of ServerSpec objects or tuples of 132 | parameters suitable for constructing ServerSpec 133 | objects. Defines the list of servers the bot will 134 | use (in order). 135 | 136 | nickname -- The bot's nickname. 137 | 138 | realname -- The bot's realname. 139 | 140 | recon -- A ReconnectStrategy for reconnecting on 141 | disconnect or failed connection. 142 | 143 | dcc_connections -- A list of initiated/accepted DCC 144 | connections. 145 | 146 | \*\*connect_params -- parameters to pass through to the connect 147 | method. 148 | """ 149 | 150 | def __init__( 151 | self, 152 | server_list, 153 | nickname, 154 | realname, 155 | _=None, 156 | recon=ExponentialBackoff(), 157 | **connect_params, 158 | ): 159 | super().__init__() 160 | self.__connect_params = connect_params 161 | self.channels = IRCDict() 162 | specs = map(ServerSpec.ensure, server_list) 163 | self.servers = more_itertools.peekable(itertools.cycle(specs)) 164 | self.recon = recon 165 | 166 | self._nickname = nickname 167 | self._realname = realname 168 | for i in [ 169 | "disconnect", 170 | "join", 171 | "kick", 172 | "mode", 173 | "namreply", 174 | "nick", 175 | "part", 176 | "quit", 177 | ]: 178 | self.connection.add_global_handler(i, getattr(self, "_on_" + i), -20) 179 | 180 | def _connect(self): 181 | """ 182 | Establish a connection to the server at the front of the server_list. 183 | """ 184 | server = self.servers.peek() 185 | try: 186 | self.connect( 187 | server.host, 188 | server.port, 189 | self._nickname, 190 | server.password, 191 | ircname=self._realname, 192 | **self.__connect_params, 193 | ) 194 | except irc.client.ServerConnectionError: 195 | self.connection._handle_event( 196 | irc.client.Event("disconnect", self.connection.server, "", [""]) 197 | ) 198 | 199 | def _on_disconnect(self, connection, event): 200 | self.channels = IRCDict() 201 | self.recon.run(self) 202 | 203 | def _on_join(self, connection, event): 204 | ch = event.target 205 | nick = event.source.nick 206 | if nick == connection.get_nickname(): 207 | self.channels[ch] = Channel() 208 | self.channels[ch].add_user(nick) 209 | 210 | def _on_kick(self, connection, event): 211 | nick = event.arguments[0] 212 | channel = event.target 213 | 214 | if nick == connection.get_nickname(): 215 | del self.channels[channel] 216 | else: 217 | self.channels[channel].remove_user(nick) 218 | 219 | def _on_mode(self, connection, event): 220 | t = event.target 221 | if not irc.client.is_channel(t): 222 | # mode on self; disregard 223 | return 224 | ch = self.channels[t] 225 | 226 | modes = irc.modes.parse_channel_modes(" ".join(event.arguments)) 227 | for sign, mode, argument in modes: 228 | f = {"+": ch.set_mode, "-": ch.clear_mode}[sign] 229 | f(mode, argument) 230 | 231 | def _on_namreply(self, connection, event): 232 | """ 233 | event.arguments[0] == "@" for secret channels, 234 | "*" for private channels, 235 | "=" for others (public channels) 236 | event.arguments[1] == channel 237 | event.arguments[2] == nick list 238 | """ 239 | 240 | ch_type, channel, nick_list = event.arguments 241 | 242 | if channel == '*': 243 | # User is not in any visible channel 244 | # http://tools.ietf.org/html/rfc2812#section-3.2.5 245 | return 246 | 247 | for nick in nick_list.split(): 248 | nick_modes = [] 249 | 250 | if nick[0] in self.connection.features.prefix: 251 | nick_modes.append(self.connection.features.prefix[nick[0]]) 252 | nick = nick[1:] 253 | 254 | for mode in nick_modes: 255 | self.channels[channel].set_mode(mode, nick) 256 | 257 | self.channels[channel].add_user(nick) 258 | 259 | def _on_nick(self, connection, event): 260 | before = event.source.nick 261 | after = event.target 262 | for ch in self.channels.values(): 263 | if ch.has_user(before): 264 | ch.change_nick(before, after) 265 | 266 | def _on_part(self, connection, event): 267 | nick = event.source.nick 268 | channel = event.target 269 | 270 | if nick == connection.get_nickname(): 271 | del self.channels[channel] 272 | else: 273 | self.channels[channel].remove_user(nick) 274 | 275 | def _on_quit(self, connection, event): 276 | nick = event.source.nick 277 | for ch in self.channels.values(): 278 | if ch.has_user(nick): 279 | ch.remove_user(nick) 280 | 281 | def die(self, msg="Bye, cruel world!"): 282 | """Let the bot die. 283 | 284 | Arguments: 285 | 286 | msg -- Quit message. 287 | """ 288 | 289 | self.connection.disconnect(msg) 290 | sys.exit(0) 291 | 292 | def disconnect(self, msg="I'll be back!"): 293 | """Disconnect the bot. 294 | 295 | The bot will try to reconnect after a while. 296 | 297 | Arguments: 298 | 299 | msg -- Quit message. 300 | """ 301 | self.connection.disconnect(msg) 302 | 303 | @staticmethod 304 | def get_version(): 305 | """Returns the bot version. 306 | 307 | Used when answering a CTCP VERSION request. 308 | """ 309 | return f"Python irc.bot ({irc._get_version()})" 310 | 311 | def jump_server(self, msg="Changing servers"): 312 | """Connect to a new server, possibly disconnecting from the current. 313 | 314 | The bot will skip to next server in the server_list each time 315 | jump_server is called. 316 | """ 317 | if self.connection.is_connected(): 318 | self.connection.disconnect(msg) 319 | 320 | next(self.servers) 321 | self._connect() 322 | 323 | def on_ctcp(self, connection, event): 324 | """Default handler for ctcp events. 325 | 326 | Replies to VERSION and PING requests and relays DCC requests 327 | to the on_dccchat method. 328 | """ 329 | nick = event.source.nick 330 | if event.arguments[0] == "VERSION": 331 | connection.ctcp_reply(nick, "VERSION " + self.get_version()) 332 | elif event.arguments[0] == "PING": 333 | if len(event.arguments) > 1: 334 | connection.ctcp_reply(nick, "PING " + event.arguments[1]) 335 | elif ( 336 | event.arguments[0] == "DCC" 337 | and event.arguments[1].split(" ", 1)[0] == "CHAT" 338 | ): 339 | self.on_dccchat(connection, event) 340 | 341 | def on_dccchat(self, connection, event): 342 | pass 343 | 344 | def start(self): 345 | """Start the bot.""" 346 | self._connect() 347 | super().start() 348 | 349 | 350 | class Channel: 351 | """ 352 | A class for keeping information about an IRC channel. 353 | """ 354 | 355 | user_modes = 'ovqha' 356 | """ 357 | Modes which are applicable to individual users, and which 358 | should be tracked in the mode_users dictionary. 359 | """ 360 | 361 | def __init__(self): 362 | self._users = IRCDict() 363 | self.mode_users = collections.defaultdict(IRCDict) 364 | self.modes = {} 365 | 366 | def users(self): 367 | """Returns an unsorted list of the channel's users.""" 368 | return self._users.keys() 369 | 370 | def opers(self): 371 | """Returns an unsorted list of the channel's operators.""" 372 | return self.mode_users['o'].keys() 373 | 374 | def voiced(self): 375 | """Returns an unsorted list of the persons that have voice 376 | mode set in the channel.""" 377 | return self.mode_users['v'].keys() 378 | 379 | def owners(self): 380 | """Returns an unsorted list of the channel's owners.""" 381 | return self.mode_users['q'].keys() 382 | 383 | def halfops(self): 384 | """Returns an unsorted list of the channel's half-operators.""" 385 | return self.mode_users['h'].keys() 386 | 387 | def admins(self): 388 | """Returns an unsorted list of the channel's admins.""" 389 | return self.mode_users['a'].keys() 390 | 391 | def has_user(self, nick): 392 | """Check whether the channel has a user.""" 393 | return nick in self._users 394 | 395 | def is_oper(self, nick): 396 | """Check whether a user has operator status in the channel.""" 397 | return nick in self.mode_users['o'] 398 | 399 | def is_voiced(self, nick): 400 | """Check whether a user has voice mode set in the channel.""" 401 | return nick in self.mode_users['v'] 402 | 403 | def is_owner(self, nick): 404 | """Check whether a user has owner status in the channel.""" 405 | return nick in self.mode_users['q'] 406 | 407 | def is_halfop(self, nick): 408 | """Check whether a user has half-operator status in the channel.""" 409 | return nick in self.mode_users['h'] 410 | 411 | def is_admin(self, nick): 412 | """Check whether a user has admin status in the channel.""" 413 | return nick in self.mode_users['a'] 414 | 415 | def add_user(self, nick): 416 | self._users[nick] = 1 417 | 418 | @property 419 | def user_dicts(self): 420 | yield self._users 421 | yield from self.mode_users.values() 422 | 423 | def remove_user(self, nick): 424 | for d in self.user_dicts: 425 | d.pop(nick, None) 426 | 427 | def change_nick(self, before, after): 428 | self._users[after] = self._users.pop(before) 429 | for mode_lookup in self.mode_users.values(): 430 | if before in mode_lookup: 431 | mode_lookup[after] = mode_lookup.pop(before) 432 | 433 | def set_userdetails(self, nick, details): 434 | if nick in self._users: 435 | self._users[nick] = details 436 | 437 | def set_mode(self, mode, value=None): 438 | """Set mode on the channel. 439 | 440 | Arguments: 441 | 442 | mode -- The mode (a single-character string). 443 | 444 | value -- Value 445 | """ 446 | if mode in self.user_modes: 447 | self.mode_users[mode][value] = 1 448 | else: 449 | self.modes[mode] = value 450 | 451 | def clear_mode(self, mode, value=None): 452 | """Clear mode on the channel. 453 | 454 | Arguments: 455 | 456 | mode -- The mode (a single-character string). 457 | 458 | value -- Value 459 | """ 460 | try: 461 | if mode in self.user_modes: 462 | del self.mode_users[mode][value] 463 | else: 464 | del self.modes[mode] 465 | except KeyError: 466 | pass 467 | 468 | def has_mode(self, mode): 469 | return mode in self.modes 470 | 471 | def is_moderated(self): 472 | return self.has_mode("m") 473 | 474 | def is_secret(self): 475 | return self.has_mode("s") 476 | 477 | def is_protected(self): 478 | return self.has_mode("p") 479 | 480 | def has_topic_lock(self): 481 | return self.has_mode("t") 482 | 483 | def is_invite_only(self): 484 | return self.has_mode("i") 485 | 486 | def has_allow_external_messages(self): 487 | return self.has_mode("n") 488 | 489 | def has_limit(self): 490 | return self.has_mode("l") 491 | 492 | def limit(self): 493 | if self.has_limit(): 494 | return self.modes["l"] 495 | else: 496 | return None 497 | 498 | def has_key(self): 499 | return self.has_mode("k") 500 | -------------------------------------------------------------------------------- /irc/client.py: -------------------------------------------------------------------------------- 1 | """ 2 | Internet Relay Chat (IRC) protocol client library. 3 | 4 | This library is intended to encapsulate the IRC protocol in Python. 5 | It provides an event-driven IRC client framework. It has 6 | a fairly thorough support for the basic IRC protocol, CTCP, and DCC chat. 7 | 8 | To best understand how to make an IRC client, the reader more 9 | or less must understand the IRC specifications. They are available 10 | here: [IRC specifications]. 11 | 12 | The main features of the IRC client framework are: 13 | 14 | * Abstraction of the IRC protocol. 15 | * Handles multiple simultaneous IRC server connections. 16 | * Handles server PONGing transparently. 17 | * Messages to the IRC server are done by calling methods on an IRC 18 | connection object. 19 | * Messages from an IRC server triggers events, which can be caught 20 | by event handlers. 21 | * Reading from and writing to IRC server sockets are normally done 22 | by an internal select() loop, but the select()ing may be done by 23 | an external main loop. 24 | * Functions can be registered to execute at specified times by the 25 | event-loop. 26 | * Decodes CTCP tagging correctly (hopefully); I haven't seen any 27 | other IRC client implementation that handles the CTCP 28 | specification subtleties. 29 | * A kind of simple, single-server, object-oriented IRC client class 30 | that dispatches events to instance methods is included. 31 | 32 | Current limitations: 33 | 34 | * Data is not written asynchronously to the server, i.e. the write() 35 | may block if the TCP buffers are stuffed. 36 | * DCC file transfers are not supported. 37 | * RFCs 2810, 2811, 2812, and 2813 have not been considered. 38 | 39 | Notes: 40 | * connection.quit() only sends QUIT to the server. 41 | * ERROR from the server triggers the error event and the disconnect event. 42 | * dropping of the connection triggers the disconnect event. 43 | 44 | 45 | .. [IRC specifications] http://www.irchelp.org/irchelp/rfc/ 46 | """ 47 | 48 | from __future__ import annotations 49 | 50 | import abc 51 | import base64 52 | import bisect 53 | import collections 54 | import contextlib 55 | import functools 56 | import itertools 57 | import logging 58 | import re 59 | import select 60 | import socket 61 | import struct 62 | import threading 63 | import time 64 | import warnings 65 | from typing import Callable 66 | 67 | import jaraco.functools 68 | from jaraco.functools import Throttler 69 | from jaraco.stream import buffer 70 | from more_itertools import always_iterable, consume, repeatfunc 71 | 72 | from . import connection, ctcp, events, features, message, schedule 73 | 74 | log = logging.getLogger(__name__) 75 | 76 | 77 | class IRCError(Exception): 78 | "An IRC exception" 79 | 80 | 81 | class InvalidCharacters(ValueError): 82 | "Invalid characters were encountered in the message" 83 | 84 | 85 | class MessageTooLong(ValueError): 86 | "Message is too long" 87 | 88 | 89 | class Connection(metaclass=abc.ABCMeta): 90 | """ 91 | Base class for IRC connections. 92 | """ 93 | 94 | transmit_encoding = 'utf-8' 95 | "encoding used for transmission" 96 | 97 | @abc.abstractproperty 98 | def socket(self): 99 | "The socket for this connection" 100 | 101 | def __init__(self, reactor): 102 | self.reactor = reactor 103 | 104 | def encode(self, msg): 105 | """Encode a message for transmission.""" 106 | return msg.encode(self.transmit_encoding) 107 | 108 | 109 | class ServerConnectionError(IRCError): 110 | pass 111 | 112 | 113 | class ServerNotConnectedError(ServerConnectionError): 114 | pass 115 | 116 | 117 | class ServerConnection(Connection): 118 | """ 119 | An IRC server connection. 120 | 121 | ServerConnection objects are instantiated by calling the server 122 | method on a Reactor object. 123 | """ 124 | 125 | buffer_class = buffer.DecodingLineBuffer 126 | socket = None 127 | connected = False 128 | 129 | def __init__(self, reactor): 130 | super().__init__(reactor) 131 | self.features = features.FeatureSet() 132 | 133 | # save the method args to allow for easier reconnection. 134 | @jaraco.functools.save_method_args 135 | def connect( 136 | self, 137 | server: str, 138 | port: int, 139 | nickname: str, 140 | password: str | None = None, 141 | username: str | None = None, 142 | ircname: str | None = None, 143 | connect_factory=connection.Factory(), 144 | sasl_login: str | None = None, 145 | ): 146 | """Connect/reconnect to a server. 147 | 148 | Arguments: 149 | 150 | * server - Server name 151 | * port - Port number 152 | * nickname - The nickname 153 | * password - Password (if any) 154 | * username - The username 155 | * ircname - The IRC name ("realname") 156 | * server_address - The remote host/port of the server 157 | * connect_factory - A callable that takes the server address and 158 | returns a connection (with a socket interface) 159 | * sasl_login - A string used to toggle sasl plain login. 160 | Password needs to be set as well, and will be used for SASL, 161 | not PASS login. 162 | 163 | This function can be called to reconnect a closed connection. 164 | 165 | Returns the ServerConnection object. 166 | """ 167 | log.debug( 168 | "connect(server=%r, port=%r, nickname=%r, ...)", server, port, nickname 169 | ) 170 | 171 | if self.connected: 172 | self.disconnect("Changing servers") 173 | 174 | self.buffer = self.buffer_class() 175 | self.handlers: dict[str, Callable] = {} 176 | self.real_server_name = "" 177 | self.real_nickname = nickname 178 | self.server = server 179 | self.port = port 180 | self.server_address = (server, port) 181 | self.nickname = nickname 182 | self.username = username or nickname 183 | self.ircname = ircname or nickname 184 | self.password = password 185 | self.connect_factory = connect_factory 186 | self.sasl_login = sasl_login 187 | try: 188 | self.socket = self.connect_factory(self.server_address) 189 | except OSError as ex: 190 | raise ServerConnectionError(f"Couldn't connect to socket: {ex}") from ex 191 | self.connected = True 192 | self.reactor._on_connect(self.socket) 193 | 194 | # Log on... 195 | if self.sasl_login and self.password: 196 | self._sasl_step = None 197 | for i in ["cap", "authenticate", "saslsuccess", "saslfail"]: 198 | self.add_global_handler(i, self._sasl_state_machine, -42) 199 | self.cap("LS") 200 | self.nick(self.nickname) 201 | self.user(self.username, self.ircname) 202 | self._sasl_step = self._sasl_cap_ls 203 | return self 204 | if self.password: 205 | self.pass_(self.password) 206 | self.nick(self.nickname) 207 | self.user(self.username, self.ircname) 208 | return self 209 | 210 | def _sasl_state_machine(self, connection, event): 211 | if self._sasl_step: 212 | self._sasl_step(event) 213 | 214 | def _sasl_cap_ls(self, event): 215 | if ( 216 | event.type == "cap" 217 | and len(event.arguments) > 1 218 | and event.arguments[0] == "LS" 219 | ): 220 | if 'sasl' in event.arguments[1].split(): 221 | self.cap("REQ", "sasl") 222 | self._sasl_step = self._sasl_cap_req 223 | else: 224 | event = Event( 225 | "login_failed", event.target, ["server does not support sasl"] 226 | ) 227 | self._handle_event(event) 228 | self._sasl_end() 229 | 230 | def _sasl_cap_req(self, event): 231 | if event.type == "cap" and len(event.arguments) > 1: 232 | if event.arguments[0] == "ACK" and 'sasl' in event.arguments: 233 | self.send_items('AUTHENTICATE', 'PLAIN') 234 | self._sasl_step = self._sasl_auth_plain 235 | elif event.arguments[0] == "NAK": 236 | event = Event( 237 | "login_failed", event.target, ["server refused sasl protocol"] 238 | ) 239 | self._handle_event(event) 240 | self._sasl_end() 241 | 242 | def _sasl_auth_plain(self, event): 243 | if event.type == "authenticate" and event.target == "+": 244 | auth_string = base64.b64encode( 245 | self.encode(f"\x00{self.sasl_login}\x00{self.password}") 246 | ).decode() 247 | self.send_items('AUTHENTICATE', auth_string) 248 | self._sasl_step = self._sasl_auth_sent 249 | 250 | def _sasl_auth_sent(self, event): 251 | if event.type == "saslsuccess": 252 | self._sasl_end() 253 | elif event.type == "saslfail": 254 | event = Event("login_failed", event.target, event.arguments) 255 | self._handle_event(event) 256 | self._sasl_end() 257 | 258 | def _sasl_end(self): 259 | self._sasl_step = None 260 | self.cap("END") 261 | # SASL done, de-register handlers 262 | for i in ["cap", "authenticate", "saslsuccess", "saslfail"]: 263 | self.remove_global_handler(i, self._sasl_state_machine) 264 | 265 | def reconnect(self): 266 | """ 267 | Reconnect with the last arguments passed to self.connect() 268 | """ 269 | self.connect(*self._saved_connect.args, **self._saved_connect.kwargs) 270 | 271 | def close(self): 272 | """Close the connection. 273 | 274 | This method closes the connection permanently; after it has 275 | been called, the object is unusable. 276 | """ 277 | # Without this thread lock, there is a window during which 278 | # select() can find a closed socket, leading to an EBADF error. 279 | with self.reactor.mutex: 280 | self.disconnect("Closing object") 281 | self.reactor._remove_connection(self) 282 | 283 | def get_server_name(self): 284 | """Get the (real) server name. 285 | 286 | This method returns the (real) server name, or, more 287 | specifically, what the server calls itself. 288 | """ 289 | return self.real_server_name or "" 290 | 291 | def get_nickname(self): 292 | """Get the (real) nick name. 293 | 294 | This method returns the (real) nickname. The library keeps 295 | track of nick changes, so it might not be the nick name that 296 | was passed to the connect() method. 297 | """ 298 | return self.real_nickname 299 | 300 | @contextlib.contextmanager 301 | def as_nick(self, name): 302 | """ 303 | Set the nick for the duration of the context. 304 | """ 305 | orig = self.get_nickname() 306 | self.nick(name) 307 | try: 308 | yield orig 309 | finally: 310 | self.nick(orig) 311 | 312 | def process_data(self): 313 | "read and process input from self.socket" 314 | 315 | try: 316 | reader = getattr(self.socket, 'read', self.socket.recv) 317 | new_data = reader(2**14) 318 | except OSError: 319 | # The server hung up. 320 | self.disconnect("Connection reset by peer") 321 | return 322 | if not new_data: 323 | # Read nothing: connection must be down. 324 | self.disconnect("Connection reset by peer") 325 | return 326 | 327 | self.buffer.feed(new_data) 328 | 329 | # process each non-empty line after logging all lines 330 | for line in self.buffer: 331 | log.debug("FROM SERVER: %s", line) 332 | if not line: 333 | continue 334 | self._process_line(line) 335 | 336 | def _process_line(self, line): 337 | event = Event("all_raw_messages", self.get_server_name(), None, [line]) 338 | self._handle_event(event) 339 | 340 | grp = _rfc_1459_command_regexp.match(line).group 341 | 342 | source = NickMask.from_group(grp("prefix")) 343 | command = events.Command.lookup(grp("command")) 344 | arguments = message.Arguments.from_group(grp('argument')) 345 | tags = message.Tag.from_group(grp('tags')) 346 | 347 | if source and not self.real_server_name: 348 | self.real_server_name = source 349 | 350 | if command == "nick": 351 | if source.nick == self.real_nickname: 352 | self.real_nickname = arguments[0] 353 | elif command == "welcome": 354 | # Record the nickname in case the client changed nick 355 | # in a nicknameinuse callback. 356 | self.real_nickname = arguments[0] 357 | elif command == "featurelist": 358 | self.features.load(arguments) 359 | 360 | handler = ( 361 | self._handle_message 362 | if command in ["privmsg", "notice"] 363 | else self._handle_other 364 | ) 365 | handler(arguments, command, source, tags) 366 | 367 | def _handle_message(self, arguments, command, source, tags): 368 | target, msg = arguments[:2] 369 | messages = ctcp.dequote(msg) 370 | if command == "privmsg": 371 | if is_channel(target): 372 | command = "pubmsg" 373 | else: 374 | if is_channel(target): 375 | command = "pubnotice" 376 | else: 377 | command = "privnotice" 378 | for m in messages: 379 | if isinstance(m, tuple): 380 | if command in ["privmsg", "pubmsg"]: 381 | command = "ctcp" 382 | else: 383 | command = "ctcpreply" 384 | 385 | m = list(m) 386 | log.debug( 387 | "command: %s, source: %s, target: %s, arguments: %s, tags: %s", 388 | command, 389 | source, 390 | target, 391 | m, 392 | tags, 393 | ) 394 | event = Event(command, source, target, m, tags) 395 | self._handle_event(event) 396 | if command == "ctcp" and m[0] == "ACTION": 397 | event = Event("action", source, target, m[1:], tags) 398 | self._handle_event(event) 399 | else: 400 | log.debug( 401 | "command: %s, source: %s, target: %s, arguments: %s, tags: %s", 402 | command, 403 | source, 404 | target, 405 | [m], 406 | tags, 407 | ) 408 | event = Event(command, source, target, [m], tags) 409 | self._handle_event(event) 410 | 411 | def _handle_other(self, arguments, command, source, tags): 412 | target = None 413 | if command == "quit": 414 | arguments = [arguments[0]] 415 | elif command == "ping": 416 | target = arguments[0] 417 | else: 418 | target = arguments[0] if arguments else None 419 | arguments = arguments[1:] 420 | if command == "mode": 421 | if not is_channel(target): 422 | command = "umode" 423 | log.debug( 424 | "command: %s, source: %s, target: %s, arguments: %s, tags: %s", 425 | command, 426 | source, 427 | target, 428 | arguments, 429 | tags, 430 | ) 431 | event = Event(command, source, target, arguments, tags) 432 | self._handle_event(event) 433 | 434 | def _handle_event(self, event): 435 | """[Internal]""" 436 | self.reactor._handle_event(self, event) 437 | if event.type in self.handlers: 438 | for fn in self.handlers[event.type]: 439 | fn(self, event) 440 | 441 | def is_connected(self): 442 | """Return connection status. 443 | 444 | Returns true if connected, otherwise false. 445 | """ 446 | return self.connected 447 | 448 | def add_global_handler(self, *args): 449 | """Add global handler. 450 | 451 | See documentation for IRC.add_global_handler. 452 | """ 453 | self.reactor.add_global_handler(*args) 454 | 455 | def remove_global_handler(self, *args): 456 | """Remove global handler. 457 | 458 | See documentation for IRC.remove_global_handler. 459 | """ 460 | self.reactor.remove_global_handler(*args) 461 | 462 | def action(self, target, action): 463 | """Send a CTCP ACTION command.""" 464 | self.ctcp("ACTION", target, action) 465 | 466 | def admin(self, server=""): 467 | """Send an ADMIN command.""" 468 | self.send_items('ADMIN', server) 469 | 470 | def cap(self, subcommand, *args): 471 | """ 472 | Send a CAP command according to `the spec 473 | `_. 474 | 475 | Arguments: 476 | 477 | subcommand -- LS, LIST, REQ, ACK, CLEAR, END 478 | args -- capabilities, if required for given subcommand 479 | 480 | Example: 481 | 482 | .cap('LS') 483 | .cap('REQ', 'multi-prefix', 'sasl') 484 | .cap('END') 485 | """ 486 | cap_subcommands = set('LS LIST REQ ACK NAK CLEAR END'.split()) 487 | client_subcommands = set(cap_subcommands) - {'NAK'} 488 | assert subcommand in client_subcommands, "invalid subcommand" 489 | 490 | def _multi_parameter(args): 491 | """ 492 | According to the spec:: 493 | 494 | If more than one capability is named, the RFC1459 designated 495 | sentinel (:) for a multi-parameter argument must be present. 496 | 497 | It's not obvious where the sentinel should be present or if it 498 | must be omitted for a single parameter, so follow convention and 499 | only include the sentinel prefixed to the first parameter if more 500 | than one parameter is present. 501 | """ 502 | if len(args) > 1: 503 | return (':' + args[0],) + args[1:] 504 | return args 505 | 506 | self.send_items('CAP', subcommand, *_multi_parameter(args)) 507 | 508 | def ctcp(self, ctcptype, target, parameter=""): 509 | """Send a CTCP command.""" 510 | ctcptype = ctcptype.upper() 511 | self.privmsg( 512 | target, 513 | f"\001{ctcptype} {parameter}\001" if parameter else "\001{ctcptype}\001", 514 | ) 515 | 516 | def ctcp_reply(self, target, parameter): 517 | """Send a CTCP REPLY command.""" 518 | self.notice(target, f"\001{parameter}\001") 519 | 520 | def disconnect(self, message=""): 521 | """Hang up the connection. 522 | 523 | Arguments: 524 | 525 | message -- Quit message. 526 | """ 527 | try: 528 | del self.connected 529 | except AttributeError: 530 | return 531 | 532 | self.quit(message) 533 | 534 | try: 535 | self.socket.shutdown(socket.SHUT_WR) 536 | except OSError: 537 | pass 538 | 539 | self.socket.close() 540 | 541 | del self.socket 542 | self._handle_event(Event("disconnect", self.server, "", [message])) 543 | 544 | def globops(self, text): 545 | """Send a GLOBOPS command.""" 546 | self.send_items('GLOBOPS', ':' + text) 547 | 548 | def info(self, server=""): 549 | """Send an INFO command.""" 550 | self.send_items('INFO', server) 551 | 552 | def invite(self, nick, channel): 553 | """Send an INVITE command.""" 554 | self.send_items('INVITE', nick, channel) 555 | 556 | def ison(self, nicks): 557 | """Send an ISON command. 558 | 559 | Arguments: 560 | 561 | nicks -- List of nicks. 562 | """ 563 | self.send_items('ISON', *tuple(nicks)) 564 | 565 | def join(self, channel, key=""): 566 | """Send a JOIN command.""" 567 | self.send_items('JOIN', channel, key) 568 | 569 | def kick(self, channel, nick, comment=""): 570 | """Send a KICK command.""" 571 | self.send_items('KICK', channel, nick, comment and ':' + comment) 572 | 573 | def links(self, remote_server="", server_mask=""): 574 | """Send a LINKS command.""" 575 | self.send_items('LINKS', remote_server, server_mask) 576 | 577 | def list(self, channels=None, server=""): 578 | """Send a LIST command.""" 579 | self.send_items('LIST', ','.join(always_iterable(channels)), server) 580 | 581 | def lusers(self, server=""): 582 | """Send a LUSERS command.""" 583 | self.send_items('LUSERS', server) 584 | 585 | def mode(self, target, command): 586 | """Send a MODE command.""" 587 | self.send_items('MODE', target, command) 588 | 589 | def motd(self, server=""): 590 | """Send an MOTD command.""" 591 | self.send_items('MOTD', server) 592 | 593 | def names(self, channels=None): 594 | """Send a NAMES command.""" 595 | self.send_items('NAMES', ','.join(always_iterable(channels))) 596 | 597 | def nick(self, newnick): 598 | """Send a NICK command.""" 599 | self.send_items('NICK', newnick) 600 | 601 | def notice(self, target, text): 602 | """Send a NOTICE command.""" 603 | # Should limit len(text) here! 604 | self.send_items('NOTICE', target, ':' + text) 605 | 606 | def oper(self, nick, password): 607 | """Send an OPER command.""" 608 | self.send_items('OPER', nick, password) 609 | 610 | def part(self, channels, message=""): 611 | """Send a PART command.""" 612 | self.send_items('PART', ','.join(always_iterable(channels)), message) 613 | 614 | def pass_(self, password): 615 | """Send a PASS command.""" 616 | self.send_items('PASS', password) 617 | 618 | def ping(self, target, target2=""): 619 | """Send a PING command.""" 620 | self.send_items('PING', target, target2) 621 | 622 | def pong(self, target, target2=""): 623 | """Send a PONG command.""" 624 | self.send_items('PONG', target, target2) 625 | 626 | def privmsg(self, target, text): 627 | """Send a PRIVMSG command.""" 628 | self.send_items('PRIVMSG', target, ':' + text) 629 | 630 | def privmsg_many(self, targets, text): 631 | """Send a PRIVMSG command to multiple targets.""" 632 | target = ','.join(targets) 633 | return self.privmsg(target, text) 634 | 635 | def quit(self, message=""): 636 | """Send a QUIT command.""" 637 | # Note that many IRC servers don't use your QUIT message 638 | # unless you've been connected for at least 5 minutes! 639 | self.send_items('QUIT', message and ':' + message) 640 | 641 | def _prep_message(self, string): 642 | # The string should not contain any carriage return other than the 643 | # one added here. 644 | if '\n' in string: 645 | msg = "Carriage returns not allowed in privmsg(text)" 646 | raise InvalidCharacters(msg) 647 | bytes = self.encode(string) + b'\r\n' 648 | # According to the RFC http://tools.ietf.org/html/rfc2812#page-6, 649 | # clients should not transmit more than 512 bytes. 650 | if len(bytes) > 512: 651 | msg = "Messages limited to 512 bytes including CR/LF" 652 | raise MessageTooLong(msg) 653 | return bytes 654 | 655 | def send_items(self, *items): 656 | """ 657 | Send all non-empty items, separated by spaces. 658 | """ 659 | self.send_raw(' '.join(filter(None, items))) 660 | 661 | def send_raw(self, string): 662 | """Send raw string to the server. 663 | 664 | The string will be padded with appropriate CR LF. 665 | """ 666 | if self.socket is None: 667 | raise ServerNotConnectedError("Not connected.") 668 | sender = getattr(self.socket, 'write', self.socket.send) 669 | try: 670 | sender(self._prep_message(string)) 671 | log.debug("TO SERVER: %s", string) 672 | except OSError: 673 | # Ouch! 674 | self.disconnect("Connection reset by peer.") 675 | 676 | def squit(self, server, comment=""): 677 | """Send an SQUIT command.""" 678 | self.send_items('SQUIT', server, comment and ':' + comment) 679 | 680 | def stats(self, statstype, server=""): 681 | """Send a STATS command.""" 682 | self.send_items('STATS', statstype, server) 683 | 684 | def time(self, server=""): 685 | """Send a TIME command.""" 686 | self.send_items('TIME', server) 687 | 688 | def topic(self, channel, new_topic=None): 689 | """Send a TOPIC command.""" 690 | self.send_items('TOPIC', channel, new_topic and ':' + new_topic) 691 | 692 | def trace(self, target=""): 693 | """Send a TRACE command.""" 694 | self.send_items('TRACE', target) 695 | 696 | def user(self, username, realname): 697 | """Send a USER command.""" 698 | cmd = f'USER {username} 0 * :{realname}' 699 | self.send_raw(cmd) 700 | 701 | def userhost(self, nicks): 702 | """Send a USERHOST command.""" 703 | self.send_items('USERHOST', ",".join(nicks)) 704 | 705 | def users(self, server=""): 706 | """Send a USERS command.""" 707 | self.send_items('USERS', server) 708 | 709 | def version(self, server=""): 710 | """Send a VERSION command.""" 711 | self.send_items('VERSION', server) 712 | 713 | def wallops(self, text): 714 | """Send a WALLOPS command.""" 715 | self.send_items('WALLOPS', ':' + text) 716 | 717 | def who(self, target="", op=""): 718 | """Send a WHO command.""" 719 | self.send_items('WHO', target, op and 'o') 720 | 721 | def whois(self, targets): 722 | """Send a WHOIS command.""" 723 | self.send_items('WHOIS', ",".join(always_iterable(targets))) 724 | 725 | def whowas(self, nick, max="", server=""): 726 | """Send a WHOWAS command.""" 727 | self.send_items('WHOWAS', nick, max, server) 728 | 729 | def set_rate_limit(self, frequency): 730 | """ 731 | Set a `frequency` limit (messages per second) for this connection. 732 | Any attempts to send faster than this rate will block. 733 | """ 734 | self.send_raw = Throttler(self.send_raw, frequency) 735 | 736 | def set_keepalive(self, interval): 737 | """ 738 | Set a keepalive to occur every `interval` on this `ServerConnection`. 739 | 740 | :param interval: `int` in seconds, or `datetime.timedelta` 741 | """ 742 | pinger = functools.partial(self.ping, 'keep-alive') 743 | self.reactor.scheduler.execute_every(period=interval, func=pinger) 744 | 745 | 746 | class PrioritizedHandler(collections.namedtuple('Base', ('priority', 'callback'))): 747 | def __lt__(self, other): 748 | "when sorting prioritized handlers, only use the priority" 749 | return self.priority < other.priority 750 | 751 | 752 | class Reactor: 753 | """ 754 | Processes events from one or more IRC server connections. 755 | 756 | This class implements a reactor in the style of the `reactor pattern 757 | `_. 758 | 759 | When a Reactor object has been instantiated, it can be used to create 760 | Connection objects that represent the IRC connections. The 761 | responsibility of the reactor object is to provide an event-driven 762 | framework for the connections and to keep the connections alive. 763 | It runs a select loop to poll each connection's TCP socket and 764 | hands over the sockets with incoming data for processing by the 765 | corresponding connection. 766 | 767 | The methods of most interest for an IRC client writer are server, 768 | add_global_handler, remove_global_handler, 769 | process_once, and process_forever. 770 | 771 | This is functionally an event-loop which can either use it's own 772 | internal polling loop, or tie into an external event-loop, by 773 | having the external event-system periodically call `process_once` 774 | on the instantiated reactor class. This will allow the reactor 775 | to process any queued data and/or events. 776 | 777 | Calling `process_forever` will hand off execution to the reactor's 778 | internal event-loop, which will not return for the life of the 779 | reactor. 780 | 781 | Here is an example: 782 | 783 | client = irc.client.Reactor() 784 | server = client.server() 785 | server.connect("irc.some.where", 6667, "my_nickname") 786 | server.privmsg("a_nickname", "Hi there!") 787 | client.process_forever() 788 | 789 | This will connect to the IRC server irc.some.where on port 6667 790 | using the nickname my_nickname and send the message "Hi there!" 791 | to the nickname a_nickname. 792 | 793 | The methods of this class are thread-safe; accesses to and modifications 794 | of its internal lists of connections, handlers, and delayed commands 795 | are guarded by a mutex. 796 | """ 797 | 798 | scheduler_class = schedule.DefaultScheduler 799 | connection_class = ServerConnection 800 | 801 | def __do_nothing(*args, **kwargs): 802 | pass 803 | 804 | def __init__(self, on_connect=__do_nothing, on_disconnect=__do_nothing): 805 | """Constructor for Reactor objects. 806 | 807 | on_connect: optional callback invoked when a new connection 808 | is made. 809 | 810 | on_disconnect: optional callback invoked when a socket is 811 | disconnected. 812 | 813 | The arguments mainly exist to be able to use an external 814 | main loop (for example Tkinter's or PyGTK's main app loop) 815 | instead of calling the process_forever method. 816 | 817 | An alternative is to just call ServerConnection.process_once() 818 | once in a while. 819 | """ 820 | 821 | self._on_connect = on_connect 822 | self._on_disconnect = on_disconnect 823 | 824 | scheduler = self.scheduler_class() 825 | assert isinstance(scheduler, schedule.IScheduler) 826 | self.scheduler = scheduler 827 | 828 | self.connections = [] 829 | self.handlers = {} 830 | # Modifications to these shared lists and dict need to be thread-safe 831 | self.mutex = threading.RLock() 832 | 833 | self.add_global_handler("ping", _ping_ponger, -42) 834 | 835 | def server(self): 836 | """Creates and returns a ServerConnection object.""" 837 | 838 | conn = self.connection_class(self) 839 | with self.mutex: 840 | self.connections.append(conn) 841 | return conn 842 | 843 | def process_data(self, sockets): 844 | """Called when there is more data to read on connection sockets. 845 | 846 | Arguments: 847 | 848 | sockets -- A list of socket objects. 849 | 850 | See documentation for Reactor.__init__. 851 | """ 852 | with self.mutex: 853 | log.log(logging.DEBUG - 2, "process_data()") 854 | for sock, conn in itertools.product(sockets, self.connections): 855 | if sock == conn.socket: 856 | conn.process_data() 857 | 858 | def process_timeout(self): 859 | """Called when a timeout notification is due. 860 | 861 | See documentation for Reactor.__init__. 862 | """ 863 | with self.mutex: 864 | self.scheduler.run_pending() 865 | 866 | @property 867 | def sockets(self): 868 | with self.mutex: 869 | return [ 870 | conn.socket 871 | for conn in self.connections 872 | if conn is not None and conn.socket is not None 873 | ] 874 | 875 | def process_once(self, timeout=0): 876 | """Process data from connections once. 877 | 878 | Arguments: 879 | 880 | timeout -- How long the select() call should wait if no 881 | data is available. 882 | 883 | This method should be called periodically to check and process 884 | incoming data, if there are any. If that seems boring, look 885 | at the process_forever method. 886 | """ 887 | log.log(logging.DEBUG - 2, "process_once()") 888 | sockets = self.sockets 889 | if sockets: 890 | in_, out, err = select.select(sockets, [], [], timeout) 891 | self.process_data(in_) 892 | else: 893 | time.sleep(timeout) 894 | self.process_timeout() 895 | 896 | def process_forever(self, timeout=0.2): 897 | """Run an infinite loop, processing data from connections. 898 | 899 | This method repeatedly calls process_once. 900 | 901 | Arguments: 902 | 903 | timeout -- Parameter to pass to process_once. 904 | """ 905 | # This loop should specifically *not* be mutex-locked. 906 | # Otherwise no other thread would ever be able to change 907 | # the shared state of a Reactor object running this function. 908 | log.debug("process_forever(timeout=%s)", timeout) 909 | one = functools.partial(self.process_once, timeout=timeout) 910 | consume(repeatfunc(one)) 911 | 912 | def disconnect_all(self, message=""): 913 | """Disconnects all connections.""" 914 | with self.mutex: 915 | for conn in self.connections: 916 | conn.disconnect(message) 917 | 918 | def add_global_handler(self, event, handler, priority=0): 919 | """Adds a global handler function for a specific event type. 920 | 921 | Arguments: 922 | 923 | event -- Event type (a string). Check the values of 924 | numeric_events for possible event types. 925 | 926 | handler -- Callback function taking 'connection' and 'event' 927 | parameters. 928 | 929 | priority -- A number (the lower number, the higher priority). 930 | 931 | The handler function is called whenever the specified event is 932 | triggered in any of the connections. See documentation for 933 | the Event class. 934 | 935 | The handler functions are called in priority order (lowest 936 | number is highest priority). If a handler function returns 937 | "NO MORE", no more handlers will be called. 938 | """ 939 | handler = PrioritizedHandler(priority, handler) 940 | with self.mutex: 941 | event_handlers = self.handlers.setdefault(event, []) 942 | bisect.insort(event_handlers, handler) 943 | 944 | def remove_global_handler(self, event, handler): 945 | """Removes a global handler function. 946 | 947 | Arguments: 948 | 949 | event -- Event type (a string). 950 | handler -- Callback function. 951 | 952 | Returns 1 on success, otherwise 0. 953 | """ 954 | with self.mutex: 955 | if event not in self.handlers: 956 | return 0 957 | for h in self.handlers[event]: 958 | if handler == h.callback: 959 | self.handlers[event].remove(h) 960 | return 1 961 | 962 | def dcc(self, dcctype="chat"): 963 | """Creates and returns a DCCConnection object. 964 | 965 | Arguments: 966 | 967 | dcctype -- "chat" for DCC CHAT connections or "raw" for 968 | DCC SEND (or other DCC types). If "chat", 969 | incoming data will be split in newline-separated 970 | chunks. If "raw", incoming data is not touched. 971 | """ 972 | with self.mutex: 973 | conn = DCCConnection(self, dcctype) 974 | self.connections.append(conn) 975 | return conn 976 | 977 | def _handle_event(self, connection, event): 978 | """ 979 | Handle an Event event incoming on ServerConnection connection. 980 | """ 981 | with self.mutex: 982 | matching_handlers = sorted( 983 | self.handlers.get("all_events", []) + self.handlers.get(event.type, []) 984 | ) 985 | for handler in matching_handlers: 986 | result = handler.callback(connection, event) 987 | if result == "NO MORE": 988 | return 989 | 990 | def _remove_connection(self, connection): 991 | """[Internal]""" 992 | with self.mutex: 993 | self.connections.remove(connection) 994 | self._on_disconnect(connection.socket) 995 | 996 | 997 | _cmd_pat = ( 998 | "^(@(?P[^ ]*) )?(:(?P[^ ]+) +)?" 999 | "(?P[^ ]+)( *(?P .+))?" 1000 | ) 1001 | _rfc_1459_command_regexp = re.compile(_cmd_pat) 1002 | 1003 | 1004 | class DCCConnectionError(IRCError): 1005 | pass 1006 | 1007 | 1008 | class DCCConnection(Connection): 1009 | """ 1010 | A DCC (Direct Client Connection). 1011 | 1012 | DCCConnection objects are instantiated by calling the dcc 1013 | method on a Reactor object. 1014 | """ 1015 | 1016 | socket = None 1017 | connected = False 1018 | passive = False 1019 | peeraddress = None 1020 | peerport = None 1021 | 1022 | def __init__(self, reactor, dcctype): 1023 | super().__init__(reactor) 1024 | self.dcctype = dcctype 1025 | 1026 | def connect(self, address, port): 1027 | """Connect/reconnect to a DCC peer. 1028 | 1029 | Arguments: 1030 | address -- Host/IP address of the peer. 1031 | 1032 | port -- The port number to connect to. 1033 | 1034 | Returns the DCCConnection object. 1035 | """ 1036 | self.peeraddress = socket.gethostbyname(address) 1037 | self.peerport = port 1038 | self.buffer = buffer.LineBuffer() 1039 | self.handlers = {} 1040 | self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 1041 | try: 1042 | self.socket.connect((self.peeraddress, self.peerport)) 1043 | except OSError as x: 1044 | raise DCCConnectionError(f"Couldn't connect to socket: {x}") from x 1045 | self.connected = True 1046 | self.reactor._on_connect(self.socket) 1047 | return self 1048 | 1049 | def listen(self, addr=None): 1050 | """Wait for a connection/reconnection from a DCC peer. 1051 | 1052 | Returns the DCCConnection object. 1053 | 1054 | The local IP address and port are available as 1055 | self.localaddress and self.localport. After connection from a 1056 | peer, the peer address and port are available as 1057 | self.peeraddress and self.peerport. 1058 | """ 1059 | self.buffer = buffer.LineBuffer() 1060 | self.handlers = {} 1061 | self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 1062 | self.passive = True 1063 | default_addr = socket.gethostbyname(socket.gethostname()), 0 1064 | try: 1065 | self.socket.bind(addr or default_addr) 1066 | self.localaddress, self.localport = self.socket.getsockname() 1067 | self.socket.listen(10) 1068 | except OSError as x: 1069 | raise DCCConnectionError(f"Couldn't bind socket: {x}") from x 1070 | return self 1071 | 1072 | def disconnect(self, message=""): 1073 | """Hang up the connection and close the object. 1074 | 1075 | Arguments: 1076 | 1077 | message -- Quit message. 1078 | """ 1079 | try: 1080 | del self.connected 1081 | except AttributeError: 1082 | return 1083 | 1084 | try: 1085 | self.socket.shutdown(socket.SHUT_WR) 1086 | except OSError: 1087 | pass 1088 | 1089 | self.socket.close() 1090 | 1091 | del self.socket 1092 | self.reactor._handle_event( 1093 | self, Event("dcc_disconnect", self.peeraddress, "", [message]) 1094 | ) 1095 | self.reactor._remove_connection(self) 1096 | 1097 | def process_data(self): 1098 | """[Internal]""" 1099 | 1100 | if self.passive and not self.connected: 1101 | conn, (self.peeraddress, self.peerport) = self.socket.accept() 1102 | self.socket.close() 1103 | self.socket = conn 1104 | self.connected = True 1105 | log.debug("DCC connection from %s:%d", self.peeraddress, self.peerport) 1106 | self.reactor._handle_event( 1107 | self, Event("dcc_connect", self.peeraddress, None, None) 1108 | ) 1109 | return 1110 | 1111 | try: 1112 | new_data = self.socket.recv(2**14) 1113 | except OSError: 1114 | # The server hung up. 1115 | self.disconnect("Connection reset by peer") 1116 | return 1117 | if not new_data: 1118 | # Read nothing: connection must be down. 1119 | self.disconnect("Connection reset by peer") 1120 | return 1121 | 1122 | if self.dcctype == "chat": 1123 | self.buffer.feed(new_data) 1124 | 1125 | chunks = list(self.buffer) 1126 | 1127 | if len(self.buffer) > 2**14: 1128 | # Bad peer! Naughty peer! 1129 | log.info("Received >16k from a peer without a newline; disconnecting.") 1130 | self.disconnect() 1131 | return 1132 | else: 1133 | chunks = [new_data] 1134 | 1135 | command = "dccmsg" 1136 | prefix = self.peeraddress 1137 | target = None 1138 | for chunk in chunks: 1139 | log.debug("FROM PEER: %s", chunk) 1140 | arguments = [chunk] 1141 | log.debug( 1142 | "command: %s, source: %s, target: %s, arguments: %s", 1143 | command, 1144 | prefix, 1145 | target, 1146 | arguments, 1147 | ) 1148 | event = Event(command, prefix, target, arguments) 1149 | self.reactor._handle_event(self, event) 1150 | 1151 | def privmsg(self, text): 1152 | """ 1153 | Send text to DCC peer. 1154 | 1155 | The text will be padded with a newline if it's a DCC CHAT session. 1156 | """ 1157 | if self.dcctype == 'chat': 1158 | text += '\n' 1159 | return self.send_bytes(self.encode(text)) 1160 | 1161 | def send_bytes(self, bytes): 1162 | """ 1163 | Send data to DCC peer. 1164 | """ 1165 | try: 1166 | self.socket.send(bytes) 1167 | log.debug("TO PEER: %r\n", bytes) 1168 | except OSError: 1169 | self.disconnect("Connection reset by peer.") 1170 | 1171 | 1172 | class SimpleIRCClient: 1173 | """A simple single-server IRC client class. 1174 | 1175 | This is an example of an object-oriented wrapper of the IRC 1176 | framework. A real IRC client can be made by subclassing this 1177 | class and adding appropriate methods. 1178 | 1179 | The method on_join will be called when a "join" event is created 1180 | (which is done when the server sends a JOIN messsage/command), 1181 | on_privmsg will be called for "privmsg" events, and so on. The 1182 | handler methods get two arguments: the connection object (same as 1183 | self.connection) and the event object. 1184 | 1185 | Functionally, any of the event names in `events.py` may be subscribed 1186 | to by prefixing them with `on_`, and creating a function of that 1187 | name in the child-class of `SimpleIRCClient`. When the event of 1188 | `event_name` is received, the appropriately named method will be 1189 | called (if it exists) by runtime class introspection. 1190 | 1191 | See `_dispatcher()`, which takes the event name, postpends it to 1192 | `on_`, and then attemps to look up the class member function by 1193 | name and call it. 1194 | 1195 | Instance attributes that can be used by sub classes: 1196 | 1197 | reactor -- The Reactor instance. 1198 | 1199 | connection -- The ServerConnection instance. 1200 | 1201 | dcc_connections -- A list of DCCConnection instances. 1202 | """ 1203 | 1204 | reactor_class = Reactor 1205 | 1206 | def __init__(self): 1207 | self.reactor = self.reactor_class() 1208 | self.connection = self.reactor.server() 1209 | self.dcc_connections = [] 1210 | self.reactor.add_global_handler("all_events", self._dispatcher, -10) 1211 | self.reactor.add_global_handler("dcc_disconnect", self._dcc_disconnect, -10) 1212 | 1213 | def _dispatcher(self, connection, event): 1214 | """ 1215 | Dispatch events to on_ method, if present. 1216 | """ 1217 | log.debug("_dispatcher: %s", event.type) 1218 | 1219 | def do_nothing(connection, event): 1220 | return None 1221 | 1222 | method = getattr(self, "on_" + event.type, do_nothing) 1223 | method(connection, event) 1224 | 1225 | def _dcc_disconnect(self, connection, event): 1226 | self.dcc_connections.remove(connection) 1227 | 1228 | def connect(self, *args, **kwargs): 1229 | """Connect using the underlying connection""" 1230 | self.connection.connect(*args, **kwargs) 1231 | 1232 | def dcc(self, *args, **kwargs): 1233 | """Create and associate a new DCCConnection object. 1234 | 1235 | Use the returned object to listen for or connect to 1236 | a DCC peer. 1237 | """ 1238 | dcc = self.reactor.dcc(*args, **kwargs) 1239 | self.dcc_connections.append(dcc) 1240 | return dcc 1241 | 1242 | def dcc_connect(self, address, port, dcctype="chat"): 1243 | """Connect to a DCC peer. 1244 | 1245 | Arguments: 1246 | 1247 | address -- IP address of the peer. 1248 | 1249 | port -- Port to connect to. 1250 | 1251 | Returns a DCCConnection instance. 1252 | """ 1253 | warnings.warn("Use self.dcc(type).connect()", DeprecationWarning, stacklevel=2) 1254 | return self.dcc(dcctype).connect(address, port) 1255 | 1256 | def dcc_listen(self, dcctype="chat"): 1257 | """Listen for connections from a DCC peer. 1258 | 1259 | Returns a DCCConnection instance. 1260 | """ 1261 | warnings.warn("Use self.dcc(type).listen()", DeprecationWarning, stacklevel=2) 1262 | return self.dcc(dcctype).listen() 1263 | 1264 | def start(self): 1265 | """Start the IRC client.""" 1266 | self.reactor.process_forever() 1267 | 1268 | 1269 | class Event: 1270 | """ 1271 | An IRC event. 1272 | 1273 | >>> print(Event('privmsg', '@somebody', '#channel')) 1274 | type: privmsg, source: @somebody, target: #channel, arguments: [], tags: [] 1275 | """ 1276 | 1277 | def __init__(self, type, source, target, arguments=None, tags=None): 1278 | """ 1279 | Initialize an Event. 1280 | 1281 | Arguments: 1282 | 1283 | type -- A string describing the event. 1284 | 1285 | source -- The originator of the event (a nick mask or a server). 1286 | 1287 | target -- The target of the event (a nick or a channel). 1288 | 1289 | arguments -- Any event-specific arguments. 1290 | """ 1291 | self.type = type 1292 | self.source = source 1293 | self.target = target 1294 | if arguments is None: 1295 | arguments = [] 1296 | self.arguments = arguments 1297 | if tags is None: 1298 | tags = [] 1299 | self.tags = tags 1300 | 1301 | def __str__(self): 1302 | tmpl = ( 1303 | "type: {type}, " 1304 | "source: {source}, " 1305 | "target: {target}, " 1306 | "arguments: {arguments}, " 1307 | "tags: {tags}" 1308 | ) 1309 | return tmpl.format(**vars(self)) 1310 | 1311 | 1312 | def is_channel(string): 1313 | """Check if a string is a channel name. 1314 | 1315 | Returns true if the argument is a channel name, otherwise false. 1316 | """ 1317 | return string and string[0] in "#&+!" 1318 | 1319 | 1320 | def ip_numstr_to_quad(num): 1321 | """ 1322 | Convert an IP number as an integer given in ASCII 1323 | representation to an IP address string. 1324 | 1325 | >>> ip_numstr_to_quad('3232235521') 1326 | '192.168.0.1' 1327 | >>> ip_numstr_to_quad(3232235521) 1328 | '192.168.0.1' 1329 | """ 1330 | packed = struct.pack('>L', int(num)) 1331 | bytes = struct.unpack('BBBB', packed) 1332 | return ".".join(map(str, bytes)) 1333 | 1334 | 1335 | def ip_quad_to_numstr(quad): 1336 | """ 1337 | Convert an IP address string (e.g. '192.168.0.1') to an IP 1338 | number as a base-10 integer given in ASCII representation. 1339 | 1340 | >>> ip_quad_to_numstr('192.168.0.1') 1341 | '3232235521' 1342 | """ 1343 | bytes = map(int, quad.split(".")) 1344 | packed = struct.pack('BBBB', *bytes) 1345 | return str(struct.unpack('>L', packed)[0]) 1346 | 1347 | 1348 | class NickMask(str): 1349 | """ 1350 | A nickmask (the source of an Event) 1351 | 1352 | >>> nm = NickMask('pinky!username@example.com') 1353 | >>> nm.nick 1354 | 'pinky' 1355 | 1356 | >>> nm.host 1357 | 'example.com' 1358 | 1359 | >>> nm.user 1360 | 'username' 1361 | 1362 | >>> isinstance(nm, str) 1363 | True 1364 | 1365 | >>> nm = NickMask('красный!red@yahoo.ru') 1366 | 1367 | >>> isinstance(nm.nick, str) 1368 | True 1369 | 1370 | Some messages omit the userhost. In that case, None is returned. 1371 | 1372 | >>> nm = NickMask('irc.server.net') 1373 | >>> nm.nick 1374 | 'irc.server.net' 1375 | >>> nm.userhost 1376 | >>> nm.host 1377 | >>> nm.user 1378 | """ 1379 | 1380 | @classmethod 1381 | def from_params(cls, nick, user, host): 1382 | return cls('{nick}!{user}@{host}'.format(**vars())) 1383 | 1384 | @property 1385 | def nick(self): 1386 | nick, sep, userhost = self.partition("!") 1387 | return nick 1388 | 1389 | @property 1390 | def userhost(self): 1391 | nick, sep, userhost = self.partition("!") 1392 | return userhost or None 1393 | 1394 | @property 1395 | def host(self): 1396 | nick, sep, userhost = self.partition("!") 1397 | user, sep, host = userhost.partition('@') 1398 | return host or None 1399 | 1400 | @property 1401 | def user(self): 1402 | nick, sep, userhost = self.partition("!") 1403 | user, sep, host = userhost.partition('@') 1404 | return user or None 1405 | 1406 | @classmethod 1407 | def from_group(cls, group): 1408 | return cls(group) if group else None 1409 | 1410 | 1411 | def _ping_ponger(connection, event): 1412 | "A global handler for the 'ping' event" 1413 | connection.pong(event.target) 1414 | -------------------------------------------------------------------------------- /irc/client_aio.py: -------------------------------------------------------------------------------- 1 | """ 2 | Internet Relay Chat (IRC) asyncio-based protocol client library. 3 | 4 | This an extension of the IRC client framework in irc.client that replaces 5 | original select-based event loop with one that uses Python 3's native 6 | asyncio event loop. 7 | 8 | This implementation shares many of the features of its select-based 9 | cousin, including: 10 | 11 | * Abstraction of the IRC protocol. 12 | * Handles multiple simultaneous IRC server connections. 13 | * Handles server PONGing transparently. 14 | * Messages to the IRC server are done by calling methods on an IRC 15 | connection object. 16 | * Messages from an IRC server triggers events, which can be caught 17 | by event handlers. 18 | * Reading from and writing to IRC server sockets are normally done 19 | by an internal select() loop, but the select()ing may be done by 20 | an external main loop. 21 | * Functions can be registered to execute at specified times by the 22 | event-loop. 23 | * Decodes CTCP tagging correctly (hopefully); I haven't seen any 24 | other IRC client implementation that handles the CTCP 25 | specification subtleties. 26 | * A kind of simple, single-server, object-oriented IRC client class 27 | that dispatches events to instance methods is included. 28 | 29 | Current limitations: 30 | * DCC chat has not yet been implemented 31 | * DCC file transfers are not suppored 32 | * RFCs 2810, 2811, 2812, and 2813 have not been considered. 33 | 34 | Notes: 35 | * connection.quit() only sends QUIT to the server. 36 | * ERROR from the server triggers the error event and the disconnect event. 37 | * dropping of the connection triggers the disconnect event. 38 | """ 39 | 40 | import asyncio 41 | import logging 42 | import threading 43 | 44 | from . import connection 45 | from .client import ( 46 | Event, 47 | Reactor, 48 | ServerConnection, 49 | ServerNotConnectedError, 50 | SimpleIRCClient, 51 | _ping_ponger, 52 | ) 53 | 54 | log = logging.getLogger(__name__) 55 | 56 | 57 | class IrcProtocol(asyncio.Protocol): 58 | """ 59 | simple asyncio-based Protocol for handling connections to 60 | the IRC Server. 61 | 62 | Note: In order to maintain a consistent interface with 63 | `irc.ServerConnection`, handling of incoming and outgoing data 64 | is mostly handling by the `AioConnection` object, using the same 65 | callback methods as on an `irc.ServerConnection` instance. 66 | """ 67 | 68 | def __init__(self, connection, loop): 69 | """ 70 | Constructor for IrcProtocol objects. 71 | """ 72 | self.connection = connection 73 | self.loop = loop 74 | 75 | def data_received(self, data): 76 | self.connection.process_data(data) 77 | 78 | def connection_lost(self, exc): 79 | log.debug(f"connection lost: {exc}") 80 | self.connection.disconnect() 81 | 82 | 83 | class AioConnection(ServerConnection): 84 | """ 85 | An IRC server connection. 86 | 87 | AioConnection objects are instantiated by calling the server 88 | method on a AioReactor object. 89 | 90 | Note: because AioConnection inherits from 91 | irc.client.ServerConnection, it has all the convenience 92 | methods on ServerConnection for handling outgoing data, 93 | including (but not limited to): 94 | 95 | * join(channel, key="") 96 | * part(channel, message="") 97 | * privmsg(target, text) 98 | * privmsg_many(targets, text) 99 | * quit(message="") 100 | 101 | And many more. See the documentation on 102 | irc.client.ServerConnection for a full list of convience 103 | functions available. 104 | """ 105 | 106 | protocol_class = IrcProtocol 107 | 108 | async def connect( 109 | self, 110 | server, 111 | port, 112 | nickname, 113 | password=None, 114 | username=None, 115 | ircname=None, 116 | connect_factory=connection.AioFactory(), 117 | ): 118 | """Connect/reconnect to a server. 119 | 120 | Arguments: 121 | 122 | * server - Server name 123 | * port - Port number 124 | * nickname - The nickname 125 | * password - Password (if any) 126 | * username - The username 127 | * ircname - The IRC name ("realname") 128 | 129 | * connect_factory - An async callable that takes the event loop and the 130 | server address, and returns a connection (with a socket interface) 131 | 132 | This function can be called to reconnect a closed connection. 133 | 134 | Returns the AioProtocol instance (used for handling incoming 135 | IRC data) and the transport instance (used for handling 136 | outgoing data). 137 | """ 138 | if self.connected: 139 | self.disconnect("Changing servers") 140 | 141 | self.buffer = self.buffer_class() 142 | self.handlers = {} 143 | self.real_server_name = "" 144 | self.real_nickname = nickname 145 | self.server = server 146 | self.port = port 147 | self.server_address = (server, port) 148 | self.nickname = nickname 149 | self.username = username or nickname 150 | self.ircname = ircname or nickname 151 | self.password = password 152 | self.connect_factory = connect_factory 153 | 154 | protocol_instance = self.protocol_class(self, self.reactor.loop) 155 | connection = self.connect_factory(protocol_instance, self.server_address) 156 | transport, protocol = await connection 157 | 158 | self.transport = transport 159 | self.protocol = protocol 160 | 161 | self.connected = True 162 | self.reactor._on_connect(self.protocol, self.transport) 163 | 164 | # Log on... 165 | if self.password: 166 | self.pass_(self.password) 167 | self.nick(self.nickname) 168 | self.user(self.username, self.ircname) 169 | return self 170 | 171 | def process_data(self, new_data): 172 | """ 173 | handles incoming data from the `IrcProtocol` connection. 174 | Main data processing/routing is handled by the _process_line 175 | method, inherited from `ServerConnection` 176 | """ 177 | self.buffer.feed(new_data) 178 | 179 | # process each non-empty line after logging all lines 180 | for line in self.buffer: 181 | log.debug("FROM SERVER: %s", line) 182 | if not line: 183 | continue 184 | self._process_line(line) 185 | 186 | def send_raw(self, string): 187 | """Send raw string to the server, via the asyncio transport. 188 | 189 | The string will be padded with appropriate CR LF. 190 | """ 191 | log.debug(f'RAW: {string}') 192 | if self.transport is None: 193 | raise ServerNotConnectedError("Not connected.") 194 | 195 | self.transport.write(self._prep_message(string)) 196 | 197 | def disconnect(self, message=""): 198 | """Hang up the connection. 199 | 200 | Arguments: 201 | message -- Quit message. 202 | """ 203 | try: 204 | del self.connected 205 | except AttributeError: 206 | return 207 | 208 | self.quit(message) 209 | 210 | self.transport.close() 211 | 212 | self._handle_event(Event("disconnect", self.server, "", [message])) 213 | 214 | 215 | class AioReactor(Reactor): 216 | """ 217 | Processes message from on or more asyncio-based IRC server connections. 218 | 219 | This class inherits most of its functionality from irc.client.Reactor, 220 | and mainly replaces the select-based loop from that reactor with 221 | an asyncio event loop. 222 | 223 | Note: if not event loop is passed into AioReactor, it will try to get 224 | the current loop. However, if there is a specific event loop 225 | you'd like to use, you can pass that loop by using the `loop` kwarg 226 | in the AioReactor's constructor: 227 | 228 | async def my_repeating_message(connection): 229 | while True: 230 | connection.privmsg('#my-irc-channel', 'hello!') 231 | await asyncio.sleep(60) 232 | 233 | my_loop = asyncio.get_event_loop() 234 | 235 | client = AioReactor(loop=my_loop) 236 | server = client.server() 237 | 238 | # use `my_loop` to initialize the repeating message on the same 239 | # loop as the AioRector 240 | asyncio.ensure_future(my_repeating_message(server), loop=my_loop) 241 | 242 | # connect to the server, start the loop 243 | server.connect('my.irc.server', 6667, 'my_irc_nick') 244 | client.process_forever() 245 | 246 | The above code will connect to the IRC server my.irc.server 247 | and echo the 'hello!' message to 'my-irc-channel' every 60 seconds. 248 | """ 249 | 250 | connection_class = AioConnection 251 | 252 | def __do_nothing(*args, **kwargs): 253 | pass 254 | 255 | def __init__(self, on_connect=__do_nothing, on_disconnect=__do_nothing, loop=None): 256 | self._on_connect = on_connect 257 | self._on_disconnect = on_disconnect 258 | 259 | self.connections = [] 260 | self.handlers = {} 261 | 262 | self.mutex = threading.RLock() 263 | 264 | self.loop = asyncio.get_event_loop() if loop is None else loop 265 | 266 | self.add_global_handler("ping", _ping_ponger, -42) 267 | 268 | def process_forever(self): 269 | """Run an infinite loop, processing data from connections. 270 | 271 | Rather than call `process_once` repeatedly, like 272 | irc.client.reactor, incoming data is handled via the 273 | asycio.Protocol class -- by default, the IrcProtocol 274 | class definied above. 275 | """ 276 | self.loop.run_forever() 277 | 278 | 279 | class AioSimpleIRCClient(SimpleIRCClient): 280 | """A simple single-server IRC client class. 281 | 282 | This class is functionally equivalent irc.client.SimpleIRCClient, 283 | the only difference being the using of the AioReactor for 284 | asyncio-based loops. 285 | 286 | For more information on AioSimpleIRCClient, see the documentation 287 | on irc.client.SimpleIRCClient 288 | """ 289 | 290 | reactor_class = AioReactor 291 | 292 | def connect(self, *args, **kwargs): 293 | self.reactor.loop.run_until_complete(self.connection.connect(*args, **kwargs)) 294 | -------------------------------------------------------------------------------- /irc/codes.txt: -------------------------------------------------------------------------------- 1 | 001 welcome 2 | 002 yourhost 3 | 003 created 4 | 004 myinfo 5 | 005 featurelist # XXX 6 | 200 tracelink 7 | 201 traceconnecting 8 | 202 tracehandshake 9 | 203 traceunknown 10 | 204 traceoperator 11 | 205 traceuser 12 | 206 traceserver 13 | 207 traceservice 14 | 208 tracenewtype 15 | 209 traceclass 16 | 210 tracereconnect 17 | 211 statslinkinfo 18 | 212 statscommands 19 | 213 statscline 20 | 214 statsnline 21 | 215 statsiline 22 | 216 statskline 23 | 217 statsqline 24 | 218 statsyline 25 | 219 endofstats 26 | 221 umodeis 27 | 231 serviceinfo 28 | 232 endofservices 29 | 233 service 30 | 234 servlist 31 | 235 servlistend 32 | 241 statslline 33 | 242 statsuptime 34 | 243 statsoline 35 | 244 statshline 36 | 250 luserconns 37 | 251 luserclient 38 | 252 luserop 39 | 253 luserunknown 40 | 254 luserchannels 41 | 255 luserme 42 | 256 adminme 43 | 257 adminloc1 44 | 258 adminloc2 45 | 259 adminemail 46 | 261 tracelog 47 | 262 endoftrace 48 | 263 tryagain 49 | 265 n_local 50 | 266 n_global 51 | 300 none 52 | 301 away 53 | 302 userhost 54 | 303 ison 55 | 305 unaway 56 | 306 nowaway 57 | 311 whoisuser 58 | 312 whoisserver 59 | 313 whoisoperator 60 | 314 whowasuser 61 | 315 endofwho 62 | 316 whoischanop 63 | 317 whoisidle 64 | 318 endofwhois 65 | 319 whoischannels 66 | 321 liststart 67 | 322 list 68 | 323 listend 69 | 324 channelmodeis 70 | 329 channelcreate 71 | # : - Spawned from a /whois 72 | 330 whoisaccount 73 | 331 notopic 74 | 332 currenttopic 75 | 333 topicinfo 76 | 341 inviting 77 | 342 summoning 78 | 346 invitelist 79 | 347 endofinvitelist 80 | 348 exceptlist 81 | 349 endofexceptlist 82 | 351 version 83 | 352 whoreply 84 | 353 namreply 85 | # Response to a WHOX query 86 | 354 whospcrpl 87 | 361 killdone 88 | 362 closing 89 | 363 closeend 90 | 364 links 91 | 365 endoflinks 92 | 366 endofnames 93 | 367 banlist 94 | 368 endofbanlist 95 | 369 endofwhowas 96 | 371 info 97 | 372 motd 98 | 373 infostart 99 | 374 endofinfo 100 | 375 motdstart 101 | 376 endofmotd 102 | 377 motd2 103 | 381 youreoper 104 | 382 rehashing 105 | 384 myportis 106 | 391 time 107 | 392 usersstart 108 | 393 users 109 | 394 endofusers 110 | 395 nousers 111 | 401 nosuchnick 112 | 402 nosuchserver 113 | 403 nosuchchannel 114 | 404 cannotsendtochan 115 | 405 toomanychannels 116 | 406 wasnosuchnick 117 | 407 toomanytargets 118 | 409 noorigin 119 | 410 invalidcapcmd 120 | 411 norecipient 121 | 412 notexttosend 122 | 413 notoplevel 123 | 414 wildtoplevel 124 | 421 unknowncommand 125 | 422 nomotd 126 | 423 noadmininfo 127 | 424 fileerror 128 | 431 nonicknamegiven 129 | 432 erroneusnickname # Thiss iz how its speld in thee RFC. 130 | 433 nicknameinuse 131 | 436 nickcollision 132 | 437 unavailresource # "Nick temporally unavailable" 133 | 441 usernotinchannel 134 | 442 notonchannel 135 | 443 useronchannel 136 | 444 nologin 137 | 445 summondisabled 138 | 446 usersdisabled 139 | 451 notregistered 140 | 461 needmoreparams 141 | 462 alreadyregistered 142 | 463 nopermforhost 143 | 464 passwdmismatch 144 | 465 yourebannedcreep # I love this one... 145 | 466 youwillbebanned 146 | 467 keyset 147 | 471 channelisfull 148 | 472 unknownmode 149 | 473 inviteonlychan 150 | 474 bannedfromchan 151 | 475 badchannelkey 152 | 476 badchanmask 153 | 477 nochanmodes # "Channel doesn't support modes" 154 | 478 banlistfull 155 | # generated when /knock is ran on a channel that you 156 | # are either in or has /knock'ing disabled 157 | 480 cannotknock 158 | 481 noprivileges 159 | 482 chanoprivsneeded 160 | 483 cantkillserver 161 | 484 restricted # Connection is restricted 162 | 485 uniqopprivsneeded 163 | 491 nooperhost 164 | 492 noservicehost 165 | 501 umodeunknownflag 166 | 502 usersdontmatch 167 | # IRCv3.1 SASL https://ircv3.net/specs/extensions/sasl-3.1 168 | 900 loggedin 169 | 901 loggedout 170 | 902 nicklocked 171 | 903 saslsuccess 172 | 904 saslfail 173 | 905 sasltoolong 174 | 906 saslaborted 175 | 907 saslalready 176 | 908 saslmechs 177 | -------------------------------------------------------------------------------- /irc/connection.py: -------------------------------------------------------------------------------- 1 | import socket 2 | 3 | 4 | def identity(x): 5 | return x 6 | 7 | 8 | class Factory: 9 | """ 10 | A class for creating custom socket connections. 11 | 12 | To create a simple connection: 13 | 14 | .. code-block:: python 15 | 16 | server_address = ('localhost', 80) 17 | Factory()(server_address) 18 | 19 | To create an SSL connection: 20 | 21 | .. code-block:: python 22 | 23 | context = ssl.create_default_context() 24 | wrapper = functools.partial(context.wrap_socket, server_hostname=server_address) 25 | Factory(wrapper=wrapper)(server_address) 26 | 27 | To create an SSL connection with parameters to wrap_socket: 28 | 29 | .. code-block:: python 30 | 31 | context = ssl.create_default_context() 32 | wrapper = functools.partial(context.wrap_socket, server_hostname=server_address, ssl_cert=get_cert()) 33 | Factory(wrapper=wrapper)(server_address) 34 | 35 | To create an IPv6 connection: 36 | 37 | .. code-block:: python 38 | 39 | Factory(ipv6=True)(server_address) 40 | 41 | Note that Factory doesn't save the state of the socket itself. The 42 | caller must do that, as necessary. As a result, the Factory may be 43 | re-used to create new connections with the same settings. 44 | 45 | """ 46 | 47 | family = socket.AF_INET 48 | 49 | def __init__(self, bind_address=None, wrapper=identity, ipv6=False): 50 | self.bind_address = bind_address 51 | self.wrapper = wrapper 52 | if ipv6: 53 | self.family = socket.AF_INET6 54 | 55 | def connect(self, server_address): 56 | sock = self.wrapper(socket.socket(self.family, socket.SOCK_STREAM)) 57 | self.bind_address and sock.bind(self.bind_address) 58 | sock.connect(server_address) 59 | return sock 60 | 61 | __call__ = connect 62 | 63 | 64 | class AioFactory: 65 | """ 66 | A class for creating async custom socket connections. 67 | 68 | To create a simple connection: 69 | 70 | .. code-block:: python 71 | 72 | server_address = ('localhost', 80) 73 | Factory()(protocol_instance, server_address) 74 | 75 | To create an SSL connection: 76 | 77 | .. code-block:: python 78 | 79 | Factory(ssl=True)(protocol_instance, server_address) 80 | 81 | To create an IPv6 connection: 82 | 83 | .. code-block:: python 84 | 85 | Factory(ipv6=True)(protocol_instance, server_address) 86 | 87 | Note that Factory doesn't save the state of the socket itself. The 88 | caller must do that, as necessary. As a result, the Factory may be 89 | re-used to create new connections with the same settings. 90 | 91 | """ 92 | 93 | def __init__(self, **kwargs): 94 | self.connection_args = kwargs 95 | 96 | def connect(self, protocol_instance, server_address): 97 | return protocol_instance.loop.create_connection( 98 | lambda: protocol_instance, *server_address, **self.connection_args 99 | ) 100 | 101 | __call__ = connect 102 | -------------------------------------------------------------------------------- /irc/ctcp.py: -------------------------------------------------------------------------------- 1 | """ 2 | Handle Client-to-Client protocol per the `best available 3 | spec `_. 4 | """ 5 | 6 | import re 7 | 8 | LOW_LEVEL_QUOTE = '\x10' 9 | LEVEL_QUOTE = "\\" 10 | DELIMITER = '\x01' 11 | 12 | low_level_mapping = {"0": '\x00', "n": "\n", "r": "\r", LEVEL_QUOTE: LEVEL_QUOTE} 13 | 14 | low_level_regexp = re.compile(LOW_LEVEL_QUOTE + "(.)") 15 | 16 | 17 | def _low_level_replace(match_obj): 18 | ch = match_obj.group(1) 19 | 20 | # If low_level_mapping doesn't have the character as key, we 21 | # should just return the character. 22 | return low_level_mapping.get(ch, ch) 23 | 24 | 25 | def dequote(message): 26 | """ 27 | Dequote a message according to CTCP specifications. 28 | 29 | The function returns a list where each element can be either a 30 | string (normal message) or a tuple of one or two strings (tagged 31 | messages). If a tuple has only one element (ie is a singleton), 32 | that element is the tag; otherwise the tuple has two elements: the 33 | tag and the data. 34 | 35 | Arguments: 36 | 37 | message -- The message to be decoded. 38 | """ 39 | 40 | # Perform the substitution 41 | message = low_level_regexp.sub(_low_level_replace, message) 42 | 43 | if DELIMITER not in message: 44 | return [message] 45 | 46 | # Split it into parts. 47 | chunks = message.split(DELIMITER) 48 | 49 | return list(_gen_messages(chunks)) 50 | 51 | 52 | def _gen_messages(chunks): 53 | i = 0 54 | while i < len(chunks) - 1: 55 | # Add message if it's non-empty. 56 | if len(chunks[i]) > 0: 57 | yield chunks[i] 58 | 59 | if i < len(chunks) - 2: 60 | # Aye! CTCP tagged data ahead! 61 | yield tuple(chunks[i + 1].split(" ", 1)) 62 | 63 | i = i + 2 64 | 65 | if len(chunks) % 2 == 0: 66 | # Hey, a lonely _CTCP_DELIMITER at the end! This means 67 | # that the last chunk, including the delimiter, is a 68 | # normal message! (This is according to the CTCP 69 | # specification.) 70 | yield DELIMITER + chunks[-1] 71 | -------------------------------------------------------------------------------- /irc/dict.py: -------------------------------------------------------------------------------- 1 | from jaraco.collections import KeyTransformingDict 2 | 3 | from . import strings 4 | 5 | 6 | class IRCDict(KeyTransformingDict): 7 | """ 8 | A dictionary of names whose keys are case-insensitive according to the 9 | IRC RFC rules. 10 | 11 | >>> d = IRCDict({'[This]': 'that'}, A='foo') 12 | 13 | The dict maintains the original case: 14 | 15 | >>> '[This]' in ''.join(d.keys()) 16 | True 17 | 18 | But the keys can be referenced with a different case 19 | 20 | >>> d['a'] == 'foo' 21 | True 22 | 23 | >>> d['{this}'] == 'that' 24 | True 25 | 26 | >>> d['{THIS}'] == 'that' 27 | True 28 | 29 | >>> '{thiS]' in d 30 | True 31 | 32 | This should work for operations like delete and pop as well. 33 | 34 | >>> d.pop('A') == 'foo' 35 | True 36 | >>> del d['{This}'] 37 | >>> len(d) 38 | 0 39 | """ 40 | 41 | @staticmethod 42 | def transform_key(key): 43 | if isinstance(key, str): 44 | key = strings.IRCFoldedCase(key) 45 | return key 46 | -------------------------------------------------------------------------------- /irc/events.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | import sys 3 | 4 | from jaraco.text import clean, drop_comment, lines_from 5 | 6 | if sys.version_info >= (3, 12): 7 | from importlib.resources import files 8 | else: 9 | from importlib_resources import files 10 | 11 | 12 | class Command(str): 13 | def __new__(cls, code, name): 14 | return super().__new__(cls, name) 15 | 16 | def __init__(self, code, name): 17 | self.code = code 18 | 19 | def __int__(self): 20 | return int(self.code) 21 | 22 | @staticmethod 23 | def lookup(raw) -> 'Command': 24 | """ 25 | Lookup a command by numeric or by name. 26 | 27 | >>> Command.lookup('002') 28 | 'yourhost' 29 | >>> Command.lookup('002').code 30 | '002' 31 | >>> int(Command.lookup('002')) 32 | 2 33 | >>> int(Command.lookup('yourhost')) 34 | 2 35 | >>> Command.lookup('yourhost').code 36 | '002' 37 | 38 | If a command is supplied that's an unrecognized name or code, 39 | a Command object is still returned. 40 | >>> fallback = Command.lookup('Unknown-command') 41 | >>> fallback 42 | 'unknown-command' 43 | >>> fallback.code 44 | 'unknown-command' 45 | >>> int(fallback) 46 | Traceback (most recent call last): 47 | ... 48 | ValueError: invalid literal for int() with base 10: 'unknown-command' 49 | >>> fallback = Command.lookup('999') 50 | >>> fallback 51 | '999' 52 | >>> int(fallback) 53 | 999 54 | """ 55 | fallback = Command(raw.lower(), raw.lower()) 56 | return numeric.get(raw, _by_name.get(raw.lower(), fallback)) 57 | 58 | 59 | _codes = itertools.starmap( 60 | Command, 61 | map( 62 | str.split, 63 | map(drop_comment, clean(lines_from(files().joinpath('codes.txt')))), 64 | ), 65 | ) 66 | 67 | 68 | numeric = {code.code: code for code in _codes} 69 | 70 | codes = {v: k for k, v in numeric.items()} 71 | 72 | _by_name = {v: v for v in numeric.values()} 73 | 74 | generated = [ 75 | "dcc_connect", 76 | "dcc_disconnect", 77 | "dccmsg", 78 | "disconnect", 79 | "ctcp", 80 | "ctcpreply", 81 | "login_failed", 82 | ] 83 | 84 | protocol = [ 85 | "error", 86 | "join", 87 | "kick", 88 | "mode", 89 | "part", 90 | "ping", 91 | "privmsg", 92 | "privnotice", 93 | "pubmsg", 94 | "pubnotice", 95 | "quit", 96 | "invite", 97 | "pong", 98 | "action", 99 | "topic", 100 | "nick", 101 | ] 102 | 103 | all = generated + protocol + list(numeric.values()) 104 | -------------------------------------------------------------------------------- /irc/features.py: -------------------------------------------------------------------------------- 1 | import collections 2 | 3 | 4 | class FeatureSet: 5 | """ 6 | An implementation of features as loaded from an ISUPPORT server directive. 7 | 8 | Each feature is loaded into an attribute of the same name (but lowercased 9 | to match Python sensibilities). 10 | 11 | >>> f = FeatureSet() 12 | >>> f.load(['target', 'PREFIX=(abc)+-/', 'your message sir']) 13 | >>> f.prefix == {'+': 'a', '-': 'b', '/': 'c'} 14 | True 15 | 16 | Order of prefix is relevant, so it is retained. 17 | 18 | >>> tuple(f.prefix) 19 | ('+', '-', '/') 20 | 21 | >>> f.load_feature('CHANMODES=foo,bar,baz') 22 | >>> f.chanmodes 23 | ['foo', 'bar', 'baz'] 24 | """ 25 | 26 | def __init__(self): 27 | self._set_rfc1459_prefixes() 28 | 29 | def _set_rfc1459_prefixes(self): 30 | "install standard (RFC1459) prefixes" 31 | self.set('PREFIX', {'@': 'o', '+': 'v'}) 32 | 33 | def set(self, name, value=True): 34 | "set a feature value" 35 | setattr(self, name.lower(), value) 36 | 37 | def remove(self, feature_name): 38 | if feature_name in vars(self): 39 | delattr(self, feature_name) 40 | 41 | def load(self, arguments): 42 | "Load the values from the a ServerConnection arguments" 43 | features = arguments[1:-1] 44 | list(map(self.load_feature, features)) 45 | 46 | def load_feature(self, feature): 47 | # negating 48 | if feature[0] == '-': 49 | return self.remove(feature[1:].lower()) 50 | 51 | name, sep, value = feature.partition('=') 52 | 53 | if not sep: 54 | return 55 | 56 | if not value: 57 | self.set(name) 58 | return 59 | 60 | parser = getattr(self, '_parse_' + name, self._parse_other) 61 | value = parser(value) 62 | self.set(name, value) 63 | 64 | @staticmethod 65 | def _parse_PREFIX(value): 66 | "channel user prefixes" 67 | channel_modes, channel_chars = value.split(')') 68 | channel_modes = channel_modes[1:] 69 | return collections.OrderedDict(zip(channel_chars, channel_modes)) 70 | 71 | @staticmethod 72 | def _parse_CHANMODES(value): 73 | "channel mode letters" 74 | return value.split(',') 75 | 76 | @staticmethod 77 | def _parse_TARGMAX(value): 78 | """ 79 | >>> res = FeatureSet._parse_TARGMAX('a:3,c:,b:2') 80 | >>> res['a'] 81 | 3 82 | """ 83 | return dict(string_int_pair(target, ':') for target in value.split(',')) 84 | 85 | @staticmethod 86 | def _parse_CHANLIMIT(value): 87 | """ 88 | >>> res = FeatureSet._parse_CHANLIMIT('ibe:250,xyz:100') 89 | >>> len(res) 90 | 6 91 | >>> res['x'] 92 | 100 93 | >>> res['i'] == res['b'] == res['e'] == 250 94 | True 95 | """ 96 | pairs = map(string_int_pair, value.split(',')) 97 | return { 98 | target: number for target_keys, number in pairs for target in target_keys 99 | } 100 | 101 | _parse_MAXLIST = _parse_CHANLIMIT 102 | 103 | @staticmethod 104 | def _parse_other(value): 105 | if value.isdigit(): 106 | return int(value) 107 | return value 108 | 109 | 110 | def string_int_pair(target, sep=':'): 111 | name, value = target.split(sep) 112 | value = int(value) if value else None 113 | return name, value 114 | -------------------------------------------------------------------------------- /irc/message.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | _TAG_UNESCAPE_RE = re.compile(r'\\(.)') 4 | _TAG_UNESCAPE_MAP = {':': ';', 's': ' ', 'n': '\n', 'r': '\r', '\\': '\\'} 5 | 6 | 7 | class Tag: 8 | """ 9 | An IRC message tag ircv3.net/specs/core/message-tags-3.2.html 10 | """ 11 | 12 | @staticmethod 13 | def parse(item): 14 | r""" 15 | >>> Tag.parse('x') == {'key': 'x', 'value': None} 16 | True 17 | 18 | >>> Tag.parse('x=yes') == {'key': 'x', 'value': 'yes'} 19 | True 20 | 21 | >>> Tag.parse('x=3')['value'] 22 | '3' 23 | 24 | >>> Tag.parse('x=red fox\\:green eggs')['value'] 25 | 'red fox;green eggs' 26 | 27 | >>> Tag.parse('x=red fox:green eggs')['value'] 28 | 'red fox:green eggs' 29 | 30 | >>> Tag.parse('x=a\\nb\\nc')['value'] 31 | 'a\nb\nc' 32 | 33 | >>> Tag.parse(r'x=a\bb\bc')['value'] 34 | 'abbbc' 35 | 36 | >>> Tag.parse(r'x=a\\nb\\nc')['value'] 37 | 'a\\nb\\nc' 38 | """ 39 | key, sep, value = item.partition('=') 40 | value = _TAG_UNESCAPE_RE.sub( 41 | lambda char: _TAG_UNESCAPE_MAP.get(char.group(1), char.group(1)), value 42 | ) 43 | value = value or None 44 | return {'key': key, 'value': value} 45 | 46 | @classmethod 47 | def from_group(cls, group): 48 | """ 49 | Construct tags from the regex group 50 | """ 51 | if not group: 52 | return 53 | tag_items = group.split(";") 54 | return list(map(cls.parse, tag_items)) 55 | 56 | 57 | class Arguments(list): 58 | @staticmethod 59 | def from_group(group): 60 | """ 61 | Construct arguments from the regex group 62 | 63 | >>> Arguments.from_group('foo') 64 | ['foo'] 65 | 66 | >>> Arguments.from_group(None) 67 | [] 68 | 69 | >>> Arguments.from_group('') 70 | [] 71 | 72 | >>> Arguments.from_group('foo bar') 73 | ['foo', 'bar'] 74 | 75 | >>> Arguments.from_group('foo bar :baz') 76 | ['foo', 'bar', 'baz'] 77 | 78 | >>> Arguments.from_group('foo bar :baz bing') 79 | ['foo', 'bar', 'baz bing'] 80 | """ 81 | if not group: 82 | return [] 83 | 84 | main, sep, ext = group.partition(" :") 85 | arguments = main.split() 86 | if sep: 87 | arguments.append(ext) 88 | 89 | return arguments 90 | -------------------------------------------------------------------------------- /irc/modes.py: -------------------------------------------------------------------------------- 1 | def parse_nick_modes(mode_string): 2 | """Parse a nick mode string. 3 | 4 | The function returns a list of lists with three members: sign, 5 | mode and argument. The sign is "+" or "-". The argument is 6 | always None. 7 | 8 | Example: 9 | 10 | >>> parse_nick_modes("+ab-c") 11 | [['+', 'a', None], ['+', 'b', None], ['-', 'c', None]] 12 | """ 13 | 14 | return _parse_modes(mode_string, "") 15 | 16 | 17 | def parse_channel_modes(mode_string): 18 | """Parse a channel mode string. 19 | 20 | The function returns a list of lists with three members: sign, 21 | mode and argument. The sign is "+" or "-". The argument is 22 | None if mode isn't one of "b", "k", "l", "v", "o", "h", or "q". 23 | 24 | Example: 25 | 26 | >>> parse_channel_modes("+ab-c foo") 27 | [['+', 'a', None], ['+', 'b', 'foo'], ['-', 'c', None]] 28 | """ 29 | return _parse_modes(mode_string, "bklvohq") 30 | 31 | 32 | def _parse_modes(mode_string, unary_modes=""): 33 | """ 34 | Parse the mode_string and return a list of triples. 35 | 36 | If no string is supplied return an empty list. 37 | 38 | >>> _parse_modes('') 39 | [] 40 | 41 | If no sign is supplied, return an empty list. 42 | 43 | >>> _parse_modes('ab') 44 | [] 45 | 46 | Discard unused args. 47 | 48 | >>> _parse_modes('+a foo bar baz') 49 | [['+', 'a', None]] 50 | 51 | Return none for unary args when not provided 52 | 53 | >>> _parse_modes('+abc foo', unary_modes='abc') 54 | [['+', 'a', 'foo'], ['+', 'b', None], ['+', 'c', None]] 55 | 56 | This function never throws an error: 57 | 58 | >>> import random 59 | >>> def random_text(min_len = 3, max_len = 80): 60 | ... len = random.randint(min_len, max_len) 61 | ... chars_to_choose = [chr(x) for x in range(0,1024)] 62 | ... chars = (random.choice(chars_to_choose) for x in range(len)) 63 | ... return ''.join(chars) 64 | >>> def random_texts(min_len = 3, max_len = 80): 65 | ... while True: 66 | ... yield random_text(min_len, max_len) 67 | >>> import itertools 68 | >>> texts = itertools.islice(random_texts(), 1000) 69 | >>> set(type(_parse_modes(text)) for text in texts) == {list} 70 | True 71 | """ 72 | 73 | # mode_string must be non-empty and begin with a sign 74 | if not mode_string or mode_string[0] not in "+-": 75 | return [] 76 | 77 | modes = [] 78 | 79 | parts = mode_string.split() 80 | 81 | mode_part, args = parts[0], parts[1:] 82 | 83 | for ch in mode_part: 84 | if ch in "+-": 85 | sign = ch 86 | continue 87 | arg = args.pop(0) if ch in unary_modes and args else None 88 | modes.append([sign, ch, arg]) 89 | return modes 90 | -------------------------------------------------------------------------------- /irc/rfc.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | 4 | def get_pages(filename): 5 | with open(filename) as f: 6 | data = f.read() 7 | return data.split('\x0c') 8 | 9 | 10 | header_pattern = re.compile(r'^RFC \d+\s+.*\s+(\w+ \d{4})$', re.M) 11 | footer_pattern = re.compile(r'^\w+\s+\w+\s+\[Page \d+\]$', re.M) 12 | 13 | 14 | def remove_header(page): 15 | page = header_pattern.sub('', page) 16 | return page.lstrip('\n') 17 | 18 | 19 | def remove_footer(page): 20 | page = footer_pattern.sub('', page) 21 | return page.rstrip() + '\n\n' 22 | 23 | 24 | def clean_pages(): 25 | return map(remove_header, map(remove_footer, get_pages('rfc2812.txt'))) 26 | 27 | 28 | def save_clean(): 29 | with open('rfc2812-clean.txt', 'w') as f: 30 | map(f.write, clean_pages()) 31 | 32 | 33 | if __name__ == '__main__': 34 | save_clean() 35 | -------------------------------------------------------------------------------- /irc/schedule.py: -------------------------------------------------------------------------------- 1 | import abc 2 | 3 | from tempora import schedule 4 | 5 | 6 | class IScheduler(metaclass=abc.ABCMeta): 7 | @abc.abstractmethod 8 | def execute_every(self, period, func): 9 | "execute func every period" 10 | 11 | @abc.abstractmethod 12 | def execute_at(self, when, func): 13 | "execute func at when" 14 | 15 | @abc.abstractmethod 16 | def execute_after(self, delay, func): 17 | "execute func after delay" 18 | 19 | @abc.abstractmethod 20 | def run_pending(self): 21 | "invoke the functions that are due" 22 | 23 | 24 | class DefaultScheduler(schedule.InvokeScheduler, IScheduler): 25 | def execute_every(self, period, func): 26 | """ 27 | Executes `func` every `period`. 28 | 29 | :param `func`: function to execute 30 | :param `period`: `int` in seconds, or `datetime.timedelta` 31 | """ 32 | self.add(schedule.PeriodicCommand.after(period, func)) 33 | 34 | def execute_at(self, when, func): 35 | self.add(schedule.DelayedCommand.at_time(when, func)) 36 | 37 | def execute_after(self, delay, func): 38 | self.add(schedule.DelayedCommand.after(delay, func)) 39 | -------------------------------------------------------------------------------- /irc/server.py: -------------------------------------------------------------------------------- 1 | """ 2 | irc.server 3 | 4 | This server has basic support for: 5 | 6 | * Connecting 7 | * Channels 8 | * Nicknames 9 | * Public/private messages 10 | 11 | It is MISSING support for notably: 12 | 13 | * Server linking 14 | * Modes (user and channel) 15 | * Proper error reporting 16 | * Basically everything else 17 | 18 | It is mostly useful as a testing tool or perhaps for building something like a 19 | private proxy on. Do NOT use it in any kind of production code or anything that 20 | will ever be connected to by the public. 21 | 22 | """ 23 | 24 | # 25 | # Very simple hacky ugly IRC server. 26 | # 27 | # Todo: 28 | # - Encode format for each message and reply with 29 | # events.codes['needmoreparams'] 30 | # - starting server when already started doesn't work properly. PID 31 | # file is not changed, no error messsage is displayed. 32 | # - Delete channel if last user leaves. 33 | # - [ERROR] 34 | # (better error msg required) 35 | # - Empty channels are left behind 36 | # - No Op assigned when new channel is created. 37 | # - User can /join multiple times (doesn't add more to channel, 38 | # does say 'joined') 39 | # - PING timeouts 40 | # - Allow all numerical commands. 41 | # - Users can send commands to channels they are not in (PART) 42 | # Not Todo (Won't be supported) 43 | # - Server linking. 44 | 45 | import argparse 46 | import errno 47 | import logging 48 | import re 49 | import select 50 | import socketserver 51 | import typing 52 | 53 | import jaraco.logging 54 | from jaraco.stream import buffer 55 | 56 | import irc.client 57 | 58 | from . import events 59 | 60 | log = logging.getLogger(__name__) 61 | 62 | 63 | class IRCError(Exception): 64 | """ 65 | Exception thrown by IRC command handlers to notify client of a 66 | server/client error. 67 | """ 68 | 69 | def __init__(self, code, value): 70 | self.code = code 71 | self.value = value 72 | 73 | def __str__(self): 74 | return repr(self.value) 75 | 76 | @classmethod 77 | def from_name(cls, name, value): 78 | return cls(events.codes[name], value) 79 | 80 | 81 | class IRCChannel: 82 | """ 83 | An IRC channel. 84 | """ 85 | 86 | def __init__(self, name, topic='No topic'): 87 | self.name = name 88 | self.topic_by = 'Unknown' 89 | self.topic = topic 90 | self.clients = set() 91 | 92 | 93 | class IRCClient(socketserver.BaseRequestHandler): 94 | """ 95 | IRC client connect and command handling. Client connection is handled by 96 | the ``handle`` method which sets up a two-way communication 97 | with the client. 98 | It then handles commands sent by the client by dispatching them to the 99 | ``handle_`` methods. 100 | """ 101 | 102 | class Disconnect(BaseException): 103 | pass 104 | 105 | def __init__(self, request, client_address, server): 106 | self.user = None 107 | self.host = client_address # Client's hostname / ip. 108 | self.realname = None # Client's real name 109 | self.nick = None # Client's currently registered nickname 110 | self.send_queue = [] # Messages to send to client (strings) 111 | self.channels = {} # Channels the client is in 112 | 113 | super().__init__(request, client_address, server) 114 | 115 | def handle(self): 116 | log.info('Client connected: %s', self.client_ident()) 117 | self.buffer = buffer.LineBuffer() 118 | 119 | try: 120 | while True: 121 | self._handle_one() 122 | except self.Disconnect: 123 | self.request.close() 124 | 125 | def _handle_one(self): 126 | """ 127 | Handle one read/write cycle. 128 | """ 129 | ready_to_read, ready_to_write, in_error = select.select( 130 | [self.request], [self.request], [self.request], 0.1 131 | ) 132 | 133 | if in_error: 134 | raise self.Disconnect() 135 | 136 | # Write any commands to the client 137 | while self.send_queue and ready_to_write: 138 | msg = self.send_queue.pop(0) 139 | self._send(msg) 140 | 141 | # See if the client has any commands for us. 142 | if ready_to_read: 143 | self._handle_incoming() 144 | 145 | def _handle_incoming(self): 146 | try: 147 | data = self.request.recv(1024) 148 | except Exception as err: 149 | raise self.Disconnect() from err 150 | 151 | if not data: 152 | raise self.Disconnect() 153 | 154 | self.buffer.feed(data) 155 | for line in self.buffer: 156 | line = line.decode('utf-8') 157 | self._handle_line(line) 158 | 159 | def _handle_line(self, line): 160 | response = None 161 | 162 | try: 163 | log.debug(f'from {self.client_ident()}: {line}') 164 | command, sep, params = line.partition(' ') 165 | handler = getattr(self, f'handle_{command.lower()}', None) 166 | if not handler: 167 | log.info(f'No handler for command: {command}. Full line: {line}') 168 | raise IRCError.from_name( 169 | 'unknowncommand', f'{command} :Unknown command' 170 | ) 171 | response = handler(params) 172 | except AttributeError as e: 173 | log.error(str(e)) 174 | raise 175 | except IRCError as e: 176 | response = f':{self.server.servername} {e.code} {e.value}' 177 | log.warning(response) 178 | except Exception as e: 179 | response = f':{self.server.servername} ERROR {e!r}' 180 | log.error(response) 181 | raise 182 | 183 | if response: 184 | self._send(response) 185 | 186 | def _send(self, msg): 187 | log.debug('to %s: %s', self.client_ident(), msg) 188 | try: 189 | self.request.send(msg.encode('utf-8') + b'\r\n') 190 | except OSError as e: 191 | if e.errno == errno.EPIPE: 192 | raise self.Disconnect() from e 193 | else: 194 | raise 195 | 196 | def handle_nick(self, params): 197 | """ 198 | Handle the initial setting of the user's nickname and nick changes. 199 | """ 200 | nick = params 201 | 202 | # Valid nickname? 203 | if re.search(r'[^a-zA-Z0-9\-\[\]\'`^{}_]', nick): 204 | raise IRCError.from_name('erroneusnickname', f':{nick}') 205 | 206 | if self.server.clients.get(nick, None) == self: 207 | # Already registered to user 208 | return 209 | 210 | if nick in self.server.clients: 211 | # Someone else is using the nick 212 | raise IRCError.from_name('nicknameinuse', f'NICK :{nick}') 213 | 214 | if not self.nick: 215 | # New connection and nick is available; register and send welcome 216 | # and MOTD. 217 | self.nick = nick 218 | self.server.clients[nick] = self 219 | msg = f"Welcome to {__name__} v{irc._get_version()}." 220 | response = ':{} {} {} :{}'.format( 221 | self.server.servername, 222 | events.codes['welcome'], 223 | self.nick, 224 | msg, 225 | ) 226 | self.send_queue.append(response) 227 | response = ( 228 | f':{self.server.servername} 376 {self.nick} :End of MOTD command.' 229 | ) 230 | self.send_queue.append(response) 231 | return 232 | 233 | # Nick is available. Change the nick. 234 | message = f':{self.client_ident()} NICK :{nick}' 235 | 236 | self.server.clients.pop(self.nick) 237 | self.nick = nick 238 | self.server.clients[self.nick] = self 239 | 240 | # Send a notification of the nick change to all the clients in the 241 | # channels the client is in. 242 | for channel in self.channels.values(): 243 | self._send_to_others(message, channel) 244 | 245 | # Send a notification of the nick change to the client itself 246 | return message 247 | 248 | def handle_user(self, params): 249 | """ 250 | Handle the USER command which identifies the user to the server. 251 | """ 252 | params = params.split(' ', 3) 253 | 254 | if len(params) != 4: 255 | raise IRCError.from_name('needmoreparams', 'USER :Not enough parameters') 256 | 257 | user, mode, unused, realname = params 258 | self.user = user 259 | self.mode = mode 260 | self.realname = realname 261 | return '' 262 | 263 | def handle_ping(self, params): 264 | """ 265 | Handle client PING requests to keep the connection alive. 266 | """ 267 | response = ':{self.server.servername} PONG :{self.server.servername}' 268 | return response.format(**locals()) 269 | 270 | def handle_join(self, params): 271 | """ 272 | Handle the JOINing of a user to a channel. Valid channel names start 273 | with a # and consist of a-z, A-Z, 0-9 and/or '_'. 274 | """ 275 | channel_names = params.split(' ', 1)[0] # Ignore keys 276 | for channel_name in channel_names.split(','): 277 | r_channel_name = channel_name.strip() 278 | 279 | # Valid channel name? 280 | if not re.match('^#([a-zA-Z0-9_])+$', r_channel_name): 281 | raise IRCError.from_name( 282 | 'nosuchchannel', f'{r_channel_name} :No such channel' 283 | ) 284 | 285 | # Add user to the channel (create new channel if not exists) 286 | channel = self.server.channels.setdefault( 287 | r_channel_name, IRCChannel(r_channel_name) 288 | ) 289 | channel.clients.add(self) 290 | 291 | # Add channel to user's channel list 292 | self.channels[channel.name] = channel 293 | 294 | # Send the topic 295 | response_join = f':{channel.topic_by} TOPIC {channel.name} :{channel.topic}' 296 | self.send_queue.append(response_join) 297 | 298 | # Send join message to everybody in the channel, including yourself 299 | # and send user list of the channel back to the user. 300 | response_join = f':{self.client_ident()} JOIN :{r_channel_name}' 301 | for client in channel.clients: 302 | client.send_queue.append(response_join) 303 | 304 | nicks = [client.nick for client in channel.clients] 305 | response_userlist = f':{self.server.servername} 353 {self.nick} = {channel.name} :{" ".join(nicks)}' 306 | self.send_queue.append(response_userlist) 307 | 308 | response = f':{self.server.servername} 366 {self.nick} {channel.name} :End of /NAMES list' 309 | self.send_queue.append(response) 310 | 311 | def handle_privmsg(self, params): 312 | """ 313 | Handle sending a private message to a user or channel. 314 | """ 315 | self._send_msg('PRIVMSG', params) 316 | 317 | def handle_notice(self, params): 318 | """ 319 | Handle sending a notice to a user or channel. 320 | """ 321 | self._send_msg('NOTICE', params) 322 | 323 | def _send_msg(self, cmd, params): 324 | """ 325 | A generic message handler (e.g. PRIVMSG and NOTICE) 326 | """ 327 | target, sep, msg = params.partition(' ') 328 | if not msg: 329 | raise IRCError.from_name('needmoreparams', cmd + ' :Not enough parameters') 330 | 331 | message = f':{self.client_ident()} {cmd} {target} {msg}' 332 | if target.startswith('#') or target.startswith('$'): 333 | # Message to channel. Check if the channel exists. 334 | channel = self.server.channels.get(target) 335 | if not channel: 336 | raise IRCError.from_name('nosuchnick', cmd + f' :{target}') 337 | 338 | if channel.name not in self.channels: 339 | # The user isn't in the channel. 340 | raise IRCError.from_name( 341 | 'cannotsendtochan', f'{channel.name} :Cannot send to channel' 342 | ) 343 | 344 | self._send_to_others(message, channel) 345 | else: 346 | # Message to user 347 | client = self.server.clients.get(target, None) 348 | if not client: 349 | raise IRCError.from_name('nosuchnick', cmd + f' :{target}') 350 | 351 | client.send_queue.append(message) 352 | 353 | def _send_to_others(self, message, channel): 354 | """ 355 | Send the message to all clients in the specified channel except for 356 | self. 357 | """ 358 | other_clients = [client for client in channel.clients if not client == self] 359 | for client in other_clients: 360 | client.send_queue.append(message) 361 | 362 | def handle_topic(self, params): 363 | """ 364 | Handle a topic command. 365 | """ 366 | channel_name, sep, topic = params.partition(' ') 367 | 368 | channel = self.server.channels.get(channel_name) 369 | if not channel: 370 | raise IRCError.from_name('nosuchnick', f'PRIVMSG :{channel_name}') 371 | if channel.name not in self.channels: 372 | # The user isn't in the channel. 373 | raise IRCError.from_name( 374 | 'cannotsendtochan', f'{channel.name} :Cannot send to channel' 375 | ) 376 | 377 | if topic: 378 | channel.topic = topic.lstrip(':') 379 | channel.topic_by = self.nick 380 | message = f':{self.client_ident()} TOPIC {channel_name} :{channel.topic}' 381 | return message 382 | 383 | def handle_part(self, params): 384 | """ 385 | Handle a client parting from channel(s). 386 | """ 387 | for pchannel in params.split(','): 388 | if pchannel.strip() in self.server.channels: 389 | # Send message to all clients in all channels user is in, and 390 | # remove the user from the channels. 391 | channel = self.server.channels.get(pchannel.strip()) 392 | response = f':{self.client_ident()} PART :{pchannel}' 393 | if channel: 394 | for client in channel.clients: 395 | client.send_queue.append(response) 396 | channel.clients.remove(self) 397 | self.channels.pop(pchannel) 398 | else: 399 | response = f':{self.server.servername} 403 {pchannel} :{pchannel}' 400 | self.send_queue.append(response) 401 | 402 | def handle_quit(self, params): 403 | """ 404 | Handle the client breaking off the connection with a QUIT command. 405 | """ 406 | response = ':{} QUIT :{}'.format(self.client_ident(), params.lstrip(':')) 407 | # Send quit message to all clients in all channels user is in, and 408 | # remove the user from the channels. 409 | for channel in self.channels.values(): 410 | for client in channel.clients: 411 | client.send_queue.append(response) 412 | channel.clients.remove(self) 413 | 414 | def handle_dump(self, params): 415 | """ 416 | Dump internal server information for debugging purposes. 417 | """ 418 | print("Clients:", self.server.clients) 419 | for client in self.server.clients.values(): 420 | print(" ", client) 421 | for channel in client.channels.values(): 422 | print(" ", channel.name) 423 | print("Channels:", self.server.channels) 424 | for channel in self.server.channels.values(): 425 | print(" ", channel.name, channel) 426 | for client in channel.clients: 427 | print(" ", client.nick, client) 428 | 429 | def handle_ison(self, params): 430 | response = f':{self.server.servername} 303 {self.client_ident().nick} :' 431 | if len(params) == 0 or params.isspace(): 432 | response = f':{self.server.servername} 461 {self.client_ident().nick} ISON :Not enough parameters' 433 | return response 434 | nickOnline = [nick for nick in params.split(" ") if nick in self.server.clients] 435 | response += ' '.join(nickOnline) 436 | return response 437 | 438 | def client_ident(self): 439 | """ 440 | Return the client identifier as included in many command replies. 441 | """ 442 | return irc.client.NickMask.from_params( 443 | self.nick, self.user, self.server.servername 444 | ) 445 | 446 | def finish(self): 447 | """ 448 | The client conection is finished. Do some cleanup to ensure that the 449 | client doesn't linger around in any channel or the client list, in case 450 | the client didn't properly close the connection with PART and QUIT. 451 | """ 452 | log.info('Client disconnected: %s', self.client_ident()) 453 | response = f':{self.client_ident()} QUIT :EOF from client' 454 | for channel in self.channels.values(): 455 | if self in channel.clients: 456 | # Client is gone without properly QUITing or PARTing this 457 | # channel. 458 | for client in channel.clients: 459 | client.send_queue.append(response) 460 | channel.clients.remove(self) 461 | if self.nick: 462 | self.server.clients.pop(self.nick) 463 | log.info('Connection finished: %s', self.client_ident()) 464 | 465 | def __repr__(self): 466 | """ 467 | Return a user-readable description of the client 468 | """ 469 | return f'<{self.__class__.__name__} {self.nick}!{self.user}@{self.host[0]} ({self.realname})>' 470 | 471 | 472 | class IRCServer(socketserver.ThreadingMixIn, socketserver.TCPServer): 473 | daemon_threads = True 474 | allow_reuse_address = True 475 | 476 | channels: typing.Dict[str, IRCChannel] = {} 477 | "Existing channels by channel name" 478 | 479 | clients: typing.Dict[str, IRCClient] = {} 480 | "Connected clients by nick name" 481 | 482 | def __init__(self, *args, **kwargs): 483 | self.servername = 'localhost' 484 | self.channels = {} 485 | self.clients = {} 486 | 487 | super().__init__(*args, **kwargs) 488 | 489 | 490 | def get_args(): 491 | parser = argparse.ArgumentParser() 492 | 493 | parser.add_argument( 494 | "-a", 495 | "--address", 496 | dest="listen_address", 497 | default='127.0.0.1', 498 | help="IP on which to listen", 499 | ) 500 | parser.add_argument( 501 | "-p", 502 | "--port", 503 | dest="listen_port", 504 | default=6667, 505 | type=int, 506 | help="Port on which to listen", 507 | ) 508 | jaraco.logging.add_arguments(parser) 509 | 510 | return parser.parse_args() 511 | 512 | 513 | def main(): 514 | options = get_args() 515 | jaraco.logging.setup(options) 516 | 517 | log.info("Starting irc.server") 518 | 519 | try: 520 | bind_address = options.listen_address, options.listen_port 521 | ircserver = IRCServer(bind_address, IRCClient) 522 | _tmpl = 'Listening on {listen_address}:{listen_port}' 523 | log.info(_tmpl.format(**vars(options))) 524 | ircserver.serve_forever() 525 | except OSError as e: 526 | log.error(repr(e)) 527 | raise SystemExit(-2) from None 528 | 529 | 530 | if __name__ == "__main__": 531 | main() 532 | -------------------------------------------------------------------------------- /irc/strings.py: -------------------------------------------------------------------------------- 1 | from jaraco.text import FoldedCase 2 | 3 | 4 | class IRCFoldedCase(FoldedCase): 5 | """ 6 | A version of FoldedCase that honors the IRC specification for lowercased 7 | strings (RFC 1459). 8 | 9 | >>> IRCFoldedCase('Foo^').lower() 10 | 'foo~' 11 | 12 | >>> IRCFoldedCase('[this]') == IRCFoldedCase('{THIS}') 13 | True 14 | 15 | >>> IRCFoldedCase('[This]').casefold() 16 | '{this}' 17 | 18 | >>> IRCFoldedCase().lower() 19 | '' 20 | """ 21 | 22 | translation = dict( 23 | zip( 24 | map(ord, r"[]\^"), 25 | map(ord, r"{}|~"), 26 | ) 27 | ) 28 | 29 | def lower(self): 30 | return super().lower().translate(self.translation) 31 | 32 | def casefold(self): 33 | """ 34 | Ensure cached superclass value doesn't supersede. 35 | 36 | >>> ob = IRCFoldedCase('[This]') 37 | >>> ob.casefold() 38 | '{this}' 39 | >>> ob.casefold() 40 | '{this}' 41 | """ 42 | return super().casefold().translate(self.translation) 43 | 44 | def __setattr__(self, key, val): 45 | if key == 'casefold': 46 | return 47 | return super().__setattr__(key, val) 48 | 49 | 50 | def lower(str): 51 | return IRCFoldedCase(str).lower() 52 | -------------------------------------------------------------------------------- /irc/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jaraco/irc/520db56a3870cf1dce52b5a498fb0a16f74a6300/irc/tests/__init__.py -------------------------------------------------------------------------------- /irc/tests/test_bot.py: -------------------------------------------------------------------------------- 1 | import socketserver 2 | import threading 3 | import time 4 | 5 | import pytest 6 | 7 | import irc.bot 8 | import irc.client 9 | import irc.server 10 | from irc.bot import ServerSpec 11 | 12 | 13 | class TestServerSpec: 14 | def test_with_host(self): 15 | server_spec = ServerSpec('irc.example.com') 16 | assert server_spec.host == 'irc.example.com' 17 | assert server_spec.port == 6667 18 | assert server_spec.password is None 19 | 20 | def test_with_host_and_port(self): 21 | server_spec = ServerSpec('irc.example.org', port=6669) 22 | assert server_spec.host == 'irc.example.org' 23 | assert server_spec.port == 6669 24 | assert server_spec.password is None 25 | 26 | def test_with_host_and_password(self): 27 | server_spec = ServerSpec('irc.example.net', password='heres johnny!') 28 | assert server_spec.host == 'irc.example.net' 29 | assert server_spec.port == 6667 30 | assert server_spec.password == 'heres johnny!' 31 | 32 | def test_with_host_and_port_and_password(self): 33 | server_spec = ServerSpec( 34 | 'irc.example.gov', port=6668, password='there-is-only-zuul' 35 | ) 36 | assert server_spec.host == 'irc.example.gov' 37 | assert server_spec.port == 6668 38 | assert server_spec.password == 'there-is-only-zuul' 39 | 40 | 41 | class TestChannel: 42 | def test_add_remove_nick(self): 43 | channel = irc.bot.Channel() 44 | channel.add_user('tester1') 45 | channel.remove_user('tester1') 46 | assert 'tester1' not in channel.users() 47 | channel.add_user('tester1') 48 | assert 'tester1' in channel.users() 49 | 50 | def test_change_nick(self): 51 | channel = irc.bot.Channel() 52 | channel.add_user('tester1') 53 | channel.change_nick('tester1', 'was_tester') 54 | 55 | def test_has_user(self): 56 | channel = irc.bot.Channel() 57 | channel.add_user('tester1') 58 | assert channel.has_user('Tester1') 59 | 60 | def test_set_mode_clear_mode(self): 61 | channel = irc.bot.Channel() 62 | channel.add_user('tester1') 63 | channel.set_mode('o', 'tester1') 64 | assert channel.is_oper('tester1') 65 | channel.clear_mode('o', 'tester1') 66 | assert not channel.is_oper('tester1') 67 | 68 | def test_remove_add_clears_mode(self): 69 | channel = irc.bot.Channel() 70 | channel.add_user('tester1') 71 | channel.set_mode('v', 'tester1') 72 | assert channel.is_voiced('tester1') 73 | channel.remove_user('tester1') 74 | channel.add_user('tester1') 75 | assert not channel.is_voiced('tester1') 76 | 77 | 78 | class DisconnectHandler(socketserver.BaseRequestHandler): 79 | """ 80 | Immediately disconnect the client after connecting 81 | """ 82 | 83 | def handle(self): 84 | self.request.close() 85 | 86 | 87 | @pytest.fixture 88 | def disconnecting_server(): 89 | """ 90 | An IRC server that disconnects the client immediately. 91 | """ 92 | # bind to localhost on an ephemeral port 93 | bind_address = 'localhost', 0 94 | try: 95 | srv = irc.server.IRCServer(bind_address, DisconnectHandler) 96 | threading.Thread(target=srv.serve_forever).start() 97 | yield srv 98 | finally: 99 | srv.shutdown() 100 | srv.server_close() 101 | 102 | 103 | class TestBot: 104 | def test_construct_bot(self): 105 | bot = irc.bot.SingleServerIRCBot( 106 | server_list=[('localhost', '9999')], 107 | realname='irclibbot', 108 | nickname='irclibbot', 109 | ) 110 | svr = bot.servers.peek() 111 | assert svr.host == 'localhost' 112 | assert svr.port == '9999' 113 | assert svr.password is None 114 | 115 | def test_namreply_no_channel(self): 116 | """ 117 | If channel is '*', _on_namreply should not crash. 118 | 119 | Regression test for #22 120 | """ 121 | event = irc.client.Event( 122 | type=None, source=None, target=None, arguments=['*', '*', 'nick'] 123 | ) 124 | irc.bot.SingleServerIRCBot._on_namreply(None, None, event) 125 | 126 | def test_reconnects_are_stable(self, disconnecting_server): 127 | """ 128 | Ensure that disconnects from the server don't lead to 129 | exponential growth in reconnect attempts. 130 | """ 131 | recon = irc.bot.ExponentialBackoff(min_interval=0.01) 132 | bot = irc.bot.SingleServerIRCBot( 133 | server_list=[disconnecting_server.socket.getsockname()], 134 | realname='reconnect_test', 135 | nickname='reconnect_test', 136 | recon=recon, 137 | ) 138 | bot._connect() 139 | for _ in range(4): 140 | bot.reactor.process_once() 141 | time.sleep(0.01) 142 | assert len(bot.reactor.scheduler.queue) <= 1 143 | 144 | 145 | def test_version(): 146 | assert isinstance(irc.bot.SingleServerIRCBot.get_version(), str) 147 | -------------------------------------------------------------------------------- /irc/tests/test_client.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | import pytest 4 | 5 | import irc.client 6 | 7 | 8 | def test_version(): 9 | assert isinstance(irc._get_version(), str) 10 | 11 | 12 | @mock.patch('irc.connection.socket') 13 | def test_privmsg_sends_msg(socket_mod): 14 | server = irc.client.Reactor().server() 15 | server.connect('foo', 6667, 'bestnick') 16 | # make sure the mock object doesn't have a write method or it will treat 17 | # it as an SSL connection and never call .send. 18 | del server.socket.write 19 | server.privmsg('#best-channel', 'You are great') 20 | server.socket.send.assert_called_with(b'PRIVMSG #best-channel :You are great\r\n') 21 | 22 | 23 | @mock.patch('irc.connection.socket') 24 | def test_privmsg_fails_on_embedded_carriage_returns(socket_mod): 25 | server = irc.client.Reactor().server() 26 | server.connect('foo', 6667, 'bestnick') 27 | with pytest.raises(ValueError): 28 | server.privmsg('#best-channel', 'You are great\nSo are you') 29 | 30 | 31 | class TestHandlers: 32 | def test_handlers_same_priority(self): 33 | """ 34 | Two handlers of the same priority should still compare. 35 | """ 36 | handler1 = irc.client.PrioritizedHandler(1, lambda: None) 37 | handler2 = irc.client.PrioritizedHandler(1, lambda: 'other') 38 | assert not handler1 < handler2 39 | assert not handler2 < handler1 40 | 41 | 42 | @mock.patch('irc.connection.socket') 43 | def test_command_without_arguments(self): 44 | "A command without arguments should not crash" 45 | server = irc.client.Reactor().server() 46 | server.connect('foo', 6667, 'bestnick') 47 | server._process_line('GLOBALUSERSTATE') 48 | -------------------------------------------------------------------------------- /irc/tests/test_client_aio.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import contextlib 3 | import warnings 4 | from unittest.mock import MagicMock 5 | 6 | from irc import client_aio 7 | 8 | 9 | def make_mocked_create_connection(mock_transport, mock_protocol): 10 | async def mock_create_connection(*args, **kwargs): 11 | return (mock_transport, mock_protocol) 12 | 13 | return mock_create_connection 14 | 15 | 16 | @contextlib.contextmanager 17 | def suppress_issue_197(): 18 | with warnings.catch_warnings(): 19 | warnings.filterwarnings('ignore', 'There is no current event loop') 20 | yield 21 | 22 | 23 | def test_privmsg_sends_msg(): 24 | # create dummy transport, protocol 25 | mock_transport = MagicMock() 26 | mock_protocol = MagicMock() 27 | 28 | # connect to dummy server 29 | with suppress_issue_197(): 30 | loop = asyncio.get_event_loop() 31 | loop.create_connection = make_mocked_create_connection( 32 | mock_transport, mock_protocol 33 | ) 34 | server = client_aio.AioReactor(loop=loop).server() 35 | loop.run_until_complete(server.connect('foo', 6667, 'my_irc_nick')) 36 | server.privmsg('#best-channel', 'You are great') 37 | 38 | mock_transport.write.assert_called_with(b'PRIVMSG #best-channel :You are great\r\n') 39 | 40 | loop.close() 41 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | # Is the project well-typed? 3 | strict = False 4 | 5 | # Early opt-in even when strict = False 6 | warn_unused_ignores = True 7 | warn_redundant_casts = True 8 | enable_error_code = ignore-without-code 9 | 10 | # Support namespace packages per https://github.com/python/mypy/issues/14057 11 | explicit_package_bases = True 12 | 13 | disable_error_code = 14 | # Disable due to many false positives 15 | overload-overlap, 16 | 17 | # jaraco/jaraco.logging#6 18 | [mypy-jaraco.logging] 19 | ignore_missing_imports = True 20 | 21 | # jaraco/jaraco.stream#6 22 | [mypy-jaraco.stream] 23 | ignore_missing_imports = True 24 | 25 | # jaraco/jaraco.text#17 26 | [mypy-jaraco.text] 27 | ignore_missing_imports = True 28 | 29 | # jaraco/tempora#35 30 | [mypy-tempora] 31 | ignore_missing_imports = True 32 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "setuptools>=77", 4 | "setuptools_scm[toml]>=3.4.1", 5 | # jaraco/skeleton#174 6 | "coherent.licensed", 7 | ] 8 | build-backend = "setuptools.build_meta" 9 | 10 | [project] 11 | name = "irc" 12 | authors = [ 13 | { name = "Jason R. Coombs", email = "jaraco@jaraco.com" }, 14 | ] 15 | description = "IRC (Internet Relay Chat) protocol library for Python" 16 | readme = "README.rst" 17 | classifiers = [ 18 | "Development Status :: 5 - Production/Stable", 19 | "Intended Audience :: Developers", 20 | "Programming Language :: Python :: 3", 21 | "Programming Language :: Python :: 3 :: Only", 22 | ] 23 | requires-python = ">=3.9" 24 | license = "MIT" 25 | dependencies = [ 26 | "jaraco.collections", 27 | "jaraco.text >= 3.14", 28 | "jaraco.logging", 29 | "jaraco.functools>=1.20", 30 | "jaraco.stream", 31 | "pytz", 32 | "more_itertools", 33 | "tempora>=1.6", 34 | 'importlib_metadata; python_version < "3.8"', 35 | 'importlib_resources; python_version < "3.12"', 36 | ] 37 | dynamic = ["version"] 38 | 39 | [project.urls] 40 | Source = "https://github.com/jaraco/irc" 41 | 42 | [project.optional-dependencies] 43 | test = [ 44 | # upstream 45 | "pytest >= 6, != 8.1.*", 46 | 47 | # local 48 | "pygments", 49 | ] 50 | 51 | doc = [ 52 | # upstream 53 | "sphinx >= 3.5", 54 | "jaraco.packaging >= 9.3", 55 | "rst.linker >= 1.9", 56 | "furo", 57 | "sphinx-lint", 58 | 59 | # tidelift 60 | "jaraco.tidelift >= 1.4", 61 | ] 62 | 63 | check = [ 64 | "pytest-checkdocs >= 2.4", 65 | "pytest-ruff >= 0.2.1; sys_platform != 'cygwin'", 66 | ] 67 | 68 | cover = [ 69 | "pytest-cov", 70 | ] 71 | 72 | enabler = [ 73 | "pytest-enabler >= 2.2", 74 | ] 75 | 76 | type = [ 77 | # upstream 78 | "pytest-mypy", 79 | 80 | # local 81 | "importlib_metadata", 82 | ] 83 | 84 | 85 | [tool.setuptools_scm] 86 | 87 | 88 | [tool.pytest-enabler.mypy] 89 | # Disabled due to jaraco/skeleton#143 90 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | norecursedirs=dist build .tox .eggs 3 | addopts= 4 | --doctest-modules 5 | --import-mode importlib 6 | consider_namespace_packages=true 7 | filterwarnings= 8 | ## upstream 9 | 10 | # Ensure ResourceWarnings are emitted 11 | default::ResourceWarning 12 | 13 | # realpython/pytest-mypy#152 14 | ignore:'encoding' argument not specified::pytest_mypy 15 | 16 | # python/cpython#100750 17 | ignore:'encoding' argument not specified::platform 18 | 19 | # pypa/build#615 20 | ignore:'encoding' argument not specified::build.env 21 | 22 | # dateutil/dateutil#1284 23 | ignore:datetime.datetime.utcfromtimestamp:DeprecationWarning:dateutil.tz.tz 24 | 25 | ## end upstream 26 | -------------------------------------------------------------------------------- /ruff.toml: -------------------------------------------------------------------------------- 1 | [lint] 2 | extend-select = [ 3 | # upstream 4 | 5 | "C901", # complex-structure 6 | "I", # isort 7 | "PERF401", # manual-list-comprehension 8 | 9 | # Ensure modern type annotation syntax and best practices 10 | # Not including those covered by type-checkers or exclusive to Python 3.11+ 11 | "FA", # flake8-future-annotations 12 | "F404", # late-future-import 13 | "PYI", # flake8-pyi 14 | "UP006", # non-pep585-annotation 15 | "UP007", # non-pep604-annotation 16 | "UP010", # unnecessary-future-import 17 | "UP035", # deprecated-import 18 | "UP037", # quoted-annotation 19 | "UP043", # unnecessary-default-type-args 20 | 21 | # local 22 | ] 23 | ignore = [ 24 | # upstream 25 | 26 | # Typeshed rejects complex or non-literal defaults for maintenance and testing reasons, 27 | # irrelevant to this project. 28 | "PYI011", # typed-argument-default-in-stub 29 | # https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules 30 | "W191", 31 | "E111", 32 | "E114", 33 | "E117", 34 | "D206", 35 | "D300", 36 | "Q000", 37 | "Q001", 38 | "Q002", 39 | "Q003", 40 | "COM812", 41 | "COM819", 42 | 43 | # local 44 | "B008", # nuisance 45 | ] 46 | 47 | [format] 48 | # Enable preview to get hugged parenthesis unwrapping and other nice surprises 49 | # See https://github.com/jaraco/skeleton/pull/133#issuecomment-2239538373 50 | preview = true 51 | # https://docs.astral.sh/ruff/settings/#format_quote-style 52 | quote-style = "preserve" 53 | -------------------------------------------------------------------------------- /scripts/dccreceive.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | # 3 | # Example program using irc.client. 4 | # 5 | # This program is free without restrictions; do anything you like with 6 | # it. 7 | # 8 | # Joel Rosdahl 9 | 10 | 11 | import argparse 12 | import os 13 | import shlex 14 | import struct 15 | import sys 16 | 17 | import jaraco.logging 18 | 19 | import irc.client 20 | 21 | 22 | class DCCReceive(irc.client.SimpleIRCClient): 23 | def __init__(self): 24 | irc.client.SimpleIRCClient.__init__(self) 25 | self.received_bytes = 0 26 | 27 | def on_ctcp(self, connection, event): 28 | payload = event.arguments[1] 29 | parts = shlex.split(payload) 30 | command, filename, peer_address, peer_port, size = parts 31 | if command != "SEND": 32 | return 33 | self.filename = os.path.basename(filename) 34 | if os.path.exists(self.filename): 35 | print("A file named", self.filename, "already exists. Refusing to save it.") 36 | self.connection.quit() 37 | return 38 | self.file = open(self.filename, "wb") 39 | peer_address = irc.client.ip_numstr_to_quad(peer_address) 40 | peer_port = int(peer_port) 41 | self.dcc = self.dcc_connect(peer_address, peer_port, "raw") 42 | 43 | def on_dccmsg(self, connection, event): 44 | data = event.arguments[0] 45 | self.file.write(data) 46 | self.received_bytes = self.received_bytes + len(data) 47 | self.dcc.send_bytes(struct.pack("!I", self.received_bytes)) 48 | 49 | def on_dcc_disconnect(self, connection, event): 50 | self.file.close() 51 | print(f"Received file {self.filename} ({self.received_bytes} bytes).") 52 | self.connection.quit() 53 | 54 | def on_disconnect(self, connection, event): 55 | sys.exit(0) 56 | 57 | 58 | def get_args(): 59 | parser = argparse.ArgumentParser( 60 | description="Receive a single file to the current directory via DCC " 61 | "and then exit." 62 | ) 63 | parser.add_argument('server') 64 | parser.add_argument('nickname') 65 | parser.add_argument('-p', '--port', default=6667, type=int) 66 | jaraco.logging.add_arguments(parser) 67 | return parser.parse_args() 68 | 69 | 70 | def main(): 71 | args = get_args() 72 | jaraco.logging.setup(args) 73 | 74 | c = DCCReceive() 75 | try: 76 | c.connect(args.server, args.port, args.nickname) 77 | except irc.client.ServerConnectionError as x: 78 | print(x) 79 | sys.exit(1) 80 | c.start() 81 | 82 | 83 | if __name__ == "__main__": 84 | main() 85 | -------------------------------------------------------------------------------- /scripts/dccsend.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | # 3 | # Example program using irc.client. 4 | # 5 | # This program is free without restrictions; do anything you like with 6 | # it. 7 | # 8 | # Joel Rosdahl 9 | 10 | import argparse 11 | import os 12 | import struct 13 | import subprocess 14 | import sys 15 | 16 | import jaraco.logging 17 | 18 | import irc.client 19 | 20 | 21 | class DCCSend(irc.client.SimpleIRCClient): 22 | def __init__(self, receiver, filename): 23 | irc.client.SimpleIRCClient.__init__(self) 24 | self.receiver = receiver 25 | self.filename = filename 26 | self.filesize = os.path.getsize(self.filename) 27 | self.file = open(filename, 'rb') 28 | self.sent_bytes = 0 29 | 30 | def on_welcome(self, connection, event): 31 | self.dcc = self.dcc_listen("raw") 32 | msg_parts = map( 33 | str, 34 | ( 35 | 'SEND', 36 | os.path.basename(self.filename), 37 | irc.client.ip_quad_to_numstr(self.dcc.localaddress), 38 | self.dcc.localport, 39 | self.filesize, 40 | ), 41 | ) 42 | msg = subprocess.list2cmdline(msg_parts) 43 | self.connection.ctcp("DCC", self.receiver, msg) 44 | 45 | def on_dcc_connect(self, connection, event): 46 | if self.filesize == 0: 47 | self.dcc.disconnect() 48 | return 49 | self.send_chunk() 50 | 51 | def on_dcc_disconnect(self, connection, event): 52 | print(f"Sent file {self.filename} ({self.filesize} bytes).") 53 | self.connection.quit() 54 | 55 | def on_dccmsg(self, connection, event): 56 | acked = struct.unpack("!I", event.arguments[0])[0] 57 | if acked == self.filesize: 58 | self.dcc.disconnect() 59 | self.connection.quit() 60 | elif acked == self.sent_bytes: 61 | self.send_chunk() 62 | 63 | def on_disconnect(self, connection, event): 64 | sys.exit(0) 65 | 66 | def on_nosuchnick(self, connection, event): 67 | print("No such nickname:", event.arguments[0]) 68 | self.connection.quit() 69 | 70 | def send_chunk(self): 71 | data = self.file.read(1024) 72 | self.dcc.send_bytes(data) 73 | self.sent_bytes = self.sent_bytes + len(data) 74 | 75 | 76 | def get_args(): 77 | parser = argparse.ArgumentParser( 78 | description="Send to via DCC and then exit." 79 | ) 80 | parser.add_argument('server') 81 | parser.add_argument('nickname') 82 | parser.add_argument('receiver', help="the nickname to receive the file") 83 | parser.add_argument('filename') 84 | parser.add_argument('-p', '--port', default=6667, type=int) 85 | jaraco.logging.add_arguments(parser) 86 | return parser.parse_args() 87 | 88 | 89 | def main(): 90 | args = get_args() 91 | jaraco.logging.setup(args) 92 | 93 | c = DCCSend(args.receiver, args.filename) 94 | try: 95 | c.connect(args.server, args.port, args.nickname) 96 | except irc.client.ServerConnectionError as x: 97 | print(x) 98 | sys.exit(1) 99 | c.start() 100 | 101 | 102 | if __name__ == "__main__": 103 | main() 104 | -------------------------------------------------------------------------------- /scripts/irccat-aio.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | # 3 | # Example program using irc.client. 4 | # 5 | # This program is free without restrictions; do anything you like with 6 | # it. 7 | 8 | import argparse 9 | import asyncio 10 | import itertools 11 | import sys 12 | 13 | import jaraco.logging 14 | 15 | import irc.client 16 | import irc.client_aio 17 | 18 | target = None 19 | 20 | 21 | def on_connect(connection, event): 22 | if irc.client.is_channel(target): 23 | connection.join(target) 24 | return 25 | 26 | 27 | def on_join(connection, event): 28 | connection.read_loop = asyncio.ensure_future( 29 | main_loop(connection), loop=connection.reactor.loop 30 | ) 31 | 32 | 33 | def get_lines(): 34 | while True: 35 | yield sys.stdin.readline().strip() 36 | 37 | 38 | async def main_loop(connection): 39 | for line in itertools.takewhile(bool, get_lines()): 40 | connection.privmsg(target, line) 41 | 42 | # Allow pause in the stdin loop to not block the asyncio event loop 43 | asyncio.sleep(0) 44 | connection.quit("Using irc.client_aio.py") 45 | 46 | 47 | def on_disconnect(connection, event): 48 | raise SystemExit() 49 | 50 | 51 | def get_args(): 52 | parser = argparse.ArgumentParser() 53 | parser.add_argument('server') 54 | parser.add_argument('nickname') 55 | parser.add_argument('target', help="a nickname or channel") 56 | parser.add_argument('--password', default=None, help="optional password") 57 | parser.add_argument('-p', '--port', default=6667, type=int) 58 | jaraco.logging.add_arguments(parser) 59 | return parser.parse_args() 60 | 61 | 62 | def main(): 63 | global target 64 | 65 | args = get_args() 66 | jaraco.logging.setup(args) 67 | target = args.target 68 | 69 | loop = asyncio.get_event_loop() 70 | reactor = irc.client_aio.AioReactor(loop=loop) 71 | 72 | try: 73 | c = loop.run_until_complete( 74 | reactor.server().connect( 75 | args.server, args.port, args.nickname, password=args.password 76 | ) 77 | ) 78 | except irc.client.ServerConnectionError: 79 | print(sys.exc_info()[1]) 80 | raise SystemExit(1) from None 81 | 82 | c.add_global_handler("welcome", on_connect) 83 | c.add_global_handler("join", on_join) 84 | c.add_global_handler("disconnect", on_disconnect) 85 | 86 | try: 87 | reactor.process_forever() 88 | finally: 89 | loop.close() 90 | 91 | 92 | if __name__ == '__main__': 93 | main() 94 | -------------------------------------------------------------------------------- /scripts/irccat.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | # 3 | # Example program using irc.client. 4 | # 5 | # This program is free without restrictions; do anything you like with 6 | # it. 7 | # 8 | # Joel Rosdahl 9 | 10 | import argparse 11 | import itertools 12 | import sys 13 | 14 | import jaraco.logging 15 | 16 | import irc.client 17 | 18 | target = None 19 | "The nick or channel to which to send messages" 20 | 21 | 22 | def on_connect(connection, event): 23 | if irc.client.is_channel(target): 24 | connection.join(target) 25 | return 26 | main_loop(connection) 27 | 28 | 29 | def on_join(connection, event): 30 | main_loop(connection) 31 | 32 | 33 | def get_lines(): 34 | while True: 35 | yield sys.stdin.readline().strip() 36 | 37 | 38 | def main_loop(connection): 39 | for line in itertools.takewhile(bool, get_lines()): 40 | print(line) 41 | connection.privmsg(target, line) 42 | connection.quit("Using irc.client.py") 43 | 44 | 45 | def on_disconnect(connection, event): 46 | raise SystemExit() 47 | 48 | 49 | def get_args(): 50 | parser = argparse.ArgumentParser() 51 | parser.add_argument('server') 52 | parser.add_argument('nickname') 53 | parser.add_argument('target', help="a nickname or channel") 54 | parser.add_argument('-p', '--port', default=6667, type=int) 55 | jaraco.logging.add_arguments(parser) 56 | return parser.parse_args() 57 | 58 | 59 | def main(): 60 | global target 61 | 62 | args = get_args() 63 | jaraco.logging.setup(args) 64 | target = args.target 65 | 66 | reactor = irc.client.Reactor() 67 | try: 68 | c = reactor.server().connect(args.server, args.port, args.nickname) 69 | except irc.client.ServerConnectionError: 70 | print(sys.exc_info()[1]) 71 | raise SystemExit(1) from None 72 | 73 | c.add_global_handler("welcome", on_connect) 74 | c.add_global_handler("join", on_join) 75 | c.add_global_handler("disconnect", on_disconnect) 76 | 77 | reactor.process_forever() 78 | 79 | 80 | if __name__ == '__main__': 81 | main() 82 | -------------------------------------------------------------------------------- /scripts/irccat2-aio.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | # 3 | # Example program using irc.client. 4 | # 5 | # This program is free without restrictions; do anything you like with 6 | # it. 7 | import argparse 8 | import asyncio 9 | import sys 10 | 11 | import jaraco.logging 12 | 13 | import irc.client 14 | import irc.client_aio 15 | 16 | 17 | class AioIRCCat(irc.client_aio.AioSimpleIRCClient): 18 | def __init__(self, target): 19 | irc.client.SimpleIRCClient.__init__(self) 20 | self.target = target 21 | 22 | def on_welcome(self, connection, event): 23 | if irc.client.is_channel(self.target): 24 | connection.join(self.target) 25 | else: 26 | self.future = asyncio.ensure_future( 27 | self.send_it(), loop=connection.reactor.loop 28 | ) 29 | 30 | def on_join(self, connection, event): 31 | self.future = asyncio.ensure_future( 32 | self.send_it(), loop=connection.reactor.loop 33 | ) 34 | 35 | def on_disconnect(self, connection, event): 36 | self.future.cancel() 37 | sys.exit(0) 38 | 39 | async def send_it(self): 40 | while 1: 41 | line = sys.stdin.readline().strip() 42 | if not line: 43 | break 44 | self.connection.privmsg(self.target, line) 45 | 46 | # Allow pause in the stdin loop to not block asyncio loop 47 | await asyncio.sleep(0) 48 | self.connection.quit("Using irc.client.py") 49 | 50 | 51 | def get_args(): 52 | parser = argparse.ArgumentParser() 53 | parser.add_argument('server') 54 | parser.add_argument('nickname') 55 | parser.add_argument('target', help="a nickname or channel") 56 | parser.add_argument('--password', default=None, help="optional password") 57 | parser.add_argument('-p', '--port', default=6667, type=int) 58 | jaraco.logging.add_arguments(parser) 59 | return parser.parse_args() 60 | 61 | 62 | def main(): 63 | args = get_args() 64 | jaraco.logging.setup(args) 65 | target = args.target 66 | 67 | c = AioIRCCat(target) 68 | 69 | try: 70 | c.connect(args.server, args.port, args.nickname, password=args.password) 71 | except irc.client.ServerConnectionError as x: 72 | print(x) 73 | sys.exit(1) 74 | 75 | try: 76 | c.start() 77 | finally: 78 | c.connection.disconnect() 79 | c.reactor.loop.close() 80 | 81 | 82 | if __name__ == "__main__": 83 | main() 84 | -------------------------------------------------------------------------------- /scripts/irccat2.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | # 3 | # Example program using irc.client. 4 | # 5 | # This program is free without restrictions; do anything you like with 6 | # it. 7 | # 8 | # Joel Rosdahl 9 | 10 | import sys 11 | 12 | import irc.client 13 | 14 | 15 | class IRCCat(irc.client.SimpleIRCClient): 16 | def __init__(self, target): 17 | irc.client.SimpleIRCClient.__init__(self) 18 | self.target = target 19 | 20 | def on_welcome(self, connection, event): 21 | if irc.client.is_channel(self.target): 22 | connection.join(self.target) 23 | else: 24 | self.send_it() 25 | 26 | def on_join(self, connection, event): 27 | self.send_it() 28 | 29 | def on_disconnect(self, connection, event): 30 | sys.exit(0) 31 | 32 | def send_it(self): 33 | while 1: 34 | line = sys.stdin.readline().strip() 35 | if not line: 36 | break 37 | self.connection.privmsg(self.target, line) 38 | self.connection.quit("Using irc.client.py") 39 | 40 | 41 | def main(): 42 | if len(sys.argv) != 4: 43 | print("Usage: irccat2 ") 44 | print("\ntarget is a nickname or a channel.") 45 | sys.exit(1) 46 | 47 | s = sys.argv[1].split(":", 1) 48 | server = s[0] 49 | if len(s) == 2: 50 | try: 51 | port = int(s[1]) 52 | except ValueError: 53 | print("Error: Erroneous port.") 54 | sys.exit(1) 55 | else: 56 | port = 6667 57 | nickname = sys.argv[2] 58 | target = sys.argv[3] 59 | 60 | c = IRCCat(target) 61 | try: 62 | c.connect(server, port, nickname) 63 | except irc.client.ServerConnectionError as x: 64 | print(x) 65 | sys.exit(1) 66 | c.start() 67 | 68 | 69 | if __name__ == "__main__": 70 | main() 71 | -------------------------------------------------------------------------------- /scripts/sasl-ssl-cat.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3 2 | # 3 | # Example program using irc.client. 4 | # 5 | # This program is free without restrictions; do anything you like with 6 | # it. 7 | # IMPORTANT: sasl_login must equal your nickserv account name 8 | # 9 | # Matthew Blau 10 | 11 | import functools 12 | import ssl 13 | import sys 14 | 15 | import irc.client 16 | import irc 17 | 18 | 19 | class IRCCat(irc.client.SimpleIRCClient): 20 | def __init__(self, target): 21 | irc.client.SimpleIRCClient.__init__(self) 22 | self.target = target 23 | 24 | def on_welcome(self, connection, event): 25 | if irc.client.is_channel(self.target): 26 | connection.join(self.target) 27 | else: 28 | self.send_it() 29 | 30 | def on_login_failed(self, connection, event): 31 | print(event) 32 | 33 | def on_join(self, connection, event): 34 | self.send_it() 35 | 36 | def on_disconnect(self, connection, event): 37 | sys.exit(0) 38 | 39 | def send_it(self): 40 | while 1: 41 | line = sys.stdin.readline().strip() 42 | if not line: 43 | break 44 | self.connection.privmsg(self.target, line) 45 | self.connection.quit("Using irc.client.py") 46 | 47 | 48 | def main(): 49 | server = "irc.libera.chat" 50 | port = 6697 51 | nickname = "nickname" 52 | account_name = "username" 53 | target = "##channel" 54 | password = "" 55 | 56 | c = IRCCat(target) 57 | try: 58 | context = ssl.create_default_context() 59 | wrapper = functools.partial(context.wrap_socket, server_hostname=server) 60 | 61 | c.connect( 62 | server, 63 | port, 64 | nickname, 65 | password, 66 | sasl_login=account_name, 67 | username=account_name, 68 | connect_factory=irc.connection.Factory(wrapper=wrapper), 69 | ) 70 | except irc.client.ServerConnectionError as x: 71 | print(x) 72 | sys.exit(1) 73 | c.start() 74 | 75 | 76 | if __name__ == "__main__": 77 | main() 78 | -------------------------------------------------------------------------------- /scripts/servermap.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | # 3 | # Example program using irc.client. 4 | # 5 | # servermap connects to an IRC server and finds out what other IRC 6 | # servers there are in the net and prints a tree-like map of their 7 | # interconnections. 8 | # 9 | # Example: 10 | # 11 | # % ./servermap irc.dal.net somenickname 12 | # Connecting to server... 13 | # Getting links... 14 | # 15 | # 26 servers (18 leaves and 8 hubs) 16 | # 17 | # splitrock.tx.us.dal.net 18 | # `-vader.ny.us.dal.net 19 | # |-twisted.ma.us.dal.net 20 | # |-sodre.nj.us.dal.net 21 | # |-glass.oh.us.dal.net 22 | # |-distant.ny.us.dal.net 23 | # | |-algo.se.eu.dal.net 24 | # | | |-borg.se.eu.dal.net 25 | # | | | `-ced.se.eu.dal.net 26 | # | | |-viking.no.eu.dal.net 27 | # | | |-inco.fr.eu.dal.net 28 | # | | |-paranoia.se.eu.dal.net 29 | # | | |-gaston.se.eu.dal.net 30 | # | | | `-powertech.no.eu.dal.net 31 | # | | `-algo-u.se.eu.dal.net 32 | # | |-philly.pa.us.dal.net 33 | # | |-liberty.nj.us.dal.net 34 | # | `-jade.va.us.dal.net 35 | # `-journey.ca.us.dal.net 36 | # |-ion.va.us.dal.net 37 | # |-dragons.ca.us.dal.net 38 | # |-toronto.on.ca.dal.net 39 | # | `-netropolis-r.uk.eu.dal.net 40 | # | |-traced.de.eu.dal.net 41 | # | `-lineone.uk.eu.dal.net 42 | # `-omega.ca.us.dal.net 43 | 44 | import argparse 45 | import sys 46 | 47 | import jaraco.logging 48 | 49 | import irc.client 50 | 51 | 52 | def on_connect(connection, event): 53 | sys.stdout.write("\nGetting links...") 54 | sys.stdout.flush() 55 | connection.links() 56 | 57 | 58 | def on_passwdmismatch(connection, event): 59 | print("Password required.") 60 | sys.exit(1) 61 | 62 | 63 | def on_links(connection, event): 64 | global links 65 | 66 | links.append((event.arguments[0], event.arguments[1], event.arguments[2])) 67 | 68 | 69 | def on_endoflinks(connection, event): 70 | global links 71 | 72 | print("\n") 73 | 74 | m = {} 75 | for to_node, from_node, _ in links: 76 | if from_node != to_node: 77 | m[from_node] = m.get(from_node, []) + [to_node] 78 | 79 | if connection.get_server_name() in m: 80 | if len(m[connection.get_server_name()]) == 1: 81 | hubs = len(m) - 1 82 | else: 83 | hubs = len(m) 84 | else: 85 | hubs = 0 86 | 87 | print(f"{len(links)} servers ({len(links) - hubs} leaves and {hubs} hubs)\n") 88 | 89 | print_tree(0, [], connection.get_server_name(), m) 90 | connection.quit("Using irc.client.py") 91 | 92 | 93 | def on_disconnect(connection, event): 94 | sys.exit(0) 95 | 96 | 97 | def indent_string(level, active_levels, last): 98 | if level == 0: 99 | return "" 100 | s = "" 101 | for i in range(level - 1): 102 | if i in active_levels: 103 | s = s + "| " 104 | else: 105 | s = s + " " 106 | if last: 107 | s = s + "`-" 108 | else: 109 | s = s + "|-" 110 | return s 111 | 112 | 113 | def print_tree(level, active_levels, root, map, last=0): 114 | sys.stdout.write(indent_string(level, active_levels, last) + root + "\n") 115 | if root in map: 116 | list = map[root] 117 | for r in list[:-1]: 118 | print_tree(level + 1, active_levels[:] + [level], r, map) 119 | print_tree(level + 1, active_levels[:], list[-1], map, 1) 120 | 121 | 122 | def get_args(): 123 | parser = argparse.ArgumentParser() 124 | parser.add_argument('server') 125 | parser.add_argument('nickname') 126 | parser.add_argument('-p', '--port', default=6667, type=int) 127 | jaraco.logging.add_arguments(parser) 128 | return parser.parse_args() 129 | 130 | 131 | def main(): 132 | global links 133 | 134 | args = get_args() 135 | jaraco.logging.setup(args) 136 | 137 | links = [] 138 | 139 | reactor = irc.client.Reactor() 140 | sys.stdout.write("Connecting to server...") 141 | sys.stdout.flush() 142 | try: 143 | c = reactor.server().connect(args.server, args.port, args.nickname) 144 | except irc.client.ServerConnectionError as x: 145 | print(x) 146 | sys.exit(1) 147 | 148 | c.add_global_handler("welcome", on_connect) 149 | c.add_global_handler("passwdmismatch", on_passwdmismatch) 150 | c.add_global_handler("links", on_links) 151 | c.add_global_handler("endoflinks", on_endoflinks) 152 | c.add_global_handler("disconnect", on_disconnect) 153 | 154 | reactor.process_forever() 155 | 156 | 157 | if __name__ == '__main__': 158 | main() 159 | -------------------------------------------------------------------------------- /scripts/ssl-cat.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | # 3 | # Example program using irc.client for SSL connections. 4 | # 5 | # This program is free without restrictions; do anything you like with 6 | # it. 7 | # 8 | # Jason R. Coombs 9 | 10 | import argparse 11 | import functools 12 | import itertools 13 | import ssl 14 | import sys 15 | 16 | import irc.client 17 | 18 | target = None 19 | "The nick or channel to which to send messages" 20 | 21 | 22 | def on_connect(connection, event): 23 | if irc.client.is_channel(target): 24 | connection.join(target) 25 | return 26 | main_loop(connection) 27 | 28 | 29 | def on_join(connection, event): 30 | main_loop(connection) 31 | 32 | 33 | def get_lines(): 34 | while True: 35 | yield sys.stdin.readline().strip() 36 | 37 | 38 | def main_loop(connection): 39 | for line in itertools.takewhile(bool, get_lines()): 40 | print(line) 41 | connection.privmsg(target, line) 42 | connection.quit("Using irc.client.py") 43 | 44 | 45 | def on_disconnect(connection, event): 46 | raise SystemExit() 47 | 48 | 49 | def get_args(): 50 | parser = argparse.ArgumentParser() 51 | parser.add_argument('server') 52 | parser.add_argument('nickname') 53 | parser.add_argument('target', help="a nickname or channel") 54 | parser.add_argument('-p', '--port', default=6667, type=int) 55 | return parser.parse_args() 56 | 57 | 58 | def main(): 59 | global target 60 | 61 | args = get_args() 62 | target = args.target 63 | 64 | context = ssl.create_default_context() 65 | wrapper = functools.partial(context.wrap_socket, server_hostname=args.server) 66 | ssl_factory = irc.connection.Factory(wrapper=wrapper) 67 | reactor = irc.client.Reactor() 68 | try: 69 | c = reactor.server().connect( 70 | args.server, args.port, args.nickname, connect_factory=ssl_factory 71 | ) 72 | except irc.client.ServerConnectionError: 73 | print(sys.exc_info()[1]) 74 | raise SystemExit(1) from None 75 | 76 | c.add_global_handler("welcome", on_connect) 77 | c.add_global_handler("join", on_join) 78 | c.add_global_handler("disconnect", on_disconnect) 79 | 80 | reactor.process_forever() 81 | 82 | 83 | if __name__ == '__main__': 84 | main() 85 | -------------------------------------------------------------------------------- /scripts/testbot.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | # 3 | # Example program using irc.bot. 4 | # 5 | # Joel Rosdahl 6 | 7 | """A simple example bot. 8 | 9 | This is an example bot that uses the SingleServerIRCBot class from 10 | irc.bot. The bot enters a channel and listens for commands in 11 | private messages and channel traffic. Commands in channel messages 12 | are given by prefixing the text by the bot name followed by a colon. 13 | It also responds to DCC CHAT invitations and echos data sent in such 14 | sessions. 15 | 16 | The known commands are: 17 | 18 | stats -- Prints some channel information. 19 | 20 | disconnect -- Disconnect the bot. The bot will try to reconnect 21 | after 60 seconds. 22 | 23 | die -- Let the bot cease to exist. 24 | 25 | dcc -- Let the bot invite you to a DCC CHAT connection. 26 | """ 27 | 28 | import irc.bot 29 | import irc.strings 30 | from irc.client import ip_numstr_to_quad, ip_quad_to_numstr 31 | 32 | 33 | class TestBot(irc.bot.SingleServerIRCBot): 34 | def __init__(self, channel, nickname, server, port=6667): 35 | irc.bot.SingleServerIRCBot.__init__(self, [(server, port)], nickname, nickname) 36 | self.channel = channel 37 | 38 | def on_nicknameinuse(self, c, e): 39 | c.nick(c.get_nickname() + "_") 40 | 41 | def on_welcome(self, c, e): 42 | c.join(self.channel) 43 | 44 | def on_privmsg(self, c, e): 45 | self.do_command(e, e.arguments[0]) 46 | 47 | def on_pubmsg(self, c, e): 48 | a = e.arguments[0].split(":", 1) 49 | if len(a) > 1 and irc.strings.lower(a[0]) == irc.strings.lower( 50 | self.connection.get_nickname() 51 | ): 52 | self.do_command(e, a[1].strip()) 53 | return 54 | 55 | def on_dccmsg(self, c, e): 56 | # non-chat DCC messages are raw bytes; decode as text 57 | text = e.arguments[0].decode('utf-8') 58 | c.privmsg("You said: " + text) 59 | 60 | def on_dccchat(self, c, e): 61 | if len(e.arguments) != 2: 62 | return 63 | args = e.arguments[1].split() 64 | if len(args) == 4: 65 | try: 66 | address = ip_numstr_to_quad(args[2]) 67 | port = int(args[3]) 68 | except ValueError: 69 | return 70 | self.dcc_connect(address, port) 71 | 72 | def do_command(self, e, cmd): 73 | nick = e.source.nick 74 | c = self.connection 75 | 76 | if cmd == "disconnect": 77 | self.disconnect() 78 | elif cmd == "die": 79 | self.die() 80 | elif cmd == "stats": 81 | for chname, chobj in self.channels.items(): 82 | c.notice(nick, "--- Channel statistics ---") 83 | c.notice(nick, "Channel: " + chname) 84 | users = sorted(chobj.users()) 85 | c.notice(nick, "Users: " + ", ".join(users)) 86 | opers = sorted(chobj.opers()) 87 | c.notice(nick, "Opers: " + ", ".join(opers)) 88 | voiced = sorted(chobj.voiced()) 89 | c.notice(nick, "Voiced: " + ", ".join(voiced)) 90 | elif cmd == "dcc": 91 | dcc = self.dcc_listen() 92 | c.ctcp( 93 | "DCC", 94 | nick, 95 | f"CHAT chat {ip_quad_to_numstr(dcc.localaddress)} {dcc.localport}", 96 | ) 97 | else: 98 | c.notice(nick, "Not understood: " + cmd) 99 | 100 | 101 | def main(): 102 | import sys 103 | 104 | if len(sys.argv) != 4: 105 | print("Usage: testbot ") 106 | sys.exit(1) 107 | 108 | s = sys.argv[1].split(":", 1) 109 | server = s[0] 110 | if len(s) == 2: 111 | try: 112 | port = int(s[1]) 113 | except ValueError: 114 | print("Error: Erroneous port.") 115 | sys.exit(1) 116 | else: 117 | port = 6667 118 | channel = sys.argv[2] 119 | nickname = sys.argv[3] 120 | 121 | bot = TestBot(channel, nickname, server, port) 122 | bot.start() 123 | 124 | 125 | if __name__ == "__main__": 126 | main() 127 | -------------------------------------------------------------------------------- /towncrier.toml: -------------------------------------------------------------------------------- 1 | [tool.towncrier] 2 | title_format = "{version}" 3 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [testenv] 2 | description = perform primary checks (tests, style, types, coverage) 3 | deps = 4 | setenv = 5 | PYTHONWARNDEFAULTENCODING = 1 6 | commands = 7 | pytest {posargs} 8 | usedevelop = True 9 | extras = 10 | test 11 | check 12 | cover 13 | enabler 14 | type 15 | 16 | [testenv:diffcov] 17 | description = run tests and check that diff from main is covered 18 | deps = 19 | {[testenv]deps} 20 | diff-cover 21 | commands = 22 | pytest {posargs} --cov-report xml 23 | diff-cover coverage.xml --compare-branch=origin/main --html-report diffcov.html 24 | diff-cover coverage.xml --compare-branch=origin/main --fail-under=100 25 | 26 | [testenv:docs] 27 | description = build the documentation 28 | extras = 29 | doc 30 | test 31 | changedir = docs 32 | commands = 33 | python -m sphinx -W --keep-going . {toxinidir}/build/html 34 | python -m sphinxlint 35 | 36 | [testenv:finalize] 37 | description = assemble changelog and tag a release 38 | skip_install = True 39 | deps = 40 | towncrier 41 | jaraco.develop >= 7.23 42 | pass_env = * 43 | commands = 44 | python -m jaraco.develop.finalize 45 | 46 | 47 | [testenv:release] 48 | description = publish the package to PyPI and GitHub 49 | skip_install = True 50 | deps = 51 | build 52 | twine>=3 53 | jaraco.develop>=7.1 54 | pass_env = 55 | TWINE_PASSWORD 56 | GITHUB_TOKEN 57 | setenv = 58 | TWINE_USERNAME = {env:TWINE_USERNAME:__token__} 59 | commands = 60 | python -c "import shutil; shutil.rmtree('dist', ignore_errors=True)" 61 | python -m build 62 | python -m twine upload dist/* 63 | python -m jaraco.develop.create-github-release 64 | --------------------------------------------------------------------------------