├── .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 |
--------------------------------------------------------------------------------