pax_global_header00006660000000000000000000000064134745200240014513gustar00rootroot0000000000000052 comment=592735d4ea61f61212a9e5d1ae5f0a90c6f07983 sopel-6.6.9/000077500000000000000000000000001347452002400126575ustar00rootroot00000000000000sopel-6.6.9/.coveragerc000066400000000000000000000011771347452002400150060ustar00rootroot00000000000000# Sample conf file from http://nedbatchelder.com/code/coverage/config.html [run] source = sopel branch = True [report] # Regexes for lines to exclude from consideration exclude_lines = # Have to re-enable the standard pragma pragma: no cover # Don't complain about missing debug-only code: #def __repr__ #if self\.debug # Don't complain if tests don't hit defensive assertion code: raise AssertionError raise NotImplementedError # Don't complain if non-runnable code isn't run: if 0: if False: if __name__ == .__main__.: show_missing = True [html] directory = coverage_html_report sopel-6.6.9/.gitignore000066400000000000000000000004431347452002400146500ustar00rootroot00000000000000build/ dist/ env/ MANIFEST doc/build/* logs/* tests sopel.egg-info/* *.db *.pyc *.pyo *.txt *~ *.rpm *.spec .*.sw* .pid-* # IntelliJ idea .idea *.iml # Eclipse/PyDev .settings .project .pydevproject # Test/Coverage .cache .pytest_cache .coverage coverage_html_report/ # macOS *.DS_Store sopel-6.6.9/.travis.yml000066400000000000000000000015401347452002400147700ustar00rootroot00000000000000language: python python: - "2.7" - "3.3" - "3.4" git: submodules: false branches: only: - master - /^\d+\.\d+\.x$/ # allows building maintenance branches - /^v?\d+\.\d+(\.\d+)?(-\S*)?$/ # allows building version tags sudo: false # Enables running on faster infrastructure. cache: directories: - $HOME/.cache/pip addons: apt: packages: - enchant install: - pip install -r requirements.txt -r dev-requirements.txt script: - ./checkstyle.sh - coverage run -m py.test -v . - coverage report after_success: coveralls deploy: provider: pypi username: dgw password: secure: U9XLRA5fYRmII/pyJGDIT0BQ4p0zP8yZJtxUSUO9arFKozgYZu0ldvoLjKnzPMPQNCGs+q4f0hNuXgN+u/FgfRPF/Q3wtUj58uIC4JFnn7u2D2pv7RqzZkGi9Hr8+SS7dChlx9bVbhC1Y0md0XlrsT6rbNKKW457Jei05+vpjvg= on: tags: true python: "3.4" allow_failure: true sopel-6.6.9/CONTRIBUTING.md000066400000000000000000000070551347452002400151170ustar00rootroot00000000000000Submitting Issues ----------------- When submitting issues to our [issue tracker](https://github.com/sopel-irc/sopel/issues), it's important that you do the following: 1. Describe your issue clearly and concisely. 2. Give Sopel the .version command, and include the output in your issue. 3. Note the OS you're running Sopel on, and how you installed Sopel (via your package manager, pip, setup.py install, or running straight from source) 4. Include relevant output from the log files in ~/.sopel/logs. Committing Code --------------- We prefer code to be submitted through GitHub pull requests. We do require that code submitted to the project be licensed under the Eiffel Forum License v2, the text of which was distributed with the source code. In order to make it easier for us to review and merge your code, it's important to write good commits, and good commit messages. Below are some things you should do when you want to submit code. These aren't hard and fast rules; we may still consider code that doesn't meet all of them. But doing the stuff below will make our lives easier, and by extension make us more likely to include your changes. * Commits should focus on one thing at a time. Do include whatever you need to make your change work, but avoid putting unrelated changes in the same commit. Preferably, one change in functionality should be in exactly one commit. * pep8ify your code before you commit. We don't worry about line length much (though it's good if you do keep lines short), but you should try to follow the rest of the rules. * Test your code before you commit. We don't have a formal testing plan in place, but you should make sure your code works as promised before you commit. * Make your commit messages clear and explicative. Our convention is to place the name of the thing you're changing in at the beginning of the message, followed by a colon: the module name for modules, docs for documentation files, coretasks for coretasks.py, db for the database feature, and so on. * Python files should always have `# coding=utf-8` as the first line (or the second, if the first is `#!/usr/bin/env python`), and `from __future__ import unicode_literals, absolute_import, print_function, division` as the first line after the module docstring. Issue Tags ---------- If you're looking to hack on some code, you should consider fixing one of the issues that has been reported to the GitHub issue tracker. Here's a quick guide to the tags we use: * Easyfix - A great place for a new developer to start off, Easyfix issues are ones which we think will be simple and quick to address. * Patches Welcome - Things we don't plan on doing, but which we might be interested to see someone else submit code for * Bug - An error or incorrect behavior * Feature - A new thing the bot should be able to do * Tweak - A minor change to something the bot already does, like making something's output prettier, improving the documentation on something, or addressing technical debt * Tracking - An ongoing process that involves lots of changes in lots of places. Often also a Tweak. * Low Priority - Things we'll get around to doing eventually * Medium Priority - Things that need to be done soon * High Priority - Things that should've been done yesterday * Tests - Issues regarding the testing unit in tests/ * Windows - Windows-specific bugs are labelled as such sopel-6.6.9/COPYING000066400000000000000000000017761347452002400137250ustar00rootroot00000000000000 Eiffel Forum License, version 2 1. Permission is hereby granted to use, copy, modify and/or distribute this package, provided that: * copyright notices are retained unchanged, * any distribution of this package, whether modified or not, includes this license text. 2. Permission is hereby also granted to distribute binary programs which depend on this package. If the binary program depends on a modified version of this package, you are encouraged to publicly release the modified version of this package. *********************** THIS PACKAGE IS PROVIDED "AS IS" AND WITHOUT WARRANTY. ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHORS BE LIABLE TO ANY PARTY FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES ARISING IN ANY WAY OUT OF THE USE OF THIS PACKAGE. *********************** sopel-6.6.9/CREDITS000066400000000000000000000015211347452002400136760ustar00rootroot00000000000000This file is dedicated to the people who want to list their names (or handles) for their contribution to this work. This project is a fork of "phenny" from http://inamidst.com/phenny/ This project's name is "Sopel" The original creator: Sean B. Palmer deserves the most credit for originally creating phenny. He has done an extraordinary job at producing this project, without him this fork would not exist. Please feel free to add your name if you have added to this project in any way. List of contributors: Michael Yanovich (yano) Matt Meinwald (meinwald) Silas Baronda (sifi) Morgan Goose (goosemo) Alek Rollyson (al3k) Kenneth K. Sham (Kays) Joel Friedly (jfriedly) Samuel Clements (Ziaix) Dimitri Molenaars (Tyrope) Elsie Powell (Embolalia) Elad Alfassa (elad661) Lior Ramati (FireRogue) Syfaro Warraw (Syfaro) sopel-6.6.9/MANIFEST.in000066400000000000000000000000321347452002400144100ustar00rootroot00000000000000include *requirements.txt sopel-6.6.9/NEWS000066400000000000000000001301071347452002400133600ustar00rootroot00000000000000This file is used to auto-generate the "Changelog" section of Sopel's website. When adding new entries, follow the style guide in NEWS.spec.md to avoid causing problems with the site build. Changes between 6.6.8 and 6.6.9 =============================== Module changes -------------- * Also block changing `core.owner_account` setting in `admin` plugin [[#1599][]] * Fixed `instagram` parsing after changes to the website [[#1608][]] * `tld` plugin decodes HTML entities before output [[#1612][]] * Handle an error condition when querying `ip` module using nickname [[#1631][]] Core changes ------------ * Fixed populating `user` & `host` in the bot's user database when `NAMES` reply arrives first [[#1630][]] [#1599]: https://github.com/sopel-irc/sopel/pull/1599 [#1608]: https://github.com/sopel-irc/sopel/pull/1608 [#1612]: https://github.com/sopel-irc/sopel/pull/1612 [#1630]: https://github.com/sopel-irc/sopel/pull/1630 [#1631]: https://github.com/sopel-irc/sopel/pull/1631 Changes between 6.6.7 and 6.6.8 =============================== Module changes -------------- * `admin` module no longer allows setting `core.owner` value [[#1587][]] * Allowing any bot admin to change the owner opened Sopel to a privilege-escalation attack * Even with this patch, you still should add only users you really trust to the `admins` list, for obvious reasons API changes ----------- * Fixed some content & formatting errors in documentation [[#1589][]] [#1587]: https://github.com/sopel-irc/sopel/pull/1587 [#1589]: https://github.com/sopel-irc/sopel/pull/1589 Changes between 6.6.6 and 6.6.7 =============================== Fixing this bug, which was discovered after the previous release, serves to exorcise the curse brought upon us by version 6.6.6. Core changes ------------ * Fixed incorrect `MODE` message parsing that could lead to Sopel thinking some users had higher channel privileges than they did [[#1575][]] [#1575]: https://github.com/sopel-irc/sopel/pull/1575 Changes between 6.6.5 and 6.6.6 =============================== This slightly cursed version of Sopel is brought to you by the following happy coincidences. When this release cycle began: * 6.6.6 was the next patch version number * Tax Day (in the U.S.) was just a few weeks off — the perfect release date As it happens, the [v6.6.6 GitHub milestone][ms-666] ended up with 13 closed issues/PRs in total. We *definitely* didn't try to *plan* any of this. Promise. [ms-666]: https://github.com/sopel-irc/sopel/milestone/21?closed=1 Module changes -------------- * Potential denial-of-service via repeated long output in the `.py` and `.calc` commands was mitigated [[#1552][]] * `admin` module's `.set` command no longer throws an exception if missing arguments [[#1520][]] * Fixed `admin` module's `.mode` command sending invalid raw line [[#1549][]] * Tweaked output-formatting code in the `meetbot` & `reddit` modules [[#1516][]] * The most visible effect of this is that moderators' names will now appear green in `reddit`'s output, like on the site, instead of the old brown/purple * Updated example/test output for `.ip` command [[#1523][]] Core changes ------------ * Flood protection delay is now capped at 2 seconds [[#1552][]] * The flood delay penalty is calculated using the message length before truncation. Very long command output could thus "hang" the bot for many minutes at a time, meaning some modules could be used to DoS the bot. * Sopel 7 will add configuration for flood protection and perhaps reorganize the logic to reduce silly bugs like this. (See: [#1518][], [#1559][]) * Fixed a few cases where keyboard interrupt (Control + C) wasn't handled correctly [[#1534][], [#1558][]] * Fixed invalid `MODE` command sent during connection phase [[#1544][]] API changes ----------- * Fixed a regression in testing modules by running them directly [[#1529][]] * Fixed that `bot` output methods (`say`, `reply`, etc.) would cause errors during tests if passed certain keyword arguments [[#1538][]] [#1516]: https://github.com/sopel-irc/sopel/pull/1516 [#1518]: https://github.com/sopel-irc/sopel/pull/1518 [#1520]: https://github.com/sopel-irc/sopel/pull/1520 [#1523]: https://github.com/sopel-irc/sopel/pull/1523 [#1529]: https://github.com/sopel-irc/sopel/pull/1529 [#1534]: https://github.com/sopel-irc/sopel/pull/1534 [#1538]: https://github.com/sopel-irc/sopel/pull/1538 [#1544]: https://github.com/sopel-irc/sopel/pull/1544 [#1549]: https://github.com/sopel-irc/sopel/pull/1549 [#1552]: https://github.com/sopel-irc/sopel/pull/1552 [#1558]: https://github.com/sopel-irc/sopel/pull/1558 [#1559]: https://github.com/sopel-irc/sopel/issues/1559 Changes between 6.6.4 and 6.6.5 =============================== Module changes -------------- * Fixed url module not cleaning punctuation when auto-titling [[#1515][]] * Fixed url module's punctuation-cleaning on Python 2 [[#1517][]] * Fixed `.redditor` command with newer `praw` versions (4.0+) [[#1506][]] * Reloading modules now runs their `shutdown()` routines [[#1412][]] [#1412]: https://github.com/sopel-irc/sopel/pull/1412 [#1506]: https://github.com/sopel-irc/sopel/pull/1506 [#1515]: https://github.com/sopel-irc/sopel/pull/1515 [#1517]: https://github.com/sopel-irc/sopel/pull/1517 Changes between 6.6.3 and 6.6.4 =============================== Module changes -------------- * Replaced `help` pastebin with `clbin.com` * `ptpb.pw` shut down due to abuse — see [ptpb/pb#246]( https://github.com/ptpb/pb/issues/246) for more * More news on this front (reducing Sopel's dependence on specific pastebins) in a future release. Several ideas are under consideration. * Cleaned up code in `instagram` and `unicode` modules * Core modules now use `bot.channels` instead of deprecated `bot.privileges` Core changes ------------ * Privilege tracking now always updates both `bot.channels` & `bot.privileges`, where before some handlers only updated one or the other * This *should* have zero effect on behavior, but do report any observed. Changes between 6.6.2 and 6.6.3 =============================== Module changes -------------- * Fixed loading etymology module on Python 3.3 * Added Unicode support to calc module's `.py` output * Correctly quote URL parameters in etymology and search modules Core changes ------------ * Added docstrings to the privilege level constants in `sopel.module` Changes between 6.6.1 and 6.6.2 =============================== Module changes -------------- * wiktionary tries harder to get a valid result before erroring out Core changes ------------ * Fixed an inconsistency between interpretations of the `--config` option in normal operation vs. wizard mode * Requirement specifiers tightened up to reduce/prevent `pip` trying to install incompatible dependency versions (`IPython`, `dnspython`) * SASL token is now split when required according to spec * Multi-byte Unicode characters are now handled correctly when splitting lines Changes between 6.6.0 and 6.6.1 =============================== Module changes -------------- * spellcheck's `pyenchant` dependency is no longer required for py3.7+ * This should alleviate a lot of installation problems due to `pyenchant` being unmaintained. If use of the spellcheck module is desired, the necessary libraries may be installed manually. Changes between 6.5.3 and 6.6.0 =============================== Sopel 6.6.0 has been a long time coming, but it contains a metric ton of fixes and enhancements. The big stuff is yet to come in Sopel 7, though; stay tuned! (You can get some hints by browsing the [GitHub milestones][].) View and contribute toward Sopel's budgetary needs by visiting the project's new [Open Collective page](https://opencollective.com/sopel). Now that Sopel 6.6.0 is released, the focus will shift from preparing the new release to migrating the website and documentation off of Embolalia's personal infrastructure. Once this migration is complete, Sopel's [documentation][docs] will be updated to reflect the changes/additions listed below. Questions about the new (and old) API methods are welcome in [#sopel][] on Freenode, of course, as always. Thank you for bearing with this transitional period! Importantly, a couple of broken modules have been removed in this release. For easy reference, they and their PyPI-installable replacements are listed near the top of the "[Module changes](#module-changes)" section. Because this is the last release planned before Sopel 7, it's important to note some upcoming changes. They're mostly for module developers, or for bot owners who run unmaintained third-party modules (which might need manual updating). But first, a user-facing change. Sopel is drifting toward a more streamlined release model, which will see most of the core module library split out into separate packages. Moving modules to their own packages will enable faster fixes when APIs change, and easier module adoption by members of the community. Further process details will appear in future changelogs as modules are moved out of core, but the plan is to have Sopel "require" modules it used to include for a release or two after their removal. Existing installations should thereby have a smooth transition, as long as they get updated reasonably often. To discuss this plan, visit issue [#1291][]. And now, for the developer stuff. There will be some attention given to how `Identifier` objects work in Sopel 7. In particular, the way lowercase works appears to have been wrong for some time. Subscribe to issue [#1389][] for updates, or to join the discussion on the best plan for addressing this. Also in Sopel 7, the following deprecated functions in `sopel.web` will be removed: `get()`, `head()`, `post()`, `get_urllib_object()`. Module authors should replace these with equivalent code using `requests`. If you don't know what that is, you're in for a treat: Remaining `sopel.web` functions might also move in a future major release. See [#1318][] and comment your opinion if you are a module developer. There is still plenty of time to give feedback; nothing will happen before Sopel 8 at the earliest. Support for multiple help examples on each module command is planned for Sopel 7 and will allow for clearer command and argument documentation. The current help system uses a deterministic but unintuitive method to choose a single example to output. The current plan is to make the new multi-example behavior opt-in, so only help for modules that are explicitly updated to support it will change. Follow this initiative in [#1200][]. Bot owners and admins might appreciate the new ignore system proposal, which would (among other things) make "hostmask" blocks actually block hostmasks! See [#1355][] to participate in the design process; this change is tentatively planned for Sopel 7 but may be pushed back. And finally, for users who hate SQLite with a passion: Work on bringing back alternate DB support has begun! It's unclear how soon this will be ready, but users who want to switch off SQLite to another database engine might be able to do so as early as Sopel 7. Follow the ups and downs of this long-awaited journey at [#1446][] for the time being. [docs]: https://sopel.chat/docs/ [#sopel]: irc://chat.freenode.net/sopel [GitHub milestones]: https://github.com/sopel-irc/sopel/milestones [#1200]: https://github.com/sopel-irc/sopel/issues/1200 [#1291]: https://github.com/sopel-irc/sopel/issues/1291 [#1318]: https://github.com/sopel-irc/sopel/issues/1318 [#1355]: https://github.com/sopel-irc/sopel/issues/1355 [#1389]: https://github.com/sopel-irc/sopel/issues/1389 [#1446]: https://github.com/sopel-irc/sopel/pull/1446 ---- Module changes -------------- * Added emoticons module: includes `.shrug`, `.tableflip`, `.lenny`, and other common kaomoji * Added instagram module: displays information about Instagram links in chat * Removed movie: The OMDb API went private some time ago (Q2 2017) * See [`sopel-modules.imdb`][pypi-imdb] on PyPI for a functional replacement * Removed weather: Yahoo deprecated its weather service, the last known keyless weather API * See [`sopel-modules.weather`][pypi-weather] on PyPI for a functional replacement that also adds the forecast command originally planned to launch with Sopel 6.6.0. * admin module's `.set` command handles spaces properly now * bugzilla no longer spits out an error on shutdown if its domain list is empty * dice module's `.roll`/`.dice`/`.d` command gives friendlier errors, and its `.choice`/`.choose`/`.ch` command has cleaner output (will be further improved in a later release) * etymology module updated to work with etymonline.com's changes * ip module can now look up nicknames (user's hostname) & IPv6 addresses * ip module switched from deprecated GeoIP database to GeoIP2 * isup module can be forced to ignore untrusted/broken HTTPS with a new command alias, `.isupinsecure` * lmgtfy module gained improved output URLs (HTTPS, encoded query) and help examples * reddit module now picks up links under all commonly used subdomains * reload's habit of duplicating packaged modules' command output has been significantly curtailed * There are still some edge cases (like reloading a module with a removed function) that might still cause problems, but the majority of module updates should be OK now. * This feature will continue to improve over the next few releases. * search module should behave better when encountering Unicode in URLs on py2 * search now warns `.ddg`/`.g` users of a bug affecting multiple "site:" operators in a single query * This is a bug in DuckDuckGo's plain-HTML SERP, and they have ignored all attempts to report it. Sopel's DuckDuckGo code might be rewritten to use an unaffected interface in the future. * tld module gracefully handles missing arguments now * translate now handles API failures gracefully * url module can now create a short URL for convenience when fetching title * only URLs longer than configured minimum length * off by default * url module will say an error if `.title` command fails * url module's URL finder will ignore suspicious trailing punctuation, making the auto-title feature more reliable when links are included in sentences * version module no longer prints "wat" to the console/log when answering CTCP `VERSION` requests * wiktionary module now decodes HTML entities in its output before sending, and it should also now give correct results for affixes & definitions containing reference tags * xkcd fetching by comic title should work again (switched back-end search provider) * Many modules updated to use HTTPS in both API calls and output [pypi-imdb]: https://pypi.org/project/sopel-modules.imdb/ [pypi-weather]: https://pypi.org/project/sopel-modules.weather/ Core changes ------------ * Added `userserv` option for `auth_method` * Added default value of `'UTC'` to `default_timezone` * Added `alias_nicks` option * Lets Sopel respond to (a) nickname(s) other than the name it uses for its IRC nick * Made handling `CNAME`'d TLS hostnames more forgiving * Raw logging is no longer enabled by default * Comparisons between Identifiers and other types were improved * Sopel no longer sends the sticky modifier for capabilities * Database tables are now created using "IF NOT EXISTS" to reduce error potential * Deprecated `sopel.web` calls in various modules updated to use `requests` instead * Intents/CTCP handling fixed * Fixed more cases where module shutdown routines weren't running API changes ----------- * New formatting methods: `italic`, `strikethrough`, `monospace`, `hex_color`, `reverse` (inverted video) * With the exception of italics, these are all "niche" formatting codes that have relatively limited client support, but they are included for completeness. * `sopel.db.get_uri()` was returning an incorrectly formatted result, now fixed * Fixed populating `bot.channels` privileges dicts on connect * Fixed updating `User` objects when receiving `NICK` events from the server * Documentation added/corrected in several places * Command prefix in examples is now assumed to match the default regex pattern (currently `\.`) * This fixes some issues with incorrect help output, but might cause new problems in modules that use non-standard prefixes in their examples. * Sopel's maintenance team regrets not providing advance warning of this change, but the potential impact is limited to perhaps a few misplaced characters in help output, at most—same as the bug itself, but likely with a much lower incidence. Changes between 6.5.2 and 6.5.3 =============================== Module changes -------------- * tell module takes nicknames up to 30 characters long now (previously 20) * reddit module (and Sopel, by extension) once again accepts any installed version of PRAW Core changes ------------ * Specified which `IPython` versions pip should give Sopel depending on Python version in use * Fixed trying to use `pytest` stuff in production * Various testing tweaks Changes between 6.5.1 and 6.5.2 =============================== Larger than the usual "patch" release because it's been so long since the last version and a number of bugs crept in. New maintainer (dgw) says hi. To reduce the "dependency hell" of installing things that Sopel doesn't need unless specific modules are used, the `spellcheck` and `ipython` modules will likely be removed from core and published separately in a future release (tentatively, Sopel 7). ---- Module changes -------------- * Added (restored) `.bing` command to search module, and fixed Bing results * Updated DDG ad filtering, and converted query URLs in search module to HTTPS * Fixed search module `.suggest` command using Google suggestions API * Fixed reddit and updated PRAW version requirement * reload module no longer tries to reload nonexistent modules when passed the name of a system package (e.g. "time" or "re") * remind module will now give the reminder time in the correct timezone when using `.at` * wikipedia module ignores `File:` namespace links, and output from other Sopel bots' wikipedia modules * Fixed retrieving xkcd comics by number * Fixed currency module (updated API URLs) * Tweaked url module's title finder to reduce garbage from Gawker/Kinja sites * help module now filters out duplicate commands from its command list * Fixed wikipedia module's handling of special characters like `&` * Prevent wikipedia module spewing an error if someone posts a link to an article that doesn't exist * help module now sends the command list to ptpb.pw, as GitHub removed their anonymous Gist API * Switched calc module to use new backend service for `.py` command, to reduce outages * Added physical temperature limit to units module * Tweaked argument handling in pronouns module Core changes ------------ * Logger bug squashed * Shutdown method handling fixed * Added documentation in a few places * Made finding the system SSL/TLS certificate trust store more robust * Modules with example tests no longer need to hack around `pytest` running their `setup()` routines * Corrected `IPython` dependency for ipython module (now installs with Sopel automatically) * Widened acceptable `requests` version range API changes ----------- * Module `shutdown()` methods should fire correctly now * `ListAttribute` config values now return `[]` (empty list) when blank instead of `[""]` (empty string in list) * Updated documentation for (still deprecated) `sopel.web` (the documentation for `post()` included a nonexistent kwarg, `headers`) Changes between 6.5.0 and 6.5.1 =============================== Module changes -------------- * A module to track users' pronouns is added * A few bug and regression fixes Changes between 6.4.0 and 6.5.0 =============================== Module changes -------------- * xkcd module can now recognize xkcd.com urls * SSL is verified for HTTP requests when not turned off in the config * The command list is placed in a gist, to prevent flooding * Title finding uses a custom user-agent, to prevent issues with some sites Core changes ------------ * Intent handling is improved API changes ----------- * A `@url` decorator is added to simplify URL handling Changes between 6.3.1 and 6.4.0 =============================== Module changes -------------- * For some subreddits where NSFW is used to mark spoilers, an appropriate tag is shown. * `.ddg` avoids giving ad results. * `.wa` is fully removed * See [`sopel-modules.wolfram`][pypi-wolfram] on PyPI for a replacement [pypi-wolfram]: https://pypi.org/project/sopel-modules.wolfram/ Core changes ------------ * Support for authenticating with Quakenet's Q is added. * Errors from empty PID files are fixed. * Issues with errors not being logged to the logging channel are fixed. * Topic tracking is improved * `extended-join` is supported properly * Error messages being reported to the triggering channel/user can be disabled. API changes ----------- * Channel privileges are no longer checked in private messages. * Rate limiting can now be done by channel and globally, not just per user. Changes between 6.3.0 and 6.3.1 =============================== Module changes -------------- * The xkcd module is working again * Fix an issue causing Unicode errors to show for some URLs when using Python 2 (but you should really switch to 3!) Core changes ------------ * Fix a bug in QUIT message parsing which caused certain users to be flooded with PMs if their nick matched the first word of a user's QUIT message (such as "disconnected" or "ping") * Fix a rare Python 3 incompatibility bug when quitting due to too many core errors * We no longer show a warning when detecting a non-Unicode system locale if you're still using Python 2 Changes between 6.2.0 and 6.3.0 =============================== Module changes -------------- * Many modules ported to use `requests` package for stability and security * Weather location lookup is fixed * Confusing and unnecessary commands like .op were removed * Splitting of options in `.choice` is now more intuitive * Some edge cases in reddit post information were fixed Core changes ------------ * A check is added to warn about an obscure environment issue that can cause strange errors * Regex characters in the bot's nick no longer cause issues when a rule has the nickname added * Rate limiting is tweaked slightly, which should reduce the severity of the `.commands` flood bug until a proper solution is found API changes ----------- * The current topic of a channel is now available as the `Channel` object's `topic` attribute * `sopel.web` has been reworked as a wrapper around requests; it remains deprecated Changes between 6.1.1 and 6.2.0 =============================== Module changes -------------- * An error in excluding URLs from title display is fixed * Case sensitivity issues in currency and dice commands are fixed * Guards to require channel or private message are added to a number of commands, to avoid confusing errors * A calculation bug in the countdown command is fixed * Misc. minor bugfixes and improvements Core changes ------------ * An occasional error with SSL connections on Python 3 is fixed * On servers which support IRCv3 account extensions, the services account name can be used to authenticate the owner * Numerous additional IRCv3 features are supported API changes ----------- * `bot.privileges` is now deprecated in favor of `bot.channels` * `bot.channels` contains more information about the channels the bot is in * `bot.users` is now available with information about the users Sopel is aware of * `sopel.web` is now deprecated in favor of the third-party `requests` library * `trigger.time` is added with the current time, or server-time if the server supports it * `sopel.tools.events` is now available as an enum of IRC numeric replies Changes between 6.1.0 and 6.1.1 =============================== If you are updating from a pre-6.0 version (i.e. Willie), there are backwards- incompatible changes which you should be aware of. See for more information. ---- Core changes ------------ * A regression which caused the config wizard to be unusable is fixed Changes between 6.0.0 and 6.1.0 =============================== If you are updating from a pre-6.0 version (i.e. Willie), there are backwards- incompatible changes which you should be aware of. See for more information. ---- Module changes -------------- * A regression which prevented the URL safety detection from working is fixed. * Issues with some special characters in DuckDuckGo searches are fixed * `lxml` is no longer required by any modules, greatly simplifying the install process * Misc. minor bugfixes and improvements Core changes ------------ * A regression which disabled blocking functionality is fixed * Examples are no longer mangled by the `.help` command, and show the correct prefix * The listing from `.commands` is now separated by module * Issues with reloading folder modules are fixed API changes ----------- * `ListAttribute` configs can be set to a list or set, with the same effect * The configure method of validated config attributes now takes the parent config and section name Changes between 5.5.1 and 6.0.0 =============================== This release contains backwards-incompatible changes. See for more information. This is the first release in which the bot will have a new name. Current tentative name is Sopel. ---- Module changes -------------- * chanlogs, rss, github, and radio modules are removed * Default in admin module is now to join on invite from anyone; the old default of only joining on invite from admins can be configured * Wikipedia module's per-channel default language configuration is deprecated; a database-backed option will be added in a future version * `.seen` replies on action messages now display action messages differently * `.kick` no longer truncates the message, and `.kickban` works properly Core changes ------------ * Deprecated login configs have been removed in favor of the new `auth_*` values * Module reloading has been redone to enable modules to be installed from PyPI * IRCv3 capability negotiation is updated for v3.2 API changes ----------- * Configuration has been entirely reworked. Old wizard functions and the `get_list()` function are removed, but most other uses of the config should continue to work. * The reorganization of `willie.tools` introduced in 5.3.0 is finalized; the old import locations no longer work. * `bot.msg` is deprecated in favor of `bot.say`, which can now be used in un-triggered contexts with an additional `recipient` argument Changes between 5.5.0 and 5.5.1 =============================== This release continues preparations for Willie 6. See ---- Module changes -------------- * The URL safety module correctly checks URLs * The URL for the service used for `.py` and `.wa` is updated Changes between 5.4.1 and 5.5.0 =============================== This release starts preparations for Willie 6. See ---- API changes ----------- * The ability to import from the `sopel` package, rather than `willie`, is added to enable forward compatibility with the rename in 6.0 * A `version_info` tuple, similar to Python's own `sys.version_info`, is added * An error when setting a validated config attribute to `None` is fixed Changes between 5.4.0 and 5.4.1 =============================== This release starts preparations for Willie 6. See ---- Core changes ------------ * Regression which prevented SSL verification from being disabled is fixed Changes between 5.3.0 and 5.4.0 =============================== This release starts preparations for Willie 6. See ---- Module changes -------------- * Dropping the lowest results from `.dice` rolls works properly * `.blocks` listing is now on one line * HTML entities in reddit post titles are now displayed correctly Core changes ------------ * DB nick group merging now works properly * A few combinations of authentication configuration that were broken are fixed API changes ----------- * The core config section now uses a 6.0-style config definition. Deprecated attributes will give a warning. * Accessing core config values directly from the `config` object itself (rather than `config.core`) now appropriately prints a warning * `@require_chanmsg` now works properly Changes between 5.2.0 and 5.3.0 =============================== Module changes -------------- * The YouTube module is removed due to breakage of its API by Google * Fixes for Unicode in channel logging * `.py` hits an updated service running Python 2.7, rather than 2.5 * Multiple new features are added in the reddit module * Wind direction arrows in weather now point the right way API changes ----------- * Time and calculation tools are split out into multiple modules. The moved things will also be available in `tools` itself until 6.0, whereupon they will only be available in their new locations. * Functions and classes used internally for scheduling jobs are moved. They are still available in their old location, but are no longer documented and will not be considered part of the public API in 6.0. Changes between 5.1.1 and 5.2.0 =============================== Module changes -------------- * An exception that failed the chanlogs module from loading is fixed * Meetbot meeting subjects no longer fail with Unicode Core changes ------------ * The various ways of authenticating the bot are now configured using the same set of attributes. The old settings will be used as fallback until 6.0.0. API changes ----------- * Privilege decorators no longer cause an error when no message is given * A new `@intent` decorator is added as a shortcut for triggering on a message with an intent Changes between 5.0.0 and 5.1.1 =============================== Module changes -------------- * Fixed a regression that caused getting weather for a nick to fail * Bugs related to channel log filenames are fixed * Channel logs can now use the bot's preferred time zone * Getter and setter methods for timezone and format are more consistently named * `.seen` persists across bot restarts * `.seen` no longer shows message or channel unless used within the same channel * Special characters in Wikipedia URLs are handled correctly Core changes ------------ * `help_prefix` can now be given in `[core]` to change the command prefix used in help API changes ----------- * The `Trigger` object is now immutable, as expected * New decorators for checking privileges on callables are added Changes between 4.6.2 and 5.0.0 =============================== This release contains backwards-incompatible changes. See for more information. ---- Module changes -------------- * YouTube no longer shows bizarre lengths when the bot is running under Python 3 * When Wikipedia links are posted, a snippet of the article is shown Core changes ------------ * `WillieDB` is entirely rewritten, meaning migration will be needed to access old data * Logging output to the debug channel is improved * The name of the NickServ user can be configured with `nickserv_name` in `[core]` * SSL is disabled on Python 2.7 when `backports.ssl_match_hostname` is not installed API changes ----------- * Deprecated `WillieDB` functions are removed * CTCP actions are stripped prior to matching, and added to the `'intent'` key of `trigger.tags` * Deprecated `Trigger` functions are removed * `bot.debug` is removed, in favor of standard Python logging * `tools.Nick` is removed, in favor of the `tools.Identifier` introduced in 4.6.0 Changes between 4.6.1 and 4.6.2 =============================== This release starts preparations for Willie 5. See for more information. ---- Module changes -------------- * Due to API deprecation and instability, `.g` now uses DuckDuckGo instead of Google Core changes ------------ * Fix remaining regression in db update function Changes between 4.6.0 and 4.6.1 =============================== This release starts preparations for Willie 5. See for more information. ---- Module changes -------------- * Fix regression in table creations and erroneously changed column names Core changes ------------ * Fix regression in db update function API changes ----------- * Correctly print out deprecation warnings Changes between 4.5.1 and 4.6.0 =============================== This release starts preparations for Willie 5. See for more information. ---- Module changes -------------- * The `^` operator in `.calc` is now equivalent to `**` * `.dice`, `.tr`, and `.addtrace` give meaningful errors when no argument is given * A number of database issues were fixed (more will be fixed in Willie 5) * A number of Python 3-related issues were fixed. * Malicious URLs are now detected with VirusTotal and malwaredomains * `.weather` no longer shows pressure Core changes ------------ * The config wizard no longer configures the database, instead automatically creating a SQLite DB * MySQL and Postgres databases are deprecated; only SQLite will be supported in Willie 5 * Logging channel and level can be configured with `logging_channel` and `logging_level` in the config * A `--version` switch was added to the `willie` command, to show the version in use * Corrupt PID files will no longer prevent the bot from starting * Non-ASCII values no longer crash the config wizard API changes ----------- * Numerous functions were added to the db to ease the transition to Willie 5 * `tools.Nick` has been renamed to `tools.Identifier`. `Nick` will be removed in Willie 5 * `willie.web` functions now specify a meaningful default user agent * `web.post` now supports dicts as the payload, and can optionally return headers * `config.get` was added as an alias to `config.parser.get` * Callables can now handle more than one event by stacking decorators Changes between 4.5.0 and 4.5.1 =============================== Module changes -------------- * Version checking module no longer causes an error at startup * URL handling modules no longer repeat themselves after being reloaded * `.isup` now adds a `.com` TLD if none is used * IPython integration now works with older versions of IPython Changes between 4.4.1 and 4.5.0 =============================== Module changes -------------- * Willie will now alert the owner at startup, and every 24 hours, when a new version is available * `.calc` will now time out on excessively long calculations, and is less likely to error out * `.help` now works properly for command aliases * `.help` will now output multi-line help in multiple messages, rather than all at once * A new `.uptime` command tells how long the bot has been running * `.length` can now handle astronomical units * Absolute vote counts are no longer shown in reddit output * Using `.setlocation` without a location will no longer set your location to Italy * Time units for `.in` are now case-insensitive * `.duck` no longer gives a long, unrelated URL Core changes ------------ * Scheduled tasks now work properly under Python 3 * Willie no longer accepts partial matches for admin status API changes ----------- * Setting attributes on the bot object now works as expected * The status code is now returned from `web.get` as `_http_status` in the headers * `web.get` now attempts to decode based on headers, rather than assuming utf-8 * `web.iri_to_uri` is now available to decode internationalized domain names * A `willie.formatting` module is now available to simplify IRC bold, underline and color formatting Changes between 4.4.0 and 4.4.1 =============================== Module changes -------------- * RSS no longer checks for malformed XML * Starting RSS manually after bot restart is no longer needed * Youtube video search properly handles spaces * The `.at` command defaults to UTC if user's timezone is not set Core changes ------------ * Ping timeout handling is working again * `bind_host` configuration option is working again Changes between 4.3.0 and 4.4.0 =============================== Module changes -------------- * `.cur` behaves better when bad arguments are given * Fixed numerous Unicode errors * Added a command to open an IPython console within the module context * Added mass units and millimeters to .cur * GitHub pull requests now get extended URL info * `.weather` now displays wind in m/s instead of kts * A security issue involving improperly named channel logs was fixed * Misc. bugfixes Core changes ------------ * Channel joins at bot startup can be rate limited with the `throttle_joins` option in the `[core]` config section to work around server limits * Added the ability for SASL login where the nickname and username are different * Improved loop protection and rate limiting * Fixed multiple Python 3 errors * Enable logging the bot in to Authserv at startup * Added support for Postgres as the database backend * SSL cert location detection now works on Debian-based systems * Misc. bugfixes API changes ----------- * Unicode in command decorators now works properly * `web.get` now decodes the result from UTF-8, with a `dont_decode` argument to disable Changes between 4.2.0 and 4.3.0 =============================== Module changes -------------- * A new channel logging module is added * Misc. bugfixes, especially when running with Python 3 Core changes ------------ * Fixed a regression that caused numerous errors in `willie.web` * Misc. bugfixes API changes ----------- * Nick instances now have an `is_nick` attribute, which is `True` when the value is a valid nickname (as opposed to a channel) Changes between 4.1.0 and 4.2.0 =============================== Module changes -------------- * A new `.cur` command can convert a number of currencies, including Bitcoin * `.c` can now understand a comma used as a radix point (rather than a period) * `.w` can now look in the Wikipedia for a specified (or configurable default) language * Timezones are now more user-friendly, and used more consistently across modules * Misc. bugfixes Core changes ------------ * `willie.web` now verifies HTTPS connections properly * The SQLite database file respects use of `~` in the configured filename * Willie can now run in Python 3 * Willie now depends on `python-backports.ssl_match_hostname` (see README.rst for installation instructions) * Misc. bugfixes API changes ----------- * `trigger.is_privmsg` is added for an easy way to see if a trigger was created in a private or channel message * `get_timezone` and `format_time` are added to `tools` to make displaying time according to user/channel format easier * Added `bot.notice` and an optional notice parameter for `bot.reply` for easier sending of IRC `NOTICE` messages Changes between 4.0.1 and 4.1.0 =============================== Module changes -------------- * Admin-only `.set` command can now set non-existent config values * The meetbot `.endmeeting` command now works properly * Significant improvements made to RSS module * The database structure for storing RSS feeds has been modified. The module will attempt to migrate old data. * Command syntax has changed in multiple ways * `.rss` help is now available with more detailed information on usage. * Module is overall better-behaved and less buggy * Traceback can now be attached to a GitHub issue from Willie's logs * GitHub module no longer puts "IRC" tag on issues it creates * A `.listactions` command is added to allow actions to be listed before the end of a meeting * Dice now limits itself to 1000 dice, and output is cleaned up * Willie now joins channels when invited * Reddit module no longer gives an error if the submitter's account has been deleted * A new `.comments` feature allows optional comments on meetings, e.g. from those muted in the channel * `.xkcd` is more robust, and can now access the nth-latest comic * calc module now uses an internal calculator, rather than the discontinued iGoogle calculator Core changes ------------ * Memory lock and unlock no longer cause errors * Debugging target no longer needs to be a channel * Whitespace can now be used in the command prefix * Line numbers are given when modules fail to load * Error messages are more consistent across core and modules * Willie now retries joining channels if it fails initially * SQLite is now the default and recommended database type * MySQL remains supported; support may be dropped in a later version * `MySQLdb` is no longer listed as a recommended dependency * IRCv3 is now largely supported * Willie can now authenticate with SASL API changes ----------- * Modules can now provide a `shutdown()` function to clean up when the bot is quitting or the module is reloading * `web.get` and `web.post` can be told to limit how much they read from a URL, to prevent malicious use * A new `@unblockable` decorator allows callables to be run even when triggered by otherwise blocked users * Willie can now connect over IPv6 * If the channel given to `bot.join` contains a space, the part after the space will be used as the password * IRCv3 is now largely supported * Modules can now request capabilities from the server. * Message tags, if enabled, can be read from `trigger.tags` Changes between 4.0.0 and 4.0.1 =============================== Core changes ------------ * Setup script once again works properly * Message splitting now works properly * Bug fixes in handling of nick and hostmask blocks Changes between 3.2.0 and 4.0.0 =============================== Module changes -------------- * The following modules have been moved to the willie-extras repository: * ai * bucket * fuckingweather * nws * roulette * twit * slap * oblique * The information of the last URL seen in a channel can now be replayed with `.title` * The YouTube module was reworked to use the YouTube JSON API * The IP module is now independent of 3rd party services, and requires a local copy of the (free) GeoLite database. If such database is not installed, Willie will download it automatically. * `.commands` now gives better output (no more truncated output due to message length limit) * Added a unit conversion module * Better handling for non-Unicode page titles in the URL titler * Removed bing support from search * Various minor improvements and bugfixes across all modules Core changes ------------ * Module discovery was reworked. Willie will now try to load additional modules from `~/.willie/modules` by default, if installed. * The home directory, usually `~/.willie`, can now be configured by adding `homedir` under `[core]` * The location of PID files can now be configured by adding `pid_dir` under `[core]` * Willie can now be run as a systemd service * Case sensitivity in nick blocking is fixed * Better handling of ping timeouts (connection problems) * Major code cleanup API changes ----------- * Improved Unicode UTF-8 support across all codepaths * Triggers, and the appropriate attributes thereof, are now `unicode` objects * Decorators were introduced for setting attributes on callables, available in `willie.module` * The `NOLIMIT` return value was moved from the `Willie` class to `willie.module` * Callables with the same name in different modules no longer override each other * `willie.channels` is now properly maintained * trigger.isvoice can now be used to determine if a user has voice privileges * Added the max_messages parameter for `willie.msg()` and `willie.say()`. See documentation for details. * Added interval callable support (see documentation for details) * Numerous minor features, and stability and usability fixes Changes between 3.1.2 and 3.2.0 =============================== * `tools.Nick` class added for RFC-compliant nickname comparison and storage * Returning `willie.NOLIMIT` from a callable ignores the rate limit for that call * `get_list()` added to `ConfigSection`. Will reliably return a list from a config attribute. * A number of bugs regarding admin and operator lists were fixed * Unusual mode changes no longer cause errors * Times shown by `.t`, `.in`, etc. all now use formats set by `.settimeformat` * sed feature can use backslashed slashes in substitutions * Weather module was rewritten, and now uses Yahoo! Weather * Numerous stability and usability fixes sopel-6.6.9/NEWS.spec.md000066400000000000000000000116421347452002400147120ustar00rootroot00000000000000Sopel's NEWS file was originally used for plainly communicating the list of changes in each release to humans. After Sopel 6.6.0, a lot of work went into improving [the website](https://sopel.chat/), and part of that work involved using the NEWS file to make HTML changelog pages. Since the file was already Markdown-like, parsing it with a script became the chosen method. What follows is a description of the syntax conventions used to keep NEWS easily consumable by the [website build script][dvs]. It uses the common all- caps RFC terms "MUST", "SHOULD", etc. according to the usual RFC conventions. Anything set off by one of these is important to follow as it might affect the website build script's ability to read this file correctly, or effectively translate it into HTML pages. [dvs]: https://github.com/sopel-irc/sopel.chat/blob/master/document_versions.py ---- The whole file SHOULD be hard-wrapped to 80 columns for ease of reading/updating in terminal-based editors, but failure to wrap a few lines here and there shouldn't break anything. Each section MUST start with a Setext-style top-level heading (underlined using `=`). There SHOULD always be two blank lines before and one blank line after this release heading. The text MUST read `Changes between a.b.c and x.y.z` (it is used to break this single file into one-page-per-version for the website). Example release section heading, including blank line spacing: ```Markdown Changes between 1.0.0 and 1.0.1 =============================== ``` After that, there MAY be an optional prose section for general notes about the release, notices about upcoming changes, migration instructions, etc. If present, this section SHOULD end with a horizontal rule (`----`). Example: ```Markdown Sopel 1.0.1 is a bugfix release with numerous small changes that add up to a big improvement in the user experience when combined. ---- ``` Each version's section SHOULD be subdivided into "Module changes", "Core changes", and "API changes"—in that order. The three subsections MUST be marked "up" (get it? because it's Mark*down*) as Setext-style second-level headings (underlined using `-`). Each subsection heading SHOULD have one blank line above and below. Subsections that remain empty after filling in the changes (see below) SHOULD be omitted from the final release section. Within the "changes" subsections, the convention is to present relevant line- items from the release's commit log or list of merged pull requests as a Markdown bulleted list (items begin with `* `; line continuations are indented with two spaces to align with the start of that item's text). The subsection names are mostly self-explanatory. Things that concern end users should go in "Module" or "Core" changes; things that only affect developers (of modules or of Sopel itself) should go in "API changes"—which, again, should appear last. The change lists MAY have nested levels of bullets to convey additional details. Each level should be indented by two additional spaces. Command names (both Sopel and shell commands), Python package names, config setting names, and anything else "code-like" SHOULD be marked up `as such` with backticks. In most cases this doesn't really matter, but it really does ease readability of the generated HTML pages. And some things (for example, sequential hyphens in change entries about CLI options like `--config`) will come out wrong without the backticks. Example subsections and placeholder change entries: ```Markdown Module changes -------------- * foo module's `.bar` command won't baz any more * eggs module removed * replaced by a new bacon module Core changes ------------ * IRC `foo` intent handled correctly API changes ----------- * `sopel.spam` is now deprecated and will be removed in Sopel 2.0. Use the new tools in `sopel.sausage` introduced in 0.9.0 instead. ``` Links MAY be included anywhere it is appropriate, using any Markdown link style. Implicitly referenced links (like `[link text][]`) are preferred because they make the text easier to read; however, explicit references (`[link text][id]`) are OK too if the same link is used in multiple places. Link reference definitions SHOULD be placed at the end of the subsection where they are first used, before any following horizontal rule or heading. Link reference definitions SHOULD be indented with two spaces, EXCEPT directly after lists: ```Markdown [link text]: https://sopel.chat/ "Optional title" ``` After a list, DO NOT indent link references. The last list item will be incorrectly formatted when the HTML for Sopel's website is rendered. Link references MUST be defined within each release section, even if you are reusing a previously defined link, because nothing outside the release section will be accessible when the file is split into HTML pages for the website. (N.B.: If this becomes overly tedious, the script could be made smarter; link references "SHOULD" be indented to help with this in case it needs to happen.) sopel-6.6.9/README.rst000066400000000000000000000112321347452002400143450ustar00rootroot00000000000000======= Sopel ======= |version| |build| |issues| |coverage-status| |license| |ocbackers| |ocsponsors| Introduction ------------ Sopel is a simple, lightweight, open source, easy-to-use IRC Utility bot, written in Python. It's designed to be easy to use, run and extend. Installation ------------ Latest stable release ===================== On most systems where you can run Python, the best way to install Sopel is to install `pip `_ and then ``pip install sopel``. Arch users can install the ``sopel`` package from the [community] repository, though new versions might take slightly longer to become available. Failing both of those options, you can grab the latest tarball `from GitHub `_ and follow the steps for installing from the latest source below. Latest source ============= First, either clone the repository with ``git clone git://github.com/sopel-irc/sopel.git`` or download a tarball `from GitHub `_. Note: Sopel requires Python 2.7.x or Python 3.3+ to run. On Python 2.7, Sopel requires ``backports.ssl_match_hostname`` to be installed. Use ``pip install backports.ssl_match_hostname`` or ``yum install python-backports.ssl_match_hostname`` to install it, or download and install it manually `from PyPI `_. In the source directory (whether cloned or from the tarball) run ``setup.py install``. You can then run ``sopel`` to configure and start the bot. Alternately, you can just run the ``sopel.py`` file in the source directory. Adding modules -------------- The easiest place to put new modules is in ``~/.sopel/modules``. Some newer modules are installable as packages; `search PyPI `_ for these. Many more modules written by other users can be found using your favorite search engine. Some older, unmaintained modules are available in the `sopel-extras `_ repository, but of course you can also write your own. A `tutorial `_ for creating new modules is available on Sopel's website. API documentation can be found online at https://sopel.chat/docs/, or you can create a local version by running ``make html`` in the ``docs`` directory. Further documentation --------------------- The `official website `_ includes such valuable information as a full listing of built-in `commands `_, `tutorials `_, `API documentation `_, and other `usage information `_. Questions? ---------- Join us in `#sopel `_ on Freenode. Credits ------- Contributors ============ This project exists thanks to all the people who contribute! `Become a contributor`__. .. image:: https://opencollective.com/sopel/contributors.svg?width=890&button=false :target: https://github.com/sopel-irc/sopel/graphs/contributors __ Contributor_ .. _Contributor: https://github.com/sopel-irc/sopel/blob/master/CONTRIBUTING.md Backers ======= Thank you to all our backers! `Become a backer`__. .. image:: https://opencollective.com/sopel/backers.svg?width=890 :target: https://opencollective.com/sopel#backers __ Backer_ .. _Backer: https://opencollective.com/sopel#backer Sponsors ======== Support Sopel by becoming a sponsor. Your logo will show up here with a link to your website. `Become a sponsor`__. .. image:: https://opencollective.com/sopel/sponsor/0/avatar.svg :target: https://opencollective.com/sopel/sponsor/0/website __ Sponsor_ .. _Sponsor: https://opencollective.com/sopel#sponsor .. |version| image:: https://img.shields.io/pypi/v/sopel.svg :target: https://pypi.python.org/pypi/sopel .. |build| image:: https://travis-ci.org/sopel-irc/sopel.svg?branch=master :target: https://travis-ci.org/sopel-irc/sopel .. |issues| image:: https://img.shields.io/github/issues/sopel-irc/sopel.svg :target: https://github.com/sopel-irc/sopel/issues .. |coverage-status| image:: https://coveralls.io/repos/github/sopel-irc/sopel/badge.svg?branch=master :target: https://coveralls.io/github/sopel-irc/sopel?branch=master .. |license| image:: https://img.shields.io/pypi/l/sopel.svg :target: https://github.com/sopel-irc/sopel/blob/master/COPYING .. |ocbackers| image:: https://opencollective.com/sopel/backers/badge.svg :alt: Backers on Open Collective :target: #backers .. |ocsponsors| image:: https://opencollective.com/sopel/sponsors/badge.svg :alt: Sponsors on Open Collective :target: #sponsors sopel-6.6.9/checkstyle.sh000077500000000000000000000040741347452002400153610ustar00rootroot00000000000000#!/bin/sh find_source_files() { find . -name '*.py' -size +0 -print | grep -ve './docs' -e 'env' -e './contrib' -e './conftest.py' } files=$(find_source_files) # These are acceptable (for now). 128 and 127 should be removed eventually. ignore='--ignore=E501,E128,E127' # These are ignored by default (and we want to keep them ignored) ignore=$ignore',W504' # These are forbidding certain __future__ imports. The plugin has errors both # for having and not having them; we want to always have them, so we ignore # the having them errors and keep the not having them errors. ignore=$ignore',FI50,FI51,FI52,FI53,FI54,FI55' # F12 is with_statement, which is already in 2.7. F15 requires and F55 forbids # generator_stop, which should probably be made mandatory at some point. ignore=$ignore',F12,F15,F55' # These are rules that are relatively new or have had their definitions tweaked # recently, so we'll forgive them until versions of PEP8 in various developers' # distros are updated ignore=$ignore',E265,E713,E111,E113,E402,E731' # For now, go through all the checking stages and only die at the end exit_code=0 if ! flake8 $ignore --filename=*.py $(find_source_files); then echo "ERROR: flake8 does not pass." exit_code=1 fi fail_coding=false for file in $(find_source_files); do line=$(head -n 1 $file) if echo $line | grep -q '#!/usr/bin/env python'; then line=$(head -n 2 $file | tail -n 1) fi if ! echo $line | grep -q '# coding=utf-8'; then echo $file fail_coding=true fi done if $fail_coding; then echo "ERROR: Above files do not have utf-8 coding declared." exit_code=1 fi # Find files which use the unicode type but (heuristically) don't make it py3 # safe fail_py3_unicode=false for file in $(find_source_files); do if grep -qle 'unicode(' -e 'class .*(unicode)' $file; then if ! grep -L 'unicode = str' $file; then fail_py3_unicode=true fi fi done if $fail_py3_unicode; then echo "ERROR: Above files use unicode() but do not make it safe for Python 3." exit_code=1 fi exit $exit_code sopel-6.6.9/ci_build.sh000077500000000000000000000010421347452002400147650ustar00rootroot00000000000000#!/bin/sh -x # This script performs most of the same steps as the Travis build. The build # doesn't actually run this script, since it uses Travis's ability to report # the 2.7 and 3.x builds separately. clean () { find . -name '*.pyc' -exec rm {} \; rm -rf build __pycache__ test/__pycache__ } if test -z $VIRTUAL_ENV; then SUDO=sudo fi clean $SUDO pip2 install -r dev-requirements.txt python2.7 pytest_run.py sopel test clean $SUDO pip3 install -r dev-requirements.txt python3 pytest_run.py sopel test clean ./checkstyle.sh clean sopel-6.6.9/conftest.py000066400000000000000000000002001347452002400150460ustar00rootroot00000000000000# This file lists files which should be ignored by pytest collect_ignore = ["setup.py", "sopel.py", "sopel/modules/ipython.py"] sopel-6.6.9/contrib/000077500000000000000000000000001347452002400143175ustar00rootroot00000000000000sopel-6.6.9/contrib/README000066400000000000000000000013441347452002400152010ustar00rootroot00000000000000This folder contains sopel.service and sopel.cfg designed to be distributed by 3rd party distrubtions such as Fedora Project or Arch Linux. sopel.cfg is a default configuration file for sopel, that assumes the OS is new enough to have /run and /usr/lib/tmpfiles.d sopel.service is a systemd service file that assumes you are using a rather recent Willie and has no multiple instance support (TODO). It also assumes that the system has a special user named sopel designated for running the bot and this user has access to /run/sopel (should be setup by sopel.conf in /usr/lib/tmpfiles.d), /var/log/sopel and /var/lib/sopel Default installation paths: sopel.cfg /etc sopel.conf /usr/lib/tmpfiles.d sopel.service /usr/lib/systemd/system sopel-6.6.9/contrib/release.sh000077500000000000000000000016441347452002400163030ustar00rootroot00000000000000#!/bin/sh # Runs through the steps to release a Sopel version. This is only useful to # the people with permissions to do so, of course. set -e cd $(dirname $0)/.. version=$(python -c "import sopel; print(sopel.__version__)") echo "Releasing Sopel version $version." echo "PyPI username:" read pypi_user echo "PyPI password:" read pypi_pass echo "sopel.chat username:" read server_user cat < ~/.pypirc [distutils] index-servers = pypi [pypi] username:$pypi_user password:$pypi_pass EOF echo "Building package and uploading to PyPI..." ./setup.py sdist upload --sign rm ~/.pypirc echo "Building docs..." cd docs make html echo "Setting up folders on sopel.chat..." ssh $server_user@sopel.chat "mkdir /var/www/sopel/$version; rm /var/www/sopel/docs; ln -s /var/www/sopel/$version/docs /var/www/sopel/docs" echo "Uploading docs..." scp -r build/html $server_user@sopel.chat:/var/www/sopel/$version/docs echo "Done!" sopel-6.6.9/contrib/rpm/000077500000000000000000000000001347452002400151155ustar00rootroot00000000000000sopel-6.6.9/contrib/rpm/makerpm.py000077500000000000000000000023231347452002400171260ustar00rootroot00000000000000#!/usr/bin/python import git import sys import os import os.path import time from subprocess import * import shutil repo = git.Repo(os.getcwd()) head_hash = repo.head.commit.hexsha[:7] now = time.strftime('%a %b %d %Y') version = '3.3' build = '0' if len(sys.argv)>1: build = sys.argv[1] print 'Generating archive...' f = open('sopel-%s.tar' % version, 'w') repo.archive(f, prefix='sopel-%s/' % version) f.close() print 'Building spec file..' spec_in = open('sopel.spec.in', 'r') spec_out = open('sopel.spec', 'w') for line in spec_in: newline = line.replace('#GITTAG#', head_hash) newline = newline.replace('#BUILD#', build) newline = newline.replace('#LONGDATE#', now) newline = newline.replace('#VERSION#', version) spec_out.write(newline) spec_in.close() spec_out.close() print 'Starting rpmbuild...' cmdline = 'rpmbuild --define="%_specdir @wd@" --define="%_rpmdir @wd@" --define="%_srcrpmdir @wd@" --define="%_sourcedir @wd@" -ba sopel.spec'.replace('@wd@', os.getcwd()) p = call(cmdline, shell=True) for item in os.listdir('noarch'): os.rename(os.path.join('noarch', item), item) print 'Cleaning...' os.removedirs('noarch') os.remove('sopel.spec') os.remove('sopel-%s.tar' % version) print 'Done' sopel-6.6.9/contrib/rpm/sopel.spec.in000066400000000000000000000045141347452002400175240ustar00rootroot00000000000000%{!?python_sitelib: %global python_sitelib %(%{__python} -c "from distutils.sysconfig import get_python_lib; print(get_python_lib())")} %define gittag #GITTAG# Name: sopel Version: #VERSION# Release: 0.#BUILD#%{gittag}%{?dist} Summary: Simple, lightweight and easy-to-use IRC Utility bot License: EFL 2.0 URL: https://sopel.chat/ Source0: %{name}-%{version}.tar BuildArch: noarch BuildRequires: python2-devel BuildRequires: python-sphinx BuildRequires: dos2unix BuildRequires: systemd Requires: pytz Requires: python-enchant Requires: pyOpenSSL Requires: python-praw Requires: python-backports-ssl_match_hostname %description Willie is a simple, lightweight, open source, easy-to-use IRC Utility bot. It is designed to be easy to use, run and extend. %prep %setup -q %build %{__python} setup.py build dos2unix CREDITS cd docs/ make singlehtml mv build/singlehtml build/api-docs rm build/api-docs/.buildinfo %install %{__python} setup.py install -O1 --skip-build --root $RPM_BUILD_ROOT mkdir -p %{buildroot}%{_prefix}/lib/tmpfiles.d install -m 0644 contrib/%{name}.conf %{buildroot}%{_prefix}/lib/tmpfiles.d/%{name}.conf mkdir -p %{buildroot}/run install -d -m 0755 %{buildroot}/run/%{name}/ mkdir -p %{buildroot}%{_sysconfdir} install -m 0644 contrib/%{name}.cfg %{buildroot}%{_sysconfdir}/%{name}.cfg mkdir -p %{buildroot}%{_unitdir} install -m 0644 contrib/%{name}.service %{buildroot}%{_unitdir}/%{name}.service mkdir -p %{buildroot}/var/log/%{name} mkdir -p %{buildroot}/var/lib/%{name} %files %doc README.rst COPYING CREDITS NEWS docs/build/api-docs %config(noreplace) %{_sysconfdir}/%{name}.cfg %{python_sitelib}/* %{_bindir}/sopel %dir %attr(-,%{name}, %{name})/run/%{name}/ %{_prefix}/lib/tmpfiles.d/%{name}.conf %{_unitdir}/%{name}.service %dir %attr(-,%{name}, %{name})/var/lib/%{name}/ %dir %attr(-,%{name}, %{name})/var/log/%{name}/ %pre getent group %{name} >/dev/null || groupadd -r %{name} getent passwd %{name} >/dev/null || \ useradd -r -g %{name} -d /var/lib/%{name} -s /sbin/nologin \ -c "sopel ircbot account " %{name} exit 0 %post %systemd_post sopel.service %preun %systemd_preun sopel.service %postun %systemd_postun sopel.service %changelog * #LONGDATE# Elad Alfassa #VERSION#0.#BUILD##GITTAG# - Update from git sopel-6.6.9/contrib/sopel.cfg000066400000000000000000000011171347452002400161220ustar00rootroot00000000000000# Default sopel configuration file for Fedora # For information related to possible configuration values see # https://sopel.chat/docs/config.html#the-core-configuration-section # https://sopel.chat/usage/module-configuration/ # # IMPORTANT NOTE! # You must delete the not_configured line in order for the bot to work, # otherwise it will refuse to start. [core] nick=sopel not_configured=True host=chat.freenode.net port=6697 use_ssl=True verify_ssl=True owner= logdir=/var/log/sopel pid_dir=/run/sopel homedir=/var/lib/sopel [db] userdb_type='sqlite' userdb_file='/var/lib/sopel/user.db sopel-6.6.9/contrib/sopel.conf000066400000000000000000000001021347452002400163010ustar00rootroot00000000000000# Sopel temporary directory setup d /run/sopel 0755 sopel sopel - sopel-6.6.9/contrib/sopel.service000066400000000000000000000005111347452002400170200ustar00rootroot00000000000000[Unit] Description=Sopel IRC bot Documentation=https://sopel.chat/ After=network.target [Service] Type=simple User=sopel PIDFile=/run/sopel/sopel-sopel.pid ExecStart=/usr/bin/sopel -c /etc/sopel.cfg Restart=on-failure RestartPreventExitStatus=2 RestartSec=30 Environment=LC_ALL=en_US.UTF-8 [Install] WantedBy=multi-user.target sopel-6.6.9/contrib/suppress-warnings.py000066400000000000000000000002201347452002400203750ustar00rootroot00000000000000# coding=utf-8 # suppress-warnings.py # Suppress iPython's DeprecationWarnings on Sopel start import warnings warnings.filterwarnings('ignore') sopel-6.6.9/contrib/update_db.py000077500000000000000000000040651347452002400166300ustar00rootroot00000000000000#!/usr/bin/env python # coding=utf8 """update_db.py - A basic migration script for 3.x/4.x databases to 5.0. Usage: ./update_db.py /path/to/config Note that it takes the config, rather than the db. Currently, this only supports text fields, since that's all the stock modules used. It migrates in place, leaving old tables there, but you should still be sure to back up everything first to be safe.""" import sqlite3 import sys import willie import willie.db import willie.config def main(): if willie.__version__.split('.', 1)[0] != '5': print('Must have Willie 5 installed to run migration script.') return if len(sys.argv) != 2: print('Usage: ./update_db.py /path/to/config') config = willie.config.Config(sys.argv[1]) filename = config.db.userdb_file if not filename: filename = os.path.splitext(config.filename)[0] + '.db' elif not config.core.db_filename: print('Filename is only configured with old setting. Make sure you ' 'set the db_filename setting in [core].') print('Migrating db file {}'.format(filename)) new_db = willie.db.WillieDB(config) conn = sqlite3.connect(new_db.filename) cur = conn.cursor() table_info = cur.execute('PRAGMA table_info(preferences)').fetchall() for column in table_info: old_name = column[1] new_name = old_name if old_name != 'tz' else 'timezone' if old_name == 'name': continue if column[2] != 'text': msg = "Can't migrate non-text field {}. Please do so manually" print(msg.format(old_name)) continue print('Migrating column {}'.format(old_name)) values = cur.execute( 'SELECT name, {} FROM preferences WHERE {} NOT NULL' .format(old_name, old_name)).fetchall() for value in values: if value[0][0] in '+%@&~#&': new_db.set_channel_value(value[0], new_name, value[1]) else: new_db.set_nick_value(value[0], new_name, value[1]) if __name__ == '__main__': main() sopel-6.6.9/dev-requirements.txt000066400000000000000000000002161347452002400167160ustar00rootroot00000000000000pytest coveralls flake8<3.6.0; python_version == '3.3' flake8>=3.6.0,<3.7.0; python_version != '3.3' setuptools<40.0; python_version == '3.3' sopel-6.6.9/docs/000077500000000000000000000000001347452002400136075ustar00rootroot00000000000000sopel-6.6.9/docs/Makefile000066400000000000000000000130111347452002400152430ustar00rootroot00000000000000# Makefile for Sphinx documentation # # You can set these variables from the command line. SPHINXOPTS = SPHINXBUILD = sphinx-build PAPER = BUILDDIR = build # Internal variables. PAPEROPT_a4 = -D latex_paper_size=a4 PAPEROPT_letter = -D latex_paper_size=letter ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source # the i18n builder cannot share the environment and doctrees with the others I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext help: @echo "Please use \`make ' where is one of" @echo " html to make standalone HTML files" @echo " dirhtml to make HTML files named index.html in directories" @echo " singlehtml to make a single large HTML file" @echo " pickle to make pickle files" @echo " json to make JSON files" @echo " htmlhelp to make HTML files and a HTML help project" @echo " qthelp to make HTML files and a qthelp project" @echo " devhelp to make HTML files and a Devhelp project" @echo " epub to make an epub" @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" @echo " latexpdf to make LaTeX files and run them through pdflatex" @echo " text to make text files" @echo " man to make manual pages" @echo " texinfo to make Texinfo files" @echo " info to make Texinfo files and run them through makeinfo" @echo " gettext to make PO message catalogs" @echo " changes to make an overview of all changed/added/deprecated items" @echo " linkcheck to check all external links for integrity" @echo " doctest to run all doctests embedded in the documentation (if enabled)" clean: -rm -rf $(BUILDDIR)/* html: $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." dirhtml: $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." singlehtml: $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml @echo @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." pickle: $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle @echo @echo "Build finished; now you can process the pickle files." json: $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json @echo @echo "Build finished; now you can process the JSON files." htmlhelp: $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp @echo @echo "Build finished; now you can run HTML Help Workshop with the" \ ".hhp project file in $(BUILDDIR)/htmlhelp." qthelp: $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp @echo @echo "Build finished; now you can run "qcollectiongenerator" with the" \ ".qhcp project file in $(BUILDDIR)/qthelp, like this:" @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/PhennyJenniWillieIRCBot.qhcp" @echo "To view the help file:" @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/PhennyJenniWillieIRCBot.qhc" devhelp: $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp @echo @echo "Build finished." @echo "To view the help file:" @echo "# mkdir -p $$HOME/.local/share/devhelp/PhennyJenniWillieIRCBot" @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/PhennyJenniWillieIRCBot" @echo "# devhelp" epub: $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub @echo @echo "Build finished. The epub file is in $(BUILDDIR)/epub." latex: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." @echo "Run \`make' in that directory to run these through (pdf)latex" \ "(use \`make latexpdf' here to do that automatically)." latexpdf: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo "Running LaTeX files through pdflatex..." $(MAKE) -C $(BUILDDIR)/latex all-pdf @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." text: $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text @echo @echo "Build finished. The text files are in $(BUILDDIR)/text." man: $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man @echo @echo "Build finished. The manual pages are in $(BUILDDIR)/man." texinfo: $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo @echo @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." @echo "Run \`make' in that directory to run these through makeinfo" \ "(use \`make info' here to do that automatically)." info: $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo @echo "Running Texinfo files through makeinfo..." make -C $(BUILDDIR)/texinfo info @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." gettext: $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale @echo @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." changes: $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes @echo @echo "The overview file is in $(BUILDDIR)/changes." linkcheck: $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck @echo @echo "Link check complete; look for any errors in the above output " \ "or in $(BUILDDIR)/linkcheck/output.txt." doctest: $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest @echo "Testing of doctests in the sources finished, look at the " \ "results in $(BUILDDIR)/doctest/output.txt." sopel-6.6.9/docs/make.bat000066400000000000000000000120231347452002400152120ustar00rootroot00000000000000@ECHO OFF REM Command file for Sphinx documentation if "%SPHINXBUILD%" == "" ( set SPHINXBUILD=sphinx-build ) set BUILDDIR=build set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% source set I18NSPHINXOPTS=%SPHINXOPTS% source if NOT "%PAPER%" == "" ( set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% ) if "%1" == "" goto help if "%1" == "help" ( :help echo.Please use `make ^` where ^ is one of echo. html to make standalone HTML files echo. dirhtml to make HTML files named index.html in directories echo. singlehtml to make a single large HTML file echo. pickle to make pickle files echo. json to make JSON files echo. htmlhelp to make HTML files and a HTML help project echo. qthelp to make HTML files and a qthelp project echo. devhelp to make HTML files and a Devhelp project echo. epub to make an epub echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter echo. text to make text files echo. man to make manual pages echo. texinfo to make Texinfo files echo. gettext to make PO message catalogs echo. changes to make an overview over all changed/added/deprecated items echo. linkcheck to check all external links for integrity echo. doctest to run all doctests embedded in the documentation if enabled goto end ) if "%1" == "clean" ( for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i del /q /s %BUILDDIR%\* goto end ) if "%1" == "html" ( %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html if errorlevel 1 exit /b 1 echo. echo.Build finished. The HTML pages are in %BUILDDIR%/html. goto end ) if "%1" == "dirhtml" ( %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml if errorlevel 1 exit /b 1 echo. echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. goto end ) if "%1" == "singlehtml" ( %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml if errorlevel 1 exit /b 1 echo. echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. goto end ) if "%1" == "pickle" ( %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle if errorlevel 1 exit /b 1 echo. echo.Build finished; now you can process the pickle files. goto end ) if "%1" == "json" ( %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json if errorlevel 1 exit /b 1 echo. echo.Build finished; now you can process the JSON files. goto end ) if "%1" == "htmlhelp" ( %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp if errorlevel 1 exit /b 1 echo. echo.Build finished; now you can run HTML Help Workshop with the ^ .hhp project file in %BUILDDIR%/htmlhelp. goto end ) if "%1" == "qthelp" ( %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp if errorlevel 1 exit /b 1 echo. echo.Build finished; now you can run "qcollectiongenerator" with the ^ .qhcp project file in %BUILDDIR%/qthelp, like this: echo.^> qcollectiongenerator %BUILDDIR%\qthelp\PhennyJenniWillieIRCBot.qhcp echo.To view the help file: echo.^> assistant -collectionFile %BUILDDIR%\qthelp\PhennyJenniWillieIRCBot.ghc goto end ) if "%1" == "devhelp" ( %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp if errorlevel 1 exit /b 1 echo. echo.Build finished. goto end ) if "%1" == "epub" ( %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub if errorlevel 1 exit /b 1 echo. echo.Build finished. The epub file is in %BUILDDIR%/epub. goto end ) if "%1" == "latex" ( %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex if errorlevel 1 exit /b 1 echo. echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. goto end ) if "%1" == "text" ( %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text if errorlevel 1 exit /b 1 echo. echo.Build finished. The text files are in %BUILDDIR%/text. goto end ) if "%1" == "man" ( %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man if errorlevel 1 exit /b 1 echo. echo.Build finished. The manual pages are in %BUILDDIR%/man. goto end ) if "%1" == "texinfo" ( %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo if errorlevel 1 exit /b 1 echo. echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. goto end ) if "%1" == "gettext" ( %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale if errorlevel 1 exit /b 1 echo. echo.Build finished. The message catalogs are in %BUILDDIR%/locale. goto end ) if "%1" == "changes" ( %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes if errorlevel 1 exit /b 1 echo. echo.The overview file is in %BUILDDIR%/changes. goto end ) if "%1" == "linkcheck" ( %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck if errorlevel 1 exit /b 1 echo. echo.Link check complete; look for any errors in the above output ^ or in %BUILDDIR%/linkcheck/output.txt. goto end ) if "%1" == "doctest" ( %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest if errorlevel 1 exit /b 1 echo. echo.Testing of doctests in the sources finished, look at the ^ results in %BUILDDIR%/doctest/output.txt. goto end ) :end sopel-6.6.9/docs/source/000077500000000000000000000000001347452002400151075ustar00rootroot00000000000000sopel-6.6.9/docs/source/_static/000077500000000000000000000000001347452002400165355ustar00rootroot00000000000000sopel-6.6.9/docs/source/_static/favicon.ico000066400000000000000000000353561347452002400206720ustar00rootroot0000000000000000 %6  % h6(0` $8oo8)tt)+02u~CWOG8&9{< {D Ga C ,sg,Q]xsD[bkdCa   !PC*<\WN}!@-*~0!K@!1+E**NS9N+t8u+3+8D,9oDrCo,"y "Mx]I~A]Q[zPQy]P]@I]xN# x",oCqCo8,E9+3+t7u*N9RN++E*1!@J!1~*-?!~MW\<*CP!  aBdk`ZCsx]P,gs,  C aF D{ <{9&8GOWC~u20,*uu*9oo9<?????????<???( @ *mn*08  ,h \0"O7g&7C o~$ 5n7KF@8*%7ra  Xw F#9P(9$"#*Y Y+mTnnm6 52?FY4:95YF?26 5nnnSn*Y Y+$"#9(P9"F wX ar7%*8AGL7m5 $~o C7&h7O"0\ h,  80*nn*x?>|?p(  7I aipvvrFX~&J X J)5)x wjvvjv x)5)J X J&~XFsvvpja I71>8|3sopel-6.6.9/docs/source/_static/sopel.png000066400000000000000000000303571347452002400203750ustar00rootroot00000000000000PNG  IHDRJJ{) pHYs  tIME,R IDATxwTյǿgf{ij(6р * OK,5`˳D{nL4F ƈ(`X(ElHΙ ͜9g|3p^gYk0l np:iN[0VcZ hw?~k}րY N9g)UQ&PA+_ @ &50S ۫VQT(P=|bt^X_bEG ɏjERM@?09.6Y=p䴫P*w?+~`Plypf'ERC98hfiU0c}NQT"43#@ZmRT2`^>QP*$pj20LWsP*w`pP{`Aj8TUsP*Z>jH1z޳25 RXڀ{"8 jH-0{AMB4$$\pjTyOBͿ*JRs1#NE}VB!{%p!ZkAzRR 5~GeC<`88wCS5 e I\ }J}198B.ȎWsPc q-pCis'dFZUT(Ts(snFtPF$7 D ǁC2jy4$tW[(P P3RMBvEE zHίRmQP)- }# *REj ˂R`Im|f&$fofBf&Vͣwrw0J>&3iP%BRڀ{3pzJ02OѤ*Ѱ{kک9 VuU(#f@_Rj Λ~E2$!u=87I+ciVogr *vp@҄(+jJi`R[(`" >BR(e\Pp^s: phR(ADպi* σo hS 8KLPO,*FBbԌZؓ3:c=9ZNyAp^G7a~5'! _X,յ>UW߲Q`a$3MY\o nzn0kg1`(!o:S2F!iǖTP,f62 =+*eSy̝%P}P Lˊ@t p`FXқ(!Y(NSF(SWs>P3Kh[0ם_cF_i܃ OyCz6? QU( LAU(~쪏^fo nc;~=׏7|q|dRRzHJf$BϚ%pS`<0O D\l}e 'g7G7@?mc։"z7n-ִ)&7IpO`倱8̭Gp3*u'=x ]`۷@ eSC{E sBp#w`8eԏ w.6Il (|9r0rH?9QuNw!smBq`^VQo!{p/x6α~Qk n;0if;ͺxl9Be͗`s| =/1ǹ+m$ a\B~$r r pgK{Dp?Wa݁Mi R2ckx]( {'0 {JpDt v>5bCjvoj*%$[B{=LC\ 8Ȏ_bGFi5s+"g$3.my:=!3X(rhs_|EYN?"Y{9*"ibϖL񶯙1$y" edl82}!3Mhv&Eyv`|Jq?OZ]LS $EFq$v&*GPnۀ*܊gAny<1&T$FIHbwqhOD£,rad-,c>M\i zH 5!(H"WDDrǘ94?Jv@Dž QgL$kfr"5MEr鞧"Y`1jt?D<ʤ{<&ymbWH/s+30ó<lY](@,}:([\'v#YF % cs!?%$Bx [(=xw lX'_$Vu }m]!P2u$8Ccb]&]Ω`OB8HAcB5kT(110%| e +[\MN&-PI10t8kvتNCTFzO0f6Fb,KMۃ̵1Q}p;l"6dWƣkJQuLӲoW ;HXz" fl %0gYEdp_Az1ɅVvD{`6dxs7sue;pBvC3BZ}CtOS/TJU&cv% +T-ȣLJHa"nH>" *( -mLg{y%diU}G,kD#4L]$\$|HɋC$ * s\pb8Cw`}G^դв^x/z  9*l{ 0<ܝ2APJq`*K] ex ӛc\)#1u $ as%FrD{i`G!">2\i>ks |LJ5#+!be:ȕ!h ]j OPo@;o_(s'jGmJƠ5H jf yps]u+ͧz>@R/izb(F R:,P[(MWm2RL :{` uqPuPVQFv `8AǺҌUӲ eҝG=P xx8X҄8GN[. e50 FCQ6N HӠl=ob }$6Wri Ǣ ҴKi+5i2* 8=_ vVqPjX)ģlD!;j'uwHD j}/FNiaQNoNP!mo9_,c\j}WmwUjRhǤU h-=7%anjkn*խ!j+ߣ;,Abn?pʱ'(6wl0'u"v1ـvJA_pjpsjy9 jV 9sΚt߫T;xYeK9&8/-̯Q|8x,_6j&I)­'􀓱JI?,^`ӫ| 26:c( Oc PS(x11)*fuL{pN2._$<'BH1Y+$#׆n*N#yR~r' n&51'K5.AȦ~)B`Jڥ/{-`详wB8sB^ *0 E7O {T3 U#I\Zj܀e{\U_8JPPM4:4/*&et\n>2Jp\AlK5 ĜΫPt`6_CcF^hV'IۨP6+ϡŋMXcx5[̘\-ȣ9&p*sY5KQxX(GB=ʦ x0c3~5MAٗ+c@BG)%khY,~MݦetXY%л,F mp>vG:B:%ǣt:,:9liߥ؅NRM>t1{N!gջӜIΊ>uOZ?ot '?v–zK-YP2GB?؊ uX?) [yЇޫPZBJ-a38 pɅap1~͊y6 fN]%)A4Pw >h.P DX4*JX ȩvTPvOf k4q<@)l ?QVRQͬ45jG)lXRHvE)g {u{PZBiewqp噮|gl -J91j4"=JKR DAWm|3Q*#&J2< s:@YgR *:bU%hdյVAw^J&h,'OI&kU ^AFY&3^]B.(ʆ ˅LG9S *vq4tA91 [ifQBиK, $?+^Mt*mVT]GPC=eP.̻¦w U(9A迷QjBjy :UYDMU2zC-8XY9CǼH\l q`a`sBM5MpG z,o<<c_i ^o p)%}wcO?Q}~+ z`瀮90MX(XP:)P P3pm ӌ (_UzxƑ>Y%wׂQKBoes,}_JV"@^X͡@;:|nk0?LڷtHB\?Àm1sn ĸFeCK"qA)&]𵐗̕CQz\ڠJ{& WҧV&j'6#, CG5"_q<-6y(WPvDnu=.&L5W ρY򌑿F"8SؤA4r* &!oP r fĒہJp2L"YG(ÅU3b{D6w_0ĹInl(RC`?aF1 jfāc)͞/=jj(~ofxf@~G,8xmLH( Ҹx-:@j +T-R-`vs(J -$w(.FޑF2ޭZ"+ͥU3wLdB}"@茶Ǒ꧚"K8 7MzJe+p }Lo_ "¦mP8,`}%^G`Y<:b\BEpjH6F(p6LErn "4@M#23KO"[{jMd?]XcVv֛"%kvr>P"NcIDAT; ,H${cwH&_~(PohȌdI7=9Ac~cqZH̾)H`aWX$?=I"ׯNĨZ,G['@=MQ$[ 3=ɽCn ޞPL!.U16;əÆ^$a& Q1I <9X`7<;#L;Ƚ'|{&̌V |ؾilLllb CuoNh3q ޡ-m'FN>Av򺨆Pe!"LVsG E/K/ H恛1I"Y$Һ~`qc2x1iA*߰Q۾c|vV-(*P Ε1z^ xza!GH$?+;پGL.;)HS(p^ wm[}pd؂ddv9st{ 2M{e ?7`NwDnS5z5P %u8#W ɂ2Eޥ_3 ?_}S#܆aӌ1{E2}28b,S$ ӷl0+/B=pp~D#ױ6VV$bu,qc [ n6z+ZrV>M舤hly#`W/H!$o_ . LP}Qw!ۘr{X;lU@z% (O_ X$ȏg_B#{ (|)U#ɀ/d)`琺9%##B6]$p&BbT =ݻ7.FVEW]3q 0X7Ll)V(^"zU!8Ǫ8{ف&@~ƨdY|e11Yͤ+mI ,WKA``8۫6wU uJ1WbTӪ;\ /Y h \GS5E #AR DoA%ȼUKo ^/`TE?7jp wjHTAU7p]`=#x>"|s"3=UPȳ^;` 3}Kv=7r`QI[{<8Bhn~Y(no-5OiI=Ӊ؅W^Lm۱mKfv}%tDV9(%lL[j,OWt* 5` gpXnSs̞kS ~`牆`/l#~@Eڿȗ6b{ރMUOoPlS, @\S?JM,9Pݿ\L ͠U6tek}of5أq{ns&xJvBYXZ=j.j xXHGCSp-lL/!KCw^.ݱud}חO| mslE]iZA-W e"5vΰYslD7m)tY{:Ji:+:U2$)dx9vU)?o껍/oB3xY!ۀ{8&(5`oZ'*T ,ͥ!?Ftaȃs&xK DTtbK֔pNFy! `WEٔ ~0NX/)*75R)=ˁ?u/?;,fdDU(JSR63|a|K8QT{:pOV9B>Zzw0?T30o[Y`0L> =A$0C!qlX G}% C( B0FA!{6xjMM9dlVq{@s)ɀ$gOU(jnϙbϢhu! 0T8:GipYRKJѐJ oPlG^*|\#u`p~ |M )5>ƒ8IjB4P*znopNs"YM4 Ԩ)T(%8j| Pƶg@Di!u&T-PSPJz#Pi[#{GPƋ=(:GejL;2*Qc HfAO,JY\0w//(E" <R! ep!L/_C3+0+ 3CMBB8'~ Z`K 2ϫ)) RCha=jph (Iy'PǼ zB2 a衶P` hj^O[JR$ooI('ڭ(e 8wAYT |8G-&6;!B2=kC y0/ZқP!e@~(pPPjQ< {FU(lʴ+\l5+y2hRT(h%hf%G (CRiгI \xD$k< hFJɴ ( &40 12#jJl@?@TuSP*8 s:8gG`AvCR))e]!7Jf8$Cl5 JһA~ 8G}jR|uH$*Jp!S$9hfiycq d,*~eV< X>֠QT(ϯ]m{Ƃ7jP*ʏTp] #r#yT*̂T`vERi*iHj3 (]tcپVB|+**JTؒNh-$ڀi߉LVU뿝Ր଀RH·Rɣt7IENDB`sopel-6.6.9/docs/source/api.rst000066400000000000000000000016551347452002400164210ustar00rootroot00000000000000Additional API features ======================= Sopel includes a number of additional functions that are useful for various common IRC tasks. Note that ``sopel.web`` was deprecated in 6.2.0, and is not included in this documentation, but is still in use in many modules. It's highly recommended that you switch to `requests `_ instead. .. contents:: sopel.tools ------------ .. automodule:: sopel.tools :members: sopel.tools.time ---------------- .. automodule:: sopel.tools.time :members: sopel.tools.calculation ----------------------- .. automodule:: sopel.tools.calculation :members: sopel.tools.target ------------------ .. automodule:: sopel.tools.target :members: sopel.tools.events ------------------ .. autoclass:: sopel.tools.events :members: :undoc-members: sopel.formatting ----------------- .. automodule:: sopel.formatting :members: :undoc-members: sopel-6.6.9/docs/source/bot.rst000066400000000000000000000005211347452002400164230ustar00rootroot00000000000000The bot and its state ===================== .. autoclass:: sopel.bot.Sopel :members: .. py:attribute:: nick Sopel's current nick. Changing this while Sopel is running is unsupported. .. py:attribute:: user Sopel's user/ident. .. py:attribute:: name Sopel's "real name", as used for whois. sopel-6.6.9/docs/source/conf.py000066400000000000000000000201761347452002400164140ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # Sopel IRC Bot documentation build configuration file, created by # sphinx-quickstart on Mon Jul 16 23:45:29 2012. # # This file is execfile()d with the current directory set to its containing dir. # # Note that not all possible configuration values are present in this # autogenerated file. # # All configuration values have a default; values that are commented out # serve to show the default. import sys, os parentdir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) os.sys.path.insert(0,parentdir) from sopel import __version__ # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. #sys.path.insert(0, os.path.abspath('.')) # -- General configuration ----------------------------------------------------- # If your documentation needs a minimal Sphinx version, state it here. #needs_sphinx = '1.0' # Add any Sphinx extension module names here, as strings. They can be extensions # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. extensions = ['sphinx.ext.autodoc', 'sphinx.ext.intersphinx'] intersphinx_mapping = {'python': ('https://docs.python.org/3', None)} # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] # The suffix of source filenames. source_suffix = '.rst' # The encoding of source files. #source_encoding = 'utf-8-sig' # The master toctree document. master_doc = 'index' # General information about the project. project = u'Sopel' copyright = u'2012-2019, Sopel contributors' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # # The short X.Y version. version = __version__ # The full version, including alpha/beta/rc tags. release = __version__ # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. #language = None # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: #today = '' # Else, today_fmt is used as the format for a strftime call. #today_fmt = '%B %d, %Y' # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. exclude_patterns = [] # The reST default role (used for this markup: `text`) to use for all documents. #default_role = None # If true, '()' will be appended to :func: etc. cross-reference text. #add_function_parentheses = True # If true, the current module name will be prepended to all description # unit titles (such as .. function::). #add_module_names = True # If true, sectionauthor and moduleauthor directives will be shown in the # output. They are ignored by default. #show_authors = False # The name of the Pygments (syntax highlighting) style to use. pygments_style = 'sphinx' # A list of ignored prefixes for module index sorting. #modindex_common_prefix = [] # -- Options for HTML output --------------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. html_theme = 'alabaster' # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. html_theme_options = { 'logo': 'sopel.png', 'logo_name': True, 'logo_text_align': 'center', 'description': 'A Python IRC bot framework.', 'donate_url': 'https://opencollective.com/sopel', 'extra_nav_links': { 'Back to main site': 'https://sopel.chat/', } } # Add any paths that contain custom themes here, relative to this directory. #html_theme_path = [] # The name for this set of Sphinx documents. If None, it defaults to # " v documentation". #html_title = None # A shorter title for the navigation bar. Default is the same as html_title. #html_short_title = None # The name of an image file (relative to this directory) to place at the top # of the sidebar. #html_logo = None # The name of an image file (within the static path) to use as favicon of the # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 # pixels large. html_favicon = '_static/favicon.ico' # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ['_static'] # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. #html_last_updated_fmt = '%b %d, %Y' # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. #html_use_smartypants = True # Custom sidebar templates, maps document names to template names. #html_sidebars = {} # Additional templates that should be rendered to pages, maps page names to # template names. #html_additional_pages = {} # If false, no module index is generated. #html_domain_indices = True # If false, no index is generated. #html_use_index = True # If true, the index is split into individual pages for each letter. #html_split_index = False # If true, links to the reST sources are added to the pages. html_show_sourcelink = False # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. #html_show_sphinx = True # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. #html_show_copyright = True # If true, an OpenSearch description file will be output, and all pages will # contain a tag referring to it. The value of this option must be the # base URL from which the finished HTML is served. #html_use_opensearch = '' # This is the file name suffix for HTML files (e.g. ".xhtml"). #html_file_suffix = None # Output file base name for HTML help builder. htmlhelp_basename = 'sopel' # -- Options for LaTeX output -------------------------------------------------- latex_elements = { # The paper size ('letterpaper' or 'a4paper'). #'papersize': 'letterpaper', # The font size ('10pt', '11pt' or '12pt'). #'pointsize': '10pt', # Additional stuff for the LaTeX preamble. #'preamble': '', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, author, documentclass [howto/manual]). latex_documents = [ ('index', 'sopel.tex', u'Sopel IRC Bot Documentation', u'Sopel contributors', 'manual'), ] # The name of an image file (relative to this directory) to place at the top of # the title page. #latex_logo = None # For "manual" documents, if this is true, then toplevel headings are parts, # not chapters. #latex_use_parts = False # If true, show page references after internal links. #latex_show_pagerefs = False # If true, show URL addresses after external links. #latex_show_urls = False # Documents to append as an appendix to all manuals. #latex_appendices = [] # If false, no module index is generated. #latex_domain_indices = True # -- Options for manual page output -------------------------------------------- # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ ('index', 'sopel', u'Sopel IRC Bot Documentation', [u'Sopel contributors'], 1) ] # If true, show URL addresses after external links. #man_show_urls = False # -- Options for Texinfo output ------------------------------------------------ # Grouping the document tree into Texinfo files. List of tuples # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ ('index', 'sopel', u'Sopel IRC Bot Documentation', u'Sopel contributors', 'SopelIRCBot', 'Simple, extendible IRC bot.', 'Miscellaneous'), ] # Documents to append as an appendix to all manuals. #texinfo_appendices = [] # If false, no module index is generated. #texinfo_domain_indices = True # How to display URL addresses: 'footnote', 'no', or 'inline'. #texinfo_show_urls = 'footnote' sopel-6.6.9/docs/source/config.rst000066400000000000000000000005261347452002400171110ustar00rootroot00000000000000Configuration functionality =========================== .. automodule:: sopel.config :members: :undoc-members: .. automodule:: sopel.config.types :members: :undoc-members: The [core] configuration section -------------------------------- .. autoclass:: sopel.config.core_section.CoreSection :members: :undoc-members: sopel-6.6.9/docs/source/db.rst000066400000000000000000000001151347452002400162230ustar00rootroot00000000000000The bot's database ================== .. automodule:: sopel.db :members: sopel-6.6.9/docs/source/index.rst000066400000000000000000000012031347452002400167440ustar00rootroot00000000000000.. title:: Sopel IRC Bot Sopel IRC Bot ============= `Sopel `_ is a Python IRC bot framework. It is designed to enable easily writing new utilities and features for your IRC channels. Quick links ----------- * `Latest releases `_ * `Source code `_ * `Tutorials `_ .. Eventually, add install instructions and Hello world here, and move the tutorial into pages following this one. Documentation ------------- .. toctree:: :titlesonly: plugin bot trigger config db api sopel-6.6.9/docs/source/plugin.rst000066400000000000000000000060721347452002400171440ustar00rootroot00000000000000Plugin structure ================ A Sopel plugin consists of a Python module containing one or more ``callable``\s. It may optionally also contain ``configure``, ``setup``, and ``shutdown`` hooks. .. py:method:: callable(bot, trigger) A callable is any function which takes as its arguments a :class:`sopel.bot.Sopel` object and a :class:`sopel.trigger.Trigger` object, and is wrapped with appropriate decorators from :mod:`sopel.module`. The ``bot`` provides the ability to send messages to the network and check the state of the bot. The ``trigger`` provides information about the line which triggered this function to be called. The return value of these function is ignored, unless it is :const:`sopel.module.NOLIMIT`, in which case rate limiting will not be applied for that call. Note that the name can, and should, be anything - it doesn't need to be called "callable". .. py:method:: setup(bot) This is an optional function of a plugin, which will be called while the module is being loaded. The purpose of this function is to perform whatever actions are needed to allow a module to function properly (e.g, ensuring that the appropriate configuration variables exist and are set). Note that this normally occurs prior to connection to the server, so the behavior of the messaging functions on the :class:`sopel.bot.Sopel` object it's passed is undefined. Throwing an exception from this function (such as a :exc:`sopel.config.ConfigurationError`) will prevent any callables in the module from being registered, and provide an error message to the user. This is useful when requiring the presence of configuration values or making other environmental requirements. The bot will not continue loading modules or connecting during the execution of this function. As such, an infinite loop (such as an unthreaded polling loop) will cause the bot to hang. .. py:method:: shutdown(bot) This is an optional function of a module, which will be called while the bot is quitting. Note that this normally occurs after closing connection to the server, so the behavior of the messaging functions on the :class:`sopel.bot.Sopel` object it's passed is undefined. The purpose of this function is to perform whatever actions are needed to allow a module to properly clean up (e.g, ensuring that any temporary cache files are deleted). The bot will not continue notifying other modules or continue quitting during the execution of this function. As such, an infinite loop (such as an unthreaded polling loop) will cause the bot to hang. .. versionadded:: 4.1 .. py:method:: configure(config) This is an optional function of a module, which will be called during the user's setup of the bot. It's intended purpose is to use the methods of the passed :class:`sopel.config.Config` object in order to create the configuration variables it needs to function properly. .. versionadded:: 3.0 sopel.module ------------ .. automodule:: sopel.module :members: sopel-6.6.9/docs/source/trigger.rst000066400000000000000000000001061347452002400173010ustar00rootroot00000000000000Triggers ======== .. autoclass:: sopel.trigger.Trigger :members: sopel-6.6.9/docs/willie.man000066400000000000000000000064151347452002400155770ustar00rootroot00000000000000.\" Man page for willie .TH Willie 1 "2013-15-01" "Linux" "Willie IRC Bot" .SH NAME willie - A highly customizable Internet Relay Chat bot .SH SYNOPSIS .\" Options to actually run / quit .B willie .RB [ \-q | \--quit ] .RB [ \-k | \--kill ] .RB [ \-d | \--fork ] .RB [ \--quiet ] .RB [ -c .IR filename .RB | \--config= .IR filename ] .\" Options to configure or get help .br .B willie .RB [ \-h | \--help ] .br .B willie .RB [ \-m | \--migrate ] .br .B willie .RB [ \-w | \--configure-all ] .RB [ \--configure-modules ] .RB [ \--configure-database ] .SH DESCRIPTION .B willie runs a bot that provides some useful (and some useless) commands in one or more Internet Relay Chat channels. It reads configuration options, such as the network to connect to, channels to join, and more from a config file specified by the .B -c option. If no config file is specified, .B ~/.willie/default.cfg is used. If the configuration file being used does not exist, a brief configuration wizard will be run to set up the basic options, and optionally set up a database for user data and any modules that may need configuration. .P In most cases, simply running .B willie will be sufficient; the bot will start after initial configuration, and will run until it is told to quit through IRC. .SH OPTIONS .TP 5 \-h or --help Show a brief summary of these options. .TP \-c .IR filename or --config= .IR filename .br Run Willie with the configuration options from the specified file. An absolute pathname can be given, or the name of a file within the configuration directory may also be given. .B ~/.willie is the preferred location, but this and .B -c will also look in .B ~/.jenni and .B ~/.phenny for legacy reasons. When the suffix of the file is .B .cfg , it may be omitted. .TP \-d or --fork Run Willie in the background. The bot will safely run in the background, and can later be killed with .B -q or .B -d \. .TP \-q or --quit Gracefully quit a daemonized instance of Willie. This option determines the instance to quit by the name of the config file given by the .B -c option, if used, so the same argument must be given with .B -c when using .B -q. .TP \-k or --kill Terminates a daemonized instance of Willie. This option determines the instance to kill by the name of the config file given by the .B -c option, if used, so the same argument must be given with .B -c when using .B -k. .TP \-l or --list List all config files found in the usual configuration file directories: .B ~/.willie ~/.jenni and .B ~/.phenny .TP \-m or --migrate Attempt to migrate a configuration file for an older version of the bot to the new format. Versions prior to 3.1 used a configuration file written in Python, which can be converted with this tool. .TP \--quiet Suppress all output from the bot to the terminal. .TP \-w or --configure-all Run the initial configuration wizard. This can be run with .B -c to configure a specified file, or without it to work on the default file. .TP \--configure-modules Check for modules with configuration options, and allow the user to modify those options. Changes will be added to the default file, or the file given to .B -c if used. .TP \--configure-database Run a wizard for configuring the user information database options. Changes will be added to the default file, or the file given to .B -c if used. sopel-6.6.9/pytest.ini000066400000000000000000000001551347452002400147110ustar00rootroot00000000000000[pytest] # sopel/modules/ files contain tests python_files=*.py addopts = --tb=short norecursedirs = contrib sopel-6.6.9/pytest_run.py000077500000000000000000000011051347452002400154450ustar00rootroot00000000000000#!/usr/bin/env python # coding=utf-8 """This is a script for running pytest from the command line. This script exists so that the project directory gets added to sys.path, which prevents us from accidentally testing the globally installed sopel version. pytest_run.py Copyright 2013, Ari Koivula, Licensed under the Eiffel Forum License 2. https://sopel.chat """ from __future__ import unicode_literals, absolute_import, print_function, division if __name__ == "__main__": import sys import pytest returncode = pytest.main() sys.exit(returncode) sopel-6.6.9/requirements.txt000066400000000000000000000006021347452002400161410ustar00rootroot00000000000000xmltodict pytz praw<6.0.0 pyenchant; python_version < '3.7' geoip2 ipython<6.0; python_version < '3.3' ipython>=6.0,<7.0; python_version >= '3.3' and python_version < '3.5' ipython>=7.0,<8.0; python_version >= '3.5' requests>=2.0.0,<3.0.0 dnspython<2.0; python_version >= '2.7' and python_version < '3.0' dnspython<1.16.0; python_version == '3.3' dnspython<3.0; python_version >= '3.4' sopel-6.6.9/setup.py000077500000000000000000000040051347452002400143730ustar00rootroot00000000000000#!/usr/bin/env python # coding=utf-8 from __future__ import unicode_literals, absolute_import, print_function, division from sopel import __version__ import sys try: from setuptools import setup except ImportError: print( 'You do not have setuptools, and can not install Sopel. The easiest ' 'way to fix this is to install pip by following the instructions at ' 'http://pip.readthedocs.org/en/latest/installing.html\n' 'Alternately, you can run sopel without installing it by running ' '"python sopel.py"', file=sys.stderr, ) sys.exit(1) if sys.version_info < (2, 7) or ( sys.version_info[0] > 3 and sys.version_info < (3, 3)): # Maybe not the cleanest or best way to do this, but I'm tired of answering # this fucking question, and if you get here you should go RTGDMFM. raise ImportError('Sopel requires Python 2.7+ or 3.3+.') def read_reqs(path): with open(path, 'r') as fil: return list(fil.readlines()) requires = read_reqs('requirements.txt') if sys.version_info[0] < 3: requires.append('backports.ssl_match_hostname') dev_requires = requires + read_reqs('dev-requirements.txt') setup( name='sopel', version=__version__, description='Simple and extendible IRC bot', author='Elsie Powell', author_email='powell.518@gmail.com', url='https://sopel.chat/', long_description=( "Sopel is a simple, extendible, easy-to-use IRC Utility bot, written " "in Python. It's designed to be easy to use, easy to run, and easy to " "make new features for." ), # Distutils is shit, and doesn't check if it's a list of basestring # but instead requires str. packages=[str('sopel'), str('sopel.modules'), str('sopel.config'), str('sopel.tools')], license='Eiffel Forum License, version 2', platforms='Linux x86, x86-64', install_requires=requires, extras_require={'dev': dev_requires}, entry_points={'console_scripts': ['sopel = sopel.run_script:main']}, ) sopel-6.6.9/sopel.py000077500000000000000000000003611347452002400143560ustar00rootroot00000000000000#!/usr/bin/env python3 # coding=utf-8 from __future__ import unicode_literals, absolute_import, print_function, division # Different from setuptools script, because we want the one in this dir. from sopel import run_script run_script.main() sopel-6.6.9/sopel/000077500000000000000000000000001347452002400140015ustar00rootroot00000000000000sopel-6.6.9/sopel/__init__.py000066400000000000000000000074631347452002400161240ustar00rootroot00000000000000# coding=utf-8 # ASCII ONLY IN THIS FILE THOUGH!!!!!!! # Python does some stupid bullshit of respecting LC_ALL over the encoding on the # file, so in order to undo Python's ridiculous fucking idiocy, we have to have # our own check. # Copyright 2008, Sean B. Palmer, inamidst.com # Copyright 2012, Elsie Powell, http://embolalia.com # Copyright 2012, Elad Alfassa # # Licensed under the Eiffel Forum License 2. from __future__ import unicode_literals, absolute_import, print_function, division import locale import sys loc = locale.getlocale() if sys.version_info.major > 2: if not loc[1] or 'UTF-8' not in loc[1]: print('WARNING!!! You are running with a non-UTF8 locale environment ' 'variables (e.g. LC_ALL is set to "C"), which makes Python 3 do ' 'stupid things. If you get strange errors, please set it to ' 'something like "en_US.UTF-8".', file=sys.stderr) from collections import namedtuple import os import re import time import traceback import signal __version__ = '6.6.9' def _version_info(version=__version__): regex = re.compile(r'(\d+)\.(\d+)\.(\d+)(?:(a|b|rc)(\d+))?.*') version_groups = regex.match(__version__).groups() major, minor, micro = (int(piece) for piece in version_groups[0:3]) level = version_groups[3] serial = int(version_groups[4] or 0) if level == 'a': level = 'alpha' elif level == 'b': level = 'beta' elif level == 'rc': level = 'candidate' elif not level and version_groups[4] is None: level = 'final' else: level = 'alpha' version_type = namedtuple('version_info', 'major, minor, micro, releaselevel, serial') return version_type(major, minor, micro, level, serial) version_info = _version_info() def run(config, pid_file, daemon=False): import sopel.bot as bot import sopel.logger from sopel.tools import stderr delay = 20 # Inject ca_certs from config to web for SSL validation of web requests if not config.core.ca_certs: stderr('Could not open CA certificates file. SSL will not ' 'work properly.') def signal_handler(sig, frame): if sig == signal.SIGUSR1 or sig == signal.SIGTERM or sig == signal.SIGINT: stderr('Got quit signal, shutting down.') p.quit('Closing') # Define empty variable `p` for bot p = None while True: if p and p.hasquit: # Check if `hasquit` was set for bot during disconnected phase break try: p = bot.Sopel(config, daemon=daemon) if hasattr(signal, 'SIGUSR1'): signal.signal(signal.SIGUSR1, signal_handler) if hasattr(signal, 'SIGTERM'): signal.signal(signal.SIGTERM, signal_handler) if hasattr(signal, 'SIGINT'): signal.signal(signal.SIGINT, signal_handler) sopel.logger.setup_logging(p) p.run(config.core.host, int(config.core.port)) except KeyboardInterrupt: break except Exception: # TODO: Be specific trace = traceback.format_exc() try: stderr(trace) except Exception: # TODO: Be specific pass logfile = open(os.path.join(config.core.logdir, 'exceptions.log'), 'a') logfile.write('Critical exception in core') logfile.write(trace) logfile.write('----------------------------------------\n\n') logfile.close() os.unlink(pid_file) os._exit(1) if not isinstance(delay, int): break if p.hasquit: break stderr('Warning: Disconnected. Reconnecting in %s seconds...' % delay) time.sleep(delay) os.unlink(pid_file) os._exit(0) sopel-6.6.9/sopel/bot.py000066400000000000000000000651601347452002400151470ustar00rootroot00000000000000# coding=utf-8 # Copyright 2008, Sean B. Palmer, inamidst.com # Copyright © 2012, Elad Alfassa # Copyright 2012-2015, Elsie Powell, http://embolalia.com # # Licensed under the Eiffel Forum License 2. from __future__ import unicode_literals, absolute_import, print_function, division import collections import os import re import sys import threading import time from sopel import tools from sopel import irc from sopel.db import SopelDB from sopel.tools import stderr, Identifier import sopel.tools.jobs from sopel.trigger import Trigger from sopel.module import NOLIMIT from sopel.logger import get_logger import sopel.loader LOGGER = get_logger(__name__) if sys.version_info.major >= 3: unicode = str basestring = str py3 = True else: py3 = False class _CapReq(object): def __init__(self, prefix, module, failure=None, arg=None, success=None): def nop(bot, cap): pass # TODO at some point, reorder those args to be sane self.prefix = prefix self.module = module self.arg = arg self.failure = failure or nop self.success = success or nop class Sopel(irc.Bot): def __init__(self, config, daemon=False): irc.Bot.__init__(self, config) self._daemon = daemon # Used for iPython. TODO something saner here # `re.compile('.*') is re.compile('.*')` because of caching, so we need # to associate a list with each regex, since they are unexpectedly # indistinct. self._callables = { 'high': collections.defaultdict(list), 'medium': collections.defaultdict(list), 'low': collections.defaultdict(list) } self.config = config """The :class:`sopel.config.Config` for the current Sopel instance.""" self.doc = {} """ A dictionary of command names to their docstring and example, if declared. The first item in a callable's commands list is used as the key in version *3.2* onward. Prior to *3.2*, the name of the function as declared in the source code was used. """ self._command_groups = collections.defaultdict(list) """A mapping of module names to a list of commands in it.""" self.stats = {} # deprecated, remove in 7.0 self._times = {} """ A dictionary mapping lower-case'd nicks to dictionaries which map funtion names to the time which they were last used by that nick. """ self.server_capabilities = {} """A dict mapping supported IRCv3 capabilities to their options. For example, if the server specifies the capability ``sasl=EXTERNAL``, it will be here as ``{"sasl": "EXTERNAL"}``. Capabilities specified without any options will have ``None`` as the value. For servers that do not support IRCv3, this will be an empty set.""" self.enabled_capabilities = set() """A set containing the IRCv3 capabilities that the bot has enabled.""" self._cap_reqs = dict() """A dictionary of capability names to a list of requests""" self.privileges = dict() """A dictionary of channels to their users and privilege levels The value associated with each channel is a dictionary of :class:`sopel.tools.Identifier`\\s to a bitwise integer value, determined by combining the appropriate constants from :mod:`sopel.module`. .. deprecated:: 6.2.0 Use :attr:`channels` instead. """ self.channels = tools.SopelMemory() # name to chan obj """A map of the channels that Sopel is in. The keys are Identifiers of the channel names, and map to :class:`sopel.tools.target.Channel` objects which contain the users in the channel and their permissions. """ self.users = tools.SopelMemory() # name to user obj """A map of the users that Sopel is aware of. The keys are Identifiers of the nicknames, and map to :class:`sopel.tools.target.User` instances. In order for Sopel to be aware of a user, it must be in at least one channel which they are also in. """ self.db = SopelDB(config) """The bot's database, as a :class:`sopel.db.SopelDB` instance.""" self.memory = tools.SopelMemory() """ A thread-safe dict for storage of runtime data to be shared between modules. See :class:`sopel.tools.Sopel.SopelMemory` """ self.shutdown_methods = [] """List of methods to call on shutdown""" self.scheduler = sopel.tools.jobs.JobScheduler(self) self.scheduler.start() # Set up block lists # Default to empty if not self.config.core.nick_blocks: self.config.core.nick_blocks = [] if not self.config.core.host_blocks: self.config.core.host_blocks = [] self.setup() # Backwards-compatibility aliases to attributes made private in 6.2. Remove # these in 7.0 times = property(lambda self: getattr(self, '_times')) command_groups = property(lambda self: getattr(self, '_command_groups')) def write(self, args, text=None): # Shim this in here for autodocs """Send a command to the server. ``args`` is an iterable of strings, which are joined by spaces. ``text`` is treated as though it were the final item in ``args``, but is preceeded by a ``:``. This is a special case which means that ``text``, unlike the items in ``args`` may contain spaces (though this constraint is not checked by ``write``). In other words, both ``sopel.write(('PRIVMSG',), 'Hello, world!')`` and ``sopel.write(('PRIVMSG', ':Hello, world!'))`` will send ``PRIVMSG :Hello, world!`` to the server. Newlines and carriage returns ('\\n' and '\\r') are removed before sending. Additionally, if the message (after joining) is longer than than 510 characters, any remaining characters will not be sent. """ irc.Bot.write(self, args, text=text) def setup(self): stderr("\nWelcome to Sopel. Loading modules...\n\n") modules = sopel.loader.enumerate_modules(self.config) error_count = 0 success_count = 0 for name in modules: path, type_ = modules[name] try: module, _ = sopel.loader.load_module(name, path, type_) except Exception as e: error_count = error_count + 1 filename, lineno = tools.get_raising_file_and_line() rel_path = os.path.relpath(filename, os.path.dirname(__file__)) raising_stmt = "%s:%d" % (rel_path, lineno) stderr("Error loading %s: %s (%s)" % (name, e, raising_stmt)) else: try: if hasattr(module, 'setup'): module.setup(self) relevant_parts = sopel.loader.clean_module( module, self.config) except Exception as e: error_count = error_count + 1 filename, lineno = tools.get_raising_file_and_line() rel_path = os.path.relpath( filename, os.path.dirname(__file__) ) raising_stmt = "%s:%d" % (rel_path, lineno) stderr("Error in %s setup procedure: %s (%s)" % (name, e, raising_stmt)) else: self.register(*relevant_parts) success_count += 1 if len(modules) > 1: # coretasks is counted stderr('\n\nRegistered %d modules,' % (success_count - 1)) stderr('%d modules failed to load\n\n' % error_count) else: stderr("Warning: Couldn't load any modules") def unregister(self, obj): if not callable(obj): return if hasattr(obj, 'rule'): # commands and intents have it added for rule in obj.rule: callb_list = self._callables[obj.priority][rule] if obj in callb_list: callb_list.remove(obj) if hasattr(obj, 'interval'): # TODO this should somehow find the right job to remove, rather than # clearing the entire queue. Issue #831 self.scheduler.clear_jobs() if (getattr(obj, '__name__', None) == 'shutdown' and obj in self.shutdown_methods): self.shutdown_methods.remove(obj) def register(self, callables, jobs, shutdowns, urls): # Append module's shutdown function to the bot's list of functions to # call on shutdown self.shutdown_methods += shutdowns for callbl in callables: if hasattr(callbl, 'rule'): for rule in callbl.rule: self._callables[callbl.priority][rule].append(callbl) else: self._callables[callbl.priority][re.compile('.*')].append(callbl) if hasattr(callbl, 'commands'): module_name = callbl.__module__.rsplit('.', 1)[-1] # TODO doc and make decorator for this. Not sure if this is how # it should work yet, so not making it public for 6.0. category = getattr(callbl, 'category', module_name) self._command_groups[category].append(callbl.commands[0]) for command, docs in callbl._docs.items(): self.doc[command] = docs for func in jobs: for interval in func.interval: job = sopel.tools.jobs.Job(interval, func) self.scheduler.add_job(job) if not self.memory.contains('url_callbacks'): self.memory['url_callbacks'] = tools.SopelMemory() for func in urls: self.memory['url_callbacks'][func.url_regex] = func def part(self, channel, msg=None): """Part a channel.""" self.write(['PART', channel], msg) def join(self, channel, password=None): """Join a channel If `channel` contains a space, and no `password` is given, the space is assumed to split the argument into the channel to join and its password. `channel` should not contain a space if `password` is given. """ if password is None: self.write(('JOIN', channel)) else: self.write(['JOIN', channel, password]) def msg(self, recipient, text, max_messages=1): # Deprecated, but way too much of a pain to remove. self.say(text, recipient, max_messages) def say(self, text, recipient, max_messages=1): """Send ``text`` as a PRIVMSG to ``recipient``. In the context of a triggered callable, the ``recipient`` defaults to the channel (or nickname, if a private message) from which the message was received. By default, this will attempt to send the entire ``text`` in one message. If the text is too long for the server, it may be truncated. If ``max_messages`` is given, the ``text`` will be split into at most that many messages, each no more than 400 bytes. The split is made at the last space character before the 400th byte, or at the 400th byte if no such space exists. If the ``text`` is too long to fit into the specified number of messages using the above splitting, the final message will contain the entire remainder, which may be truncated by the server. """ excess = '' if not isinstance(text, unicode): # Make sure we are dealing with unicode string text = text.decode('utf-8') if max_messages > 1: # Manage multi-line only when needed text, excess = tools.get_sendable_message(text) try: self.sending.acquire() # No messages within the last 3 seconds? Go ahead! # Otherwise, wait so it's been at least 0.8 seconds + penalty recipient_id = Identifier(recipient) if recipient_id not in self.stack: self.stack[recipient_id] = [] elif self.stack[recipient_id]: elapsed = time.time() - self.stack[recipient_id][-1][0] if elapsed < 3: penalty = float(max(0, len(text) - 40)) / 70 wait = min(0.8 + penalty, 2) # Never wait more than 2 seconds if elapsed < wait: time.sleep(wait - elapsed) # Loop detection messages = [m[1] for m in self.stack[recipient_id][-8:]] # If what we about to send repeated at least 5 times in the # last 2 minutes, replace with '...' if messages.count(text) >= 5 and elapsed < 120: text = '...' if messages.count('...') >= 3: # If we said '...' 3 times, discard message return self.write(('PRIVMSG', recipient), text) self.stack[recipient_id].append((time.time(), self.safe(text))) self.stack[recipient_id] = self.stack[recipient_id][-10:] finally: self.sending.release() # Now that we've sent the first part, we need to send the rest. Doing # this recursively seems easier to me than iteratively if excess: self.msg(recipient, excess, max_messages - 1) def notice(self, text, dest): """Send an IRC NOTICE to a user or a channel. Within the context of a triggered callable, ``dest`` will default to the channel (or nickname, if a private message), in which the trigger happened. """ self.write(('NOTICE', dest), text) def action(self, text, dest): """Send ``text`` as a CTCP ACTION PRIVMSG to ``dest``. The same loop detection and length restrictions apply as with :func:`say`, though automatic message splitting is not available. Within the context of a triggered callable, ``dest`` will default to the channel (or nickname, if a private message), in which the trigger happened. """ self.say('\001ACTION {}\001'.format(text), dest) def reply(self, text, dest, reply_to, notice=False): """Prepend ``reply_to`` to ``text``, and send as a PRIVMSG to ``dest``. If ``notice`` is ``True``, send a NOTICE rather than a PRIVMSG. The same loop detection and length restrictions apply as with :func:`say`, though automatic message splitting is not available. Within the context of a triggered callable, ``reply_to`` will default to the nickname of the user who triggered the call, and ``dest`` to the channel (or nickname, if a private message), in which the trigger happened. """ text = '%s: %s' % (reply_to, text) if notice: self.notice(text, dest) else: self.say(text, dest) class SopelWrapper(object): def __init__(self, sopel, trigger): # The custom __setattr__ for this class sets the attribute on the # original bot object. We don't want that for these, so we set them # with the normal __setattr__. object.__setattr__(self, '_bot', sopel) object.__setattr__(self, '_trigger', trigger) def __dir__(self): classattrs = [attr for attr in self.__class__.__dict__ if not attr.startswith('__')] return list(self.__dict__) + classattrs + dir(self._bot) def __getattr__(self, attr): return getattr(self._bot, attr) def __setattr__(self, attr, value): return setattr(self._bot, attr, value) def say(self, message, destination=None, max_messages=1): if destination is None: destination = self._trigger.sender self._bot.say(message, destination, max_messages) def action(self, message, destination=None): if destination is None: destination = self._trigger.sender self._bot.action(message, destination) def notice(self, message, destination=None): if destination is None: destination = self._trigger.sender self._bot.notice(message, destination) def reply(self, message, destination=None, reply_to=None, notice=False): if destination is None: destination = self._trigger.sender if reply_to is None: reply_to = self._trigger.nick self._bot.reply(message, destination, reply_to, notice) def call(self, func, sopel, trigger): nick = trigger.nick current_time = time.time() if nick not in self._times: self._times[nick] = dict() if self.nick not in self._times: self._times[self.nick] = dict() if not trigger.is_privmsg and trigger.sender not in self._times: self._times[trigger.sender] = dict() if not trigger.admin and not func.unblockable: if func in self._times[nick]: usertimediff = current_time - self._times[nick][func] if func.rate > 0 and usertimediff < func.rate: #self._times[nick][func] = current_time LOGGER.info( "%s prevented from using %s in %s due to user limit: %d < %d", trigger.nick, func.__name__, trigger.sender, usertimediff, func.rate ) return if func in self._times[self.nick]: globaltimediff = current_time - self._times[self.nick][func] if func.global_rate > 0 and globaltimediff < func.global_rate: #self._times[self.nick][func] = current_time LOGGER.info( "%s prevented from using %s in %s due to global limit: %d < %d", trigger.nick, func.__name__, trigger.sender, globaltimediff, func.global_rate ) return if not trigger.is_privmsg and func in self._times[trigger.sender]: chantimediff = current_time - self._times[trigger.sender][func] if func.channel_rate > 0 and chantimediff < func.channel_rate: #self._times[trigger.sender][func] = current_time LOGGER.info( "%s prevented from using %s in %s due to channel limit: %d < %d", trigger.nick, func.__name__, trigger.sender, chantimediff, func.channel_rate ) return try: exit_code = func(sopel, trigger) except Exception: # TODO: Be specific exit_code = None self.error(trigger) if exit_code != NOLIMIT: self._times[nick][func] = current_time self._times[self.nick][func] = current_time if not trigger.is_privmsg: self._times[trigger.sender][func] = current_time def dispatch(self, pretrigger): args = pretrigger.args event, args, text = pretrigger.event, args, args[-1] if args else '' if self.config.core.nick_blocks or self.config.core.host_blocks: nick_blocked = self._nick_blocked(pretrigger.nick) host_blocked = self._host_blocked(pretrigger.host) else: nick_blocked = host_blocked = None list_of_blocked_functions = [] for priority in ('high', 'medium', 'low'): items = self._callables[priority].items() for regexp, funcs in items: match = regexp.match(text) if not match: continue user_obj = self.users.get(pretrigger.nick) account = user_obj.account if user_obj else None trigger = Trigger(self.config, pretrigger, match, account) wrapper = self.SopelWrapper(self, trigger) for func in funcs: if (not trigger.admin and not func.unblockable and (nick_blocked or host_blocked)): function_name = "%s.%s" % ( func.__module__, func.__name__ ) list_of_blocked_functions.append(function_name) continue if event not in func.event: continue if hasattr(func, 'intents'): if not trigger.tags.get('intent'): continue match = False for intent in func.intents: if intent.match(trigger.tags.get('intent')): match = True if not match: continue if func.thread: targs = (func, wrapper, trigger) t = threading.Thread(target=self.call, args=targs) t.start() else: self.call(func, wrapper, trigger) if list_of_blocked_functions: if nick_blocked and host_blocked: block_type = 'both' elif nick_blocked: block_type = 'nick' else: block_type = 'host' LOGGER.info( "[%s]%s prevented from using %s.", block_type, trigger.nick, ', '.join(list_of_blocked_functions) ) def _host_blocked(self, host): bad_masks = self.config.core.host_blocks for bad_mask in bad_masks: bad_mask = bad_mask.strip() if not bad_mask: continue if (re.match(bad_mask + '$', host, re.IGNORECASE) or bad_mask == host): return True return False def _nick_blocked(self, nick): bad_nicks = self.config.core.nick_blocks for bad_nick in bad_nicks: bad_nick = bad_nick.strip() if not bad_nick: continue if (re.match(bad_nick + '$', nick, re.IGNORECASE) or Identifier(bad_nick) == nick): return True return False def _shutdown(self): stderr( 'Calling shutdown for %d modules.' % (len(self.shutdown_methods),) ) for shutdown_method in self.shutdown_methods: try: stderr( "calling %s.%s" % ( shutdown_method.__module__, shutdown_method.__name__, ) ) shutdown_method(self) except Exception as e: stderr( "Error calling shutdown method for module %s:%s" % ( shutdown_method.__module__, e ) ) # Avoid calling shutdown methods if we already have. self.shutdown_methods = [] def cap_req(self, module_name, capability, arg=None, failure_callback=None, success_callback=None): """Tell Sopel to request a capability when it starts. By prefixing the capability with `-`, it will be ensured that the capability is not enabled. Simmilarly, by prefixing the capability with `=`, it will be ensured that the capability is enabled. Requiring and disabling is "first come, first served"; if one module requires a capability, and another prohibits it, this function will raise an exception in whichever module loads second. An exception will also be raised if the module is being loaded after the bot has already started, and the request would change the set of enabled capabilities. If the capability is not prefixed, and no other module prohibits it, it will be requested. Otherwise, it will not be requested. Since capability requests that are not mandatory may be rejected by the server, as well as by other modules, a module which makes such a request should account for that possibility. The actual capability request to the server is handled after the completion of this function. In the event that the server denies a request, the `failure_callback` function will be called, if provided. The arguments will be a `Sopel` object, and the capability which was rejected. This can be used to disable callables which rely on the capability. It will be be called either if the server NAKs the request, or if the server enabled it and later DELs it. The `success_callback` function will be called upon acknowledgement of the capability from the server, whether during the initial capability negotiation, or later. If ``arg`` is given, and does not exactly match what the server provides or what other modules have requested for that capability, it is considered a conflict. """ # TODO raise better exceptions cap = capability[1:] prefix = capability[0] entry = self._cap_reqs.get(cap, []) if any((ent.arg != arg for ent in entry)): raise Exception('Capability conflict') if prefix == '-': if self.connection_registered and cap in self.enabled_capabilities: raise Exception('Can not change capabilities after server ' 'connection has been completed.') if any((ent.prefix != '-' for ent in entry)): raise Exception('Capability conflict') entry.append(_CapReq(prefix, module_name, failure_callback, arg, success_callback)) self._cap_reqs[cap] = entry else: if prefix != '=': cap = capability prefix = '' if self.connection_registered and (cap not in self.enabled_capabilities): raise Exception('Can not change capabilities after server ' 'connection has been completed.') # Non-mandatory will callback at the same time as if the server # rejected it. if any((ent.prefix == '-' for ent in entry)) and prefix == '=': raise Exception('Capability conflict') entry.append(_CapReq(prefix, module_name, failure_callback, arg, success_callback)) self._cap_reqs[cap] = entry sopel-6.6.9/sopel/config/000077500000000000000000000000001347452002400152465ustar00rootroot00000000000000sopel-6.6.9/sopel/config/__init__.py000066400000000000000000000234171347452002400173660ustar00rootroot00000000000000# coding=utf-8 """ The config object provides a simplified to access Sopel's configuration file. The sections of the file are attributes of the object, and the keys in the section are attributes of that. So, for example, the ``eggs`` attribute in the ``[spam]`` section can be accessed from ``config.spam.eggs``. Section definitions (see "Section configuration sections" below) can be added to the config object with ``define_section``. When this is done, only the defined keys will be available. A section can not be given more than one definition. The ``[core]`` section is defined with ``CoreSection`` when the object is initialized. .. versionadded:: 6.0.0 """ # Copyright 2012-2015, Elsie Powell, embolalia.com # Copyright © 2012, Elad Alfassa # Licensed under the Eiffel Forum License 2. from __future__ import unicode_literals, absolute_import, print_function, division from sopel.tools import iteritems, stderr import sopel.tools from sopel.tools import get_input import sopel.loader import os import sys if sys.version_info.major < 3: import ConfigParser else: basestring = str import configparser as ConfigParser import sopel.config.core_section from sopel.config.types import StaticSection class ConfigurationError(Exception): """ Exception type for configuration errors """ def __init__(self, value): self.value = value def __str__(self): return 'ConfigurationError: %s' % self.value class Config(object): def __init__(self, filename, validate=True): """The bot's configuration. The given filename will be associated with the configuration, and is the file which will be written if write() is called. If load is not given or True, the configuration object will load the attributes from the file at filename. A few default values will be set here if they are not defined in the config file, or a config file is not loaded. They are documented below. """ self.filename = filename """The config object's associated file, as noted above.""" self.parser = ConfigParser.RawConfigParser(allow_no_value=True) self.parser.read(self.filename) self.define_section('core', sopel.config.core_section.CoreSection, validate=validate) self.get = self.parser.get @property def homedir(self): """An alias to config.core.homedir""" # Technically it's the other way around, so we can bootstrap filename # attributes in the core section, but whatever. configured = None if self.parser.has_option('core', 'homedir'): configured = self.parser.get('core', 'homedir') if configured: return configured else: return os.path.dirname(self.filename) def save(self): """Save all changes to the config file.""" cfgfile = open(self.filename, 'w') self.parser.write(cfgfile) cfgfile.flush() cfgfile.close() def add_section(self, name): """Add a section to the config file. Returns ``False`` if already exists. """ try: return self.parser.add_section(name) except ConfigParser.DuplicateSectionError: return False def define_section(self, name, cls_, validate=True): """Define the available settings in a section. ``cls_`` must be a subclass of ``StaticSection``. If the section has already been defined with a different class, ValueError is raised. If ``validate`` is True, the section's values will be validated, and an exception raised if they are invalid. This is desirable in a module's setup function, for example, but might not be in the configure function. """ if not issubclass(cls_, StaticSection): raise ValueError("Class must be a subclass of StaticSection.") current = getattr(self, name, None) current_name = str(current.__class__) new_name = str(cls_) if (current is not None and not isinstance(current, self.ConfigSection) and not current_name == new_name): raise ValueError( "Can not re-define class for section from {} to {}.".format( current_name, new_name) ) setattr(self, name, cls_(self, name, validate=validate)) class ConfigSection(object): """Represents a section of the config file. Contains all keys in thesection as attributes. """ def __init__(self, name, items, parent): object.__setattr__(self, '_name', name) object.__setattr__(self, '_parent', parent) for item in items: value = item[1].strip() if not value.lower() == 'none': if value.lower() == 'false': value = False object.__setattr__(self, item[0], value) def __getattr__(self, name): return None def __setattr__(self, name, value): object.__setattr__(self, name, value) if type(value) is list: value = ','.join(value) self._parent.parser.set(self._name, name, value) def get_list(self, name): value = getattr(self, name) if not value: return [] if isinstance(value, basestring): value = value.split(',') # Keep the split value, so we don't have to keep doing this setattr(self, name, value) return value def __getattr__(self, name): if name in self.parser.sections(): items = self.parser.items(name) section = self.ConfigSection(name, items, self) # Return a section setattr(self, name, section) return section else: raise AttributeError("%r object has no attribute %r" % (type(self).__name__, name)) def option(self, question, default=False): """Ask "y/n" and return the corresponding boolean answer. Show user in terminal a "y/n" prompt, and return true or false based on the response. If default is passed as true, the default will be shown as ``[y]``, else it will be ``[n]``. ``question`` should be phrased as a question, but without a question mark at the end. """ d = 'n' if default: d = 'y' ans = get_input(question + ' (y/n)? [' + d + '] ') if not ans: ans = d return ans.lower() == 'y' def _modules(self): home = os.getcwd() modules_dir = os.path.join(home, 'modules') filenames = sopel.loader.enumerate_modules(self) os.sys.path.insert(0, modules_dir) for name, mod_spec in iteritems(filenames): path, type_ = mod_spec try: module, _ = sopel.loader.load_module(name, path, type_) except Exception as e: filename, lineno = sopel.tools.get_raising_file_and_line() rel_path = os.path.relpath(filename, os.path.dirname(__file__)) raising_stmt = "%s:%d" % (rel_path, lineno) stderr("Error loading %s: %s (%s)" % (name, e, raising_stmt)) else: if hasattr(module, 'configure'): prompt = name + ' module' if module.__doc__: doc = module.__doc__.split('\n', 1)[0] if doc: prompt = doc prompt = 'Configure {} (y/n)? [n]'.format(prompt) do_configure = get_input(prompt) do_configure = do_configure and do_configure.lower() == 'y' if do_configure: module.configure(self) self.save() def _wizard(section, config=None): dotdir = os.path.expanduser('~/.sopel') configpath = os.path.join(dotdir, ((config or 'default.cfg') + ('.cfg' if config and not config.endswith('.cfg') else ''))) if section == 'all': _create_config(configpath) elif section == 'mod': _check_dir(False) if not os.path.isfile(configpath): print("No config file found." + " Please make one before configuring these options.") sys.exit(1) config = Config(configpath, validate=False) config._modules() def _check_dir(create=True): dotdir = os.path.join(os.path.expanduser('~'), '.sopel') if not os.path.isdir(dotdir): if create: print('Creating a config directory at ~/.sopel...') try: os.makedirs(dotdir) except Exception as e: print('There was a problem creating %s:' % dotdir, file=sys.stderr) print('%s, %s' % (e.__class__, str(e)), file=sys.stderr) print('Please fix this and then run Sopel again.', file=sys.stderr) sys.exit(1) else: print("No config file found. Please make one before configuring these options.") sys.exit(1) def _create_config(configpath): _check_dir() print("Please answer the following questions" + " to create your configuration file:\n") try: config = Config(configpath, validate=False) sopel.config.core_section.configure(config) if config.option( 'Would you like to see if there are any modules' ' that need configuring' ): config._modules() config.save() except Exception: # TODO: Be specific print("Encountered an error while writing the config file." + " This shouldn't happen. Check permissions.") raise sys.exit(1) print("Config file written successfully!") sopel-6.6.9/sopel/config/core_section.py000066400000000000000000000201001347452002400202650ustar00rootroot00000000000000# coding=utf-8 from __future__ import unicode_literals, absolute_import, print_function, division import os.path from sopel.config.types import ( StaticSection, ValidatedAttribute, ListAttribute, ChoiceAttribute, FilenameAttribute, NO_DEFAULT ) from sopel.tools import Identifier def _find_certs(): """ Find the TLS root CA store. :returns: str (path to file) """ # check if the root CA store is at a known location locations = [ '/etc/pki/tls/cert.pem', # best first guess '/etc/ssl/certs/ca-certificates.crt', # Debian '/etc/ssl/cert.pem', # FreeBSD base OpenSSL '/usr/local/openssl/cert.pem', # FreeBSD userland OpenSSL '/etc/pki/tls/certs/ca-bundle.crt', # RHEL 6 / Fedora '/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem', # RHEL 7 / CentOS '/etc/pki/tls/cacert.pem', # OpenELEC '/etc/ssl/ca-bundle.pem', # OpenSUSE ] for certs in locations: if os.path.isfile(certs): return certs return None def configure(config): config.core.configure_setting('nick', 'Enter the nickname for your bot.') config.core.configure_setting('host', 'Enter the server to connect to.') config.core.configure_setting('use_ssl', 'Should the bot connect with SSL?') if config.core.use_ssl: default_port = 6697 else: default_port = 6667 config.core.configure_setting('port', 'Enter the port to connect on.', default=default_port) config.core.configure_setting( 'owner', "Enter your own IRC name (or that of the bot's owner)") config.core.configure_setting( 'channels', 'Enter the channels to connect to at startup, separated by commas.' ) class CoreSection(StaticSection): """The config section used for configuring the bot itself.""" admins = ListAttribute('admins') """The list of people (other than the owner) who can administer the bot""" admin_accounts = ListAttribute('admin_accounts') """The list of accounts (other than the owner's) who can administer the bot. This should not be set for networks that do not support IRCv3 account capabilities.""" alias_nicks = ListAttribute('alias_nicks') """List of alternate names recognized as the bot's nick for $nick and $nickname regex substitutions""" auth_method = ChoiceAttribute('auth_method', choices=[ 'nickserv', 'authserv', 'Q', 'sasl', 'server', 'userserv']) """The method to use to authenticate with the server. Can be ``nickserv``, ``authserv``, ``Q``, ``sasl``, or ``server`` or ``userserv``.""" auth_password = ValidatedAttribute('auth_password') """The password to use to authenticate with the server.""" auth_target = ValidatedAttribute('auth_target') """The user to use for nickserv authentication, or the SASL mechanism. May not apply, depending on ``auth_method``. Defaults to NickServ for nickserv auth, and PLAIN for SASL auth.""" auth_username = ValidatedAttribute('auth_username') """The username/account to use to authenticate with the server. May not apply, depending on ``auth_method``.""" bind_host = ValidatedAttribute('bind_host') """Bind the connection to a specific IP""" ca_certs = FilenameAttribute('ca_certs', default=_find_certs()) """The path of the CA certs pem file""" channels = ListAttribute('channels') """List of channels for the bot to join when it connects""" db_filename = ValidatedAttribute('db_filename') """The filename for Sopel's database.""" default_time_format = ValidatedAttribute('default_time_format', default='%Y-%m-%d - %T%Z') """The default format to use for time in messages.""" default_timezone = ValidatedAttribute('default_timezone', default='UTC') """The default timezone to use for time in messages.""" enable = ListAttribute('enable') """A whitelist of the only modules you want to enable.""" exclude = ListAttribute('exclude') """A list of modules which should not be loaded.""" extra = ListAttribute('extra') """A list of other directories you'd like to include modules from.""" help_prefix = ValidatedAttribute('help_prefix', default='.') """The prefix to use in help""" @property def homedir(self): """The directory in which various files are stored at runtime. By default, this is the same directory as the config. It can not be changed at runtime. """ return self._parent.homedir host = ValidatedAttribute('host', default='irc.dftba.net') """The server to connect to.""" host_blocks = ListAttribute('host_blocks') """A list of hostmasks which Sopel should ignore. Regular expression syntax is used""" log_raw = ValidatedAttribute('log_raw', bool, default=False) """Whether a log of raw lines as sent and received should be kept.""" logdir = FilenameAttribute('logdir', directory=True, default='logs') """Directory in which to place logs.""" logging_channel = ValidatedAttribute('logging_channel', Identifier) """The channel to send logging messages to.""" logging_level = ChoiceAttribute('logging_level', ['CRITICAL', 'ERROR', 'WARNING', 'INFO', 'DEBUG'], 'WARNING') """The lowest severity of logs to display.""" modes = ValidatedAttribute('modes', default='B') """User modes to be set on connection.""" name = ValidatedAttribute('name', default='Sopel: https://sopel.chat') """The "real name" of your bot for WHOIS responses.""" nick = ValidatedAttribute('nick', Identifier, default=Identifier('Sopel')) """The nickname for the bot""" nick_blocks = ListAttribute('nick_blocks') """A list of nicks which Sopel should ignore. Regular expression syntax is used.""" not_configured = ValidatedAttribute('not_configured', bool, default=False) """For package maintainers. Not used in normal configurations. This allows software packages to install a default config file, with this set to true, so that the bot will not run until it has been properly configured.""" owner = ValidatedAttribute('owner', default=NO_DEFAULT) """The IRC name of the owner of the bot.""" owner_account = ValidatedAttribute('owner_account') """The services account name of the owner of the bot. This should only be set on networks which support IRCv3 account capabilities. """ pid_dir = FilenameAttribute('pid_dir', directory=True, default='.') """The directory in which to put the file Sopel uses to track its process ID. You probably do not need to change this unless you're managing Sopel with systemd or similar.""" port = ValidatedAttribute('port', int, default=6667) """The port to connect on.""" prefix = ValidatedAttribute('prefix', default='\\.') """The prefix to add to the beginning of commands. It is a regular expression (so the default, ``\\.``, means commands start with a period), though using capturing groups will create problems.""" reply_errors = ValidatedAttribute('reply_errors', bool, default=True) """Whether to message the sender of a message that triggered an error with the exception.""" throttle_join = ValidatedAttribute('throttle_join', int) """Slow down the initial join of channels to prevent getting kicked. Sopel will only join this many channels at a time, sleeping for a second between each batch. This is unnecessary on most networks.""" timeout = ValidatedAttribute('timeout', int, default=120) """The amount of time acceptable between pings before timing out.""" use_ssl = ValidatedAttribute('use_ssl', bool, default=False) """Whether to use a SSL secured connection.""" user = ValidatedAttribute('user', default='sopel') """The "user" for your bot (the part before the @ in the hostname).""" verify_ssl = ValidatedAttribute('verify_ssl', bool, default=True) """Whether to require a trusted SSL certificate for SSL connections.""" sopel-6.6.9/sopel/config/types.py000066400000000000000000000313311347452002400167650ustar00rootroot00000000000000# coding=utf-8 """Types for creating section definitions. A section definition consists of a subclass of ``StaticSection``, on which any number of subclasses of ``BaseValidated`` (a few common ones of which are available in this module) are assigned as attributes. These descriptors define how to read values from, and write values to, the config file. As an example, if one wanted to define the ``[spam]`` section as having an ``eggs`` option, which contains a list of values, they could do this: >>> class SpamSection(StaticSection): ... eggs = ListAttribute('eggs') ... >>> SpamSection(config, 'spam') >>> print(config.spam.eggs) [] >>> config.spam.eggs = ['goose', 'turkey', 'duck', 'chicken', 'quail'] >>> print(config.spam.eggs) ['goose', 'turkey', 'duck', 'chicken', 'quail'] >>> config.spam.eggs = 'herring' Traceback (most recent call last): ... ValueError: ListAttribute value must be a list. """ from __future__ import unicode_literals, absolute_import, print_function, division import os.path import sys from sopel.tools import get_input if sys.version_info.major >= 3: unicode = str basestring = (str, bytes) class NO_DEFAULT(object): """A special value to indicate that there should be no default.""" class StaticSection(object): """A configuration section with parsed and validated settings. This class is intended to be subclassed with added ``ValidatedAttribute``\\s. """ def __init__(self, config, section_name, validate=True): if not config.parser.has_section(section_name): config.parser.add_section(section_name) self._parent = config self._parser = config.parser self._section_name = section_name for value in dir(self): try: getattr(self, value) except ValueError as e: raise ValueError( 'Invalid value for {}.{}: {}'.format(section_name, value, str(e)) ) except AttributeError: if validate: raise ValueError( 'Missing required value for {}.{}'.format(section_name, value) ) def configure_setting(self, name, prompt, default=NO_DEFAULT): """Return a validated value for this attribute from the terminal. ``prompt`` will be the docstring of the attribute if not given. If ``default`` is passed, it will be used if no value is given by the user. If it is not passed, the current value of the setting, or the default value if it's unset, will be used. Note that if ``default`` is passed, the current value of the setting will be ignored, even if it is not the attribute's default. """ clazz = getattr(self.__class__, name) if default is NO_DEFAULT: try: default = getattr(self, name) except AttributeError: pass except ValueError: print('The configured value for this option was invalid.') if clazz.default is not NO_DEFAULT: default = clazz.default while True: try: value = clazz.configure(prompt, default, self._parent, self._section_name) except ValueError as exc: print(exc) else: break setattr(self, name, value) class BaseValidated(object): """The base type for a descriptor in a ``StaticSection``.""" def __init__(self, name, default=None): """ ``name`` is the name of the setting in the section. ``default`` is the value to be returned if the setting is not set. If not given, AttributeError will be raised instead. """ self.name = name self.default = default def configure(self, prompt, default, parent, section_name): """With the prompt and default, parse and return a value from terminal. """ if default is not NO_DEFAULT and default is not None: prompt = '{} [{}]'.format(prompt, default) value = get_input(prompt + ' ') if not value and default is NO_DEFAULT: raise ValueError("You must provide a value for this option.") value = value or default return self.parse(value) def serialize(self, value): """Take some object, and return the string to be saved to the file. Must be implemented in subclasses. """ raise NotImplementedError("Serialize method must be implemented in subclass") def parse(self, value): """Take a string from the file, and return the appropriate object. Must be implemented in subclasses.""" raise NotImplementedError("Parse method must be implemented in subclass") def __get__(self, instance, owner=None): if instance is None: # If instance is None, we're getting from a section class, not an # instance of a session class. It makes the wizard code simpler # (and is really just more intuitive) to return the descriptor # instance here. return self if instance._parser.has_option(instance._section_name, self.name): value = instance._parser.get(instance._section_name, self.name) else: if self.default is not NO_DEFAULT: return self.default raise AttributeError( "Missing required value for {}.{}".format( instance._section_name, self.name ) ) return self.parse(value) def __set__(self, instance, value): if value is None: instance._parser.remove_option(instance._section_name, self.name) return value = self.serialize(value) instance._parser.set(instance._section_name, self.name, value) def __delete__(self, instance): instance._parser.remove_option(instance._section_name, self.name) def _parse_boolean(value): if value is True or value == 1: return value if isinstance(value, basestring): return value.lower() in ['1', 'yes', 'y', 'true', 'on'] return bool(value) def _serialize_boolean(value): return 'true' if _parse_boolean(value) else 'false' class ValidatedAttribute(BaseValidated): def __init__(self, name, parse=None, serialize=None, default=None): """A descriptor for settings in a ``StaticSection`` ``parse`` is the function to be used to read the string and create the appropriate object. If not given, return the string as-is. ``serialize`` takes an object, and returns the value to be written to the file. If not given, defaults to ``unicode``. """ self.name = name if parse == bool: parse = _parse_boolean if not serialize or serialize == bool: serialize = _serialize_boolean self.parse = parse or self.parse self.serialize = serialize or self.serialize self.default = default def serialize(self, value): return unicode(value) def parse(self, value): return value def configure(self, prompt, default, parent, section_name): if self.parse == _parse_boolean: prompt += ' (y/n)' default = 'y' if default else 'n' return super(ValidatedAttribute, self).configure(prompt, default, parent, section_name) class ListAttribute(BaseValidated): """A config attribute containing a list of string values. Values are saved to the file as a comma-separated list. It does not currently support commas within items in the list. By default, the spaces before and after each item are stripped; you can override this by passing ``strip=False``.""" def __init__(self, name, strip=True, default=None): default = default or [] super(ListAttribute, self).__init__(name, default=default) self.strip = strip def parse(self, value): value = list(filter(None, value.split(','))) if self.strip: return [v.strip() for v in value] else: return value def serialize(self, value): if not isinstance(value, (list, set)): raise ValueError('ListAttribute value must be a list.') return ','.join(value) def configure(self, prompt, default, parent, section_name): each_prompt = '?' if isinstance(prompt, tuple): each_prompt = prompt[1] prompt = prompt[0] if default is not NO_DEFAULT: default = ','.join(default) prompt = '{} [{}]'.format(prompt, default) else: default = '' print(prompt) values = [] value = get_input(each_prompt + ' ') or default while value: values.append(value) value = get_input(each_prompt + ' ') return self.parse(','.join(values)) class ChoiceAttribute(BaseValidated): """A config attribute which must be one of a set group of options. Currently, the choices can only be strings.""" def __init__(self, name, choices, default=None): super(ChoiceAttribute, self).__init__(name, default=default) self.choices = choices def parse(self, value): if value in self.choices: return value else: raise ValueError('Value must be in {}'.format(self.choices)) def serialize(self, value): if value in self.choices: return value else: raise ValueError('Value must be in {}'.format(self.choices)) class FilenameAttribute(BaseValidated): """A config attribute which must be a file or directory.""" def __init__(self, name, relative=True, directory=False, default=None): """ ``relative`` is whether the path should be relative to the location of the config file (absolute paths will still be absolute). If ``directory`` is True, the path must indicate a directory, rather than a file. """ super(FilenameAttribute, self).__init__(name, default=default) self.relative = relative self.directory = directory def __get__(self, instance, owner=None): if instance is None: return self if instance._parser.has_option(instance._section_name, self.name): value = instance._parser.get(instance._section_name, self.name) else: if self.default is not NO_DEFAULT: value = self.default else: raise AttributeError( "Missing required value for {}.{}".format( instance._section_name, self.name ) ) main_config = instance._parent this_section = getattr(main_config, instance._section_name) return self.parse(main_config, this_section, value) def __set__(self, instance, value): main_config = instance._parent this_section = getattr(main_config, instance._section_name) value = self.serialize(main_config, this_section, value) instance._parser.set(instance._section_name, self.name, value) def configure(self, prompt, default, parent, section_name): """With the prompt and default, parse and return a value from terminal. """ if default is not NO_DEFAULT and default is not None: prompt = '{} [{}]'.format(prompt, default) value = get_input(prompt + ' ') if not value and default is NO_DEFAULT: raise ValueError("You must provide a value for this option.") value = value or default return self.parse(parent, section_name, value) def parse(self, main_config, this_section, value): if value is None: return value = os.path.expanduser(value) if not os.path.isabs(value): if not self.relative: raise ValueError("Value must be an absolute path.") value = os.path.join(main_config.homedir, value) if self.directory and not os.path.isdir(value): try: os.makedirs(value) except (IOError, OSError): raise ValueError( "Value must be an existing or creatable directory.") if not self.directory and not os.path.isfile(value): try: open(value, 'w').close() except (IOError, OSError): raise ValueError("Value must be an existing or creatable file.") return value def serialize(self, main_config, this_section, value): self.parse(main_config, this_section, value) return value # So that it's still relative sopel-6.6.9/sopel/coretasks.py000066400000000000000000000722041347452002400163560ustar00rootroot00000000000000# coding=utf-8 """Tasks that allow the bot to run, but aren't user-facing functionality This is written as a module to make it easier to extend to support more responses to standard IRC codes without having to shove them all into the dispatch function in bot.py and making it easier to maintain. """ # Copyright 2008-2011, Sean B. Palmer (inamidst.com) and Michael Yanovich # (yanovich.net) # Copyright © 2012, Elad Alfassa # Copyright 2012-2015, Elsie Powell embolalia.com # Licensed under the Eiffel Forum License 2. from __future__ import unicode_literals, absolute_import, print_function, division from random import randint import re import sys import time import sopel import sopel.module from sopel.bot import _CapReq from sopel.tools import Identifier, iteritems, events from sopel.tools.target import User, Channel import base64 from sopel.logger import get_logger if sys.version_info.major >= 3: unicode = str LOGGER = get_logger(__name__) batched_caps = {} who_reqs = {} # Keeps track of reqs coming from this module, rather than others def auth_after_register(bot): """Do NickServ/AuthServ auth""" if bot.config.core.auth_method == 'nickserv': nickserv_name = bot.config.core.auth_target or 'NickServ' bot.msg( nickserv_name, 'IDENTIFY %s' % bot.config.core.auth_password ) elif bot.config.core.auth_method == 'authserv': account = bot.config.core.auth_username password = bot.config.core.auth_password bot.write(( 'AUTHSERV auth', account + ' ' + password )) elif bot.config.core.auth_method == 'Q': account = bot.config.core.auth_username password = bot.config.core.auth_password bot.write(( 'AUTH', account + ' ' + password )) elif bot.config.core.auth_method == 'userserv': userserv_name = bot.config.core.auth_target or 'UserServ' account = bot.config.core.auth_username password = bot.config.core.auth_password bot.msg(userserv_name, "LOGIN {account} {password}".format( account=account, password=password)) @sopel.module.event(events.RPL_WELCOME, events.RPL_LUSERCLIENT) @sopel.module.rule('.*') @sopel.module.thread(False) @sopel.module.unblockable def startup(bot, trigger): """Do tasks related to connecting to the network. 001 RPL_WELCOME is from RFC2812 and is the first message that is sent after the connection has been registered on the network. 251 RPL_LUSERCLIENT is a mandatory message that is sent after client connects to the server in rfc1459. RFC2812 does not require it and all networks might not send it. We support both. """ if bot.connection_registered: return bot.connection_registered = True auth_after_register(bot) modes = bot.config.core.modes bot.write(('MODE', '%s +%s' % (bot.nick, modes))) bot.memory['retry_join'] = dict() if bot.config.core.throttle_join: throttle_rate = int(bot.config.core.throttle_join) channels_joined = 0 for channel in bot.config.core.channels: channels_joined += 1 if not channels_joined % throttle_rate: time.sleep(1) bot.join(channel) else: for channel in bot.config.core.channels: bot.join(channel) if (not bot.config.core.owner_account and 'account-tag' in bot.enabled_capabilities and '@' not in bot.config.core.owner): msg = ( "This network supports using network services to identify you as " "my owner, rather than just matching your nickname. This is much " "more secure. If you'd like to do this, make sure you're logged in " "and reply with \"{}useserviceauth\"" ).format(bot.config.core.help_prefix) bot.msg(bot.config.core.owner, msg) @sopel.module.require_privmsg() @sopel.module.require_owner() @sopel.module.commands('useserviceauth') def enable_service_auth(bot, trigger): if bot.config.core.owner_account: return if 'account-tag' not in bot.enabled_capabilities: bot.say('This server does not fully support services auth, so this ' 'command is not available.') return if not trigger.account: bot.say('You must be logged in to network services before using this ' 'command.') return bot.config.core.owner_account = trigger.account bot.config.save() bot.say('Success! I will now use network services to identify you as my ' 'owner.') @sopel.module.event(events.ERR_NOCHANMODES) @sopel.module.rule('.*') @sopel.module.priority('high') def retry_join(bot, trigger): """Give NickServer enough time to identify on a +R channel. Give NickServ enough time to identify, and retry rejoining an identified-only (+R) channel. Maximum of ten rejoin attempts. """ channel = trigger.args[1] if channel in bot.memory['retry_join'].keys(): bot.memory['retry_join'][channel] += 1 if bot.memory['retry_join'][channel] > 10: LOGGER.warning('Failed to join %s after 10 attempts.', channel) return else: bot.memory['retry_join'][channel] = 0 bot.join(channel) return time.sleep(6) bot.join(channel) @sopel.module.rule('(.*)') @sopel.module.event(events.RPL_NAMREPLY) @sopel.module.priority('high') @sopel.module.thread(False) @sopel.module.unblockable def handle_names(bot, trigger): """Handle NAMES response, happens when joining to channels.""" names = trigger.split() # TODO specific to one channel type. See issue 281. channels = re.search(r'(#\S*)', trigger.raw) if not channels: return channel = Identifier(channels.group(1)) if channel not in bot.privileges: bot.privileges[channel] = dict() if channel not in bot.channels: bot.channels[channel] = Channel(channel) # This could probably be made flexible in the future, but I don't think # it'd be worth it. # If this ever needs to be updated, remember to change the mode handling in # the WHO-handler functions below, too. mapping = {'+': sopel.module.VOICE, '%': sopel.module.HALFOP, '@': sopel.module.OP, '&': sopel.module.ADMIN, '~': sopel.module.OWNER} for name in names: priv = 0 for prefix, value in iteritems(mapping): if prefix in name: priv = priv | value nick = Identifier(name.lstrip(''.join(mapping.keys()))) bot.privileges[channel][nick] = priv user = bot.users.get(nick) if user is None: # It's not possible to set the username/hostname from info received # in a NAMES reply, unfortunately. # Fortunately, the user should already exist in bot.users by the # time this code runs, so this is 99.9% ass-covering. user = User(nick, None, None) bot.users[nick] = user bot.channels[channel].add_user(user, privs=priv) @sopel.module.rule('(.*)') @sopel.module.event('MODE') @sopel.module.priority('high') @sopel.module.thread(False) @sopel.module.unblockable def track_modes(bot, trigger): """Track usermode changes and keep our lists of ops up to date.""" # Mode message format: *( ( "-" / "+" ) * * ) if len(trigger.args) < 3: # We need at least [channel, mode, nickname] to do anything useful # MODE messages with fewer args won't help us LOGGER.info("Received an apparently useless MODE message: {}" .format(trigger.raw)) return # Our old MODE parsing code checked if any of the args was empty. # Somewhere around here would be a good place to re-implement that if it's # actually necessary to guard against some non-compliant IRCd. But for now # let's just log malformed lines to the debug log. if not all(trigger.args): LOGGER.debug("The server sent a possibly malformed MODE message: {}" .format(trigger.raw)) # From here on, we will make a (possibly dangerous) assumption that the # received MODE message is more-or-less compliant channel = Identifier(trigger.args[0]) # If the first character of where the mode is being set isn't a # # then it's a user mode, not a channel mode, so we'll ignore it. # TODO: Handle CHANTYPES from ISUPPORT numeric (005) # (Actually, most of this function should be rewritten again when we parse # ISUPPORT...) if channel.is_nick(): return modestring = trigger.args[1] nicks = [Identifier(nick) for nick in trigger.args[2:]] mapping = {'v': sopel.module.VOICE, 'h': sopel.module.HALFOP, 'o': sopel.module.OP, 'a': sopel.module.ADMIN, 'q': sopel.module.OWNER} # Parse modes before doing anything else modes = [] sign = '' for char in modestring: # There was a comment claiming IRC allows e.g. MODE +aB-c foo, but it # doesn't seem to appear in any RFCs. But modern.ircdocs.horse shows # it, so we'll leave in the extra parsing for now. if char in '+-': sign = char elif char in mapping: # Filter out unexpected modes and hope they don't have parameters modes.append(sign + char) # Try to map modes to arguments, after sanity-checking if len(modes) != len(nicks) or not all([nick.is_nick() for nick in nicks]): # Something fucky happening, like unusual batching of non-privilege # modes together with the ones we expect. Way easier to just re-WHO # than try to account for non-standard parameter-taking modes. _send_who(bot, channel) return pairs = dict(zip(modes, nicks)) for (mode, nick) in pairs.items(): priv = bot.channels[channel].privileges.get(nick, 0) # Log a warning if the two privilege-tracking data structures # get out of sync. That should never happen. # This is a good place to verify that bot.channels is doing # what it's supposed to do before ultimately removing the old, # deprecated bot.privileges structure completely. ppriv = bot.privileges[channel].get(nick, 0) if priv != ppriv: LOGGER.warning("Privilege data error! Please share Sopel's" "raw log with the developers, if enabled. " "(Expected {} == {} for {} in {}.)" .format(priv, ppriv, nick, channel)) value = mapping.get(mode[1]) if value is not None: if mode[0] == '+': priv = priv | value else: priv = priv & ~value bot.privileges[channel][nick] = priv bot.channels[channel].privileges[nick] = priv @sopel.module.rule('.*') @sopel.module.event('NICK') @sopel.module.priority('high') @sopel.module.thread(False) @sopel.module.unblockable def track_nicks(bot, trigger): """Track nickname changes and maintain our chanops list accordingly.""" old = trigger.nick new = Identifier(trigger) # Give debug mssage, and PM the owner, if the bot's own nick changes. if old == bot.nick and new != bot.nick: privmsg = ("Hi, I'm your bot, %s." "Something has made my nick change. " "This can cause some problems for me, " "and make me do weird things. " "You'll probably want to restart me, " "and figure out what made that happen " "so you can stop it happening again. " "(Usually, it means you tried to give me a nick " "that's protected by NickServ.)") % bot.nick debug_msg = ("Nick changed by server. " "This can cause unexpected behavior. Please restart the bot.") LOGGER.critical(debug_msg) bot.msg(bot.config.core.owner, privmsg) return for channel in bot.privileges: channel = Identifier(channel) if old in bot.privileges[channel]: value = bot.privileges[channel].pop(old) bot.privileges[channel][new] = value for channel in bot.channels.values(): channel.rename_user(old, new) if old in bot.users: bot.users[new] = bot.users.pop(old) @sopel.module.rule('(.*)') @sopel.module.event('PART') @sopel.module.priority('high') @sopel.module.thread(False) @sopel.module.unblockable def track_part(bot, trigger): nick = trigger.nick channel = trigger.sender _remove_from_channel(bot, nick, channel) @sopel.module.rule('.*') @sopel.module.event('KICK') @sopel.module.priority('high') @sopel.module.thread(False) @sopel.module.unblockable def track_kick(bot, trigger): nick = Identifier(trigger.args[1]) channel = trigger.sender _remove_from_channel(bot, nick, channel) def _remove_from_channel(bot, nick, channel): if nick == bot.nick: bot.privileges.pop(channel, None) bot.channels.pop(channel, None) lost_users = [] for nick_, user in bot.users.items(): user.channels.pop(channel, None) if not user.channels: lost_users.append(nick_) for nick_ in lost_users: bot.users.pop(nick_, None) else: bot.privileges[channel].pop(nick, None) user = bot.users.get(nick) if user and channel in user.channels: bot.channels[channel].clear_user(nick) if not user.channels: bot.users.pop(nick, None) def _whox_enabled(bot): # Either privilege tracking or away notification. For simplicity, both # account notify and extended join must be there for account tracking. return (('account-notify' in bot.enabled_capabilities and 'extended-join' in bot.enabled_capabilities) or 'away-notify' in bot.enabled_capabilities) def _send_who(bot, channel): if _whox_enabled(bot): # WHOX syntax, see http://faerion.sourceforge.net/doc/irc/whox.var # Needed for accounts in who replies. The random integer is a param # to identify the reply as one from this command, because if someone # else sent it, we have no fucking way to know what the format is. rand = str(randint(0, 999)) while rand in who_reqs: rand = str(randint(0, 999)) who_reqs[rand] = channel bot.write(['WHO', channel, 'a%nuachtf,' + rand]) else: # We might be on an old network, but we still care about keeping our # user list updated bot.write(['WHO', channel]) @sopel.module.rule('.*') @sopel.module.event('JOIN') @sopel.module.priority('high') @sopel.module.thread(False) @sopel.module.unblockable def track_join(bot, trigger): if trigger.nick == bot.nick and trigger.sender not in bot.channels: bot.write(('TOPIC', trigger.sender)) bot.privileges[trigger.sender] = dict() bot.channels[trigger.sender] = Channel(trigger.sender) _send_who(bot, trigger.sender) bot.privileges[trigger.sender][trigger.nick] = 0 user = bot.users.get(trigger.nick) if user is None: user = User(trigger.nick, trigger.user, trigger.host) bot.users[trigger.nick] = user bot.channels[trigger.sender].add_user(user) if len(trigger.args) > 1 and trigger.args[1] != '*' and ( 'account-notify' in bot.enabled_capabilities and 'extended-join' in bot.enabled_capabilities): user.account = trigger.args[1] @sopel.module.rule('.*') @sopel.module.event('QUIT') @sopel.module.priority('high') @sopel.module.thread(False) @sopel.module.unblockable def track_quit(bot, trigger): for chanprivs in bot.privileges.values(): chanprivs.pop(trigger.nick, None) for channel in bot.channels.values(): channel.clear_user(trigger.nick) bot.users.pop(trigger.nick, None) @sopel.module.rule('.*') @sopel.module.event('CAP') @sopel.module.thread(False) @sopel.module.priority('high') @sopel.module.unblockable def recieve_cap_list(bot, trigger): cap = trigger.strip('-=~') # Server is listing capabilites if trigger.args[1] == 'LS': recieve_cap_ls_reply(bot, trigger) # Server denied CAP REQ elif trigger.args[1] == 'NAK': entry = bot._cap_reqs.get(cap, None) # If it was requested with bot.cap_req if entry: for req in entry: # And that request was mandatory/prohibit, and a callback was # provided if req.prefix and req.failure: # Call it. req.failure(bot, req.prefix + cap) # Server is removing a capability elif trigger.args[1] == 'DEL': entry = bot._cap_reqs.get(cap, None) # If it was requested with bot.cap_req if entry: for req in entry: # And that request wasn't prohibit, and a callback was # provided if req.prefix != '-' and req.failure: # Call it. req.failure(bot, req.prefix + cap) # Server is adding new capability elif trigger.args[1] == 'NEW': entry = bot._cap_reqs.get(cap, None) # If it was requested with bot.cap_req if entry: for req in entry: # And that request wasn't prohibit if req.prefix != '-': # Request it bot.write(('CAP', 'REQ', req.prefix + cap)) # Server is acknowledging a capability elif trigger.args[1] == 'ACK': caps = trigger.args[2].split() for cap in caps: cap.strip('-~= ') bot.enabled_capabilities.add(cap) entry = bot._cap_reqs.get(cap, []) for req in entry: if req.success: req.success(bot, req.prefix + trigger) if cap == 'sasl': # TODO why is this not done with bot.cap_req? recieve_cap_ack_sasl(bot) def recieve_cap_ls_reply(bot, trigger): if bot.server_capabilities: # We've already seen the results, so someone sent CAP LS from a module. # We're too late to do SASL, and we don't want to send CAP END before # the module has done what it needs to, so just return return for cap in trigger.split(): c = cap.split('=') if len(c) == 2: batched_caps[c[0]] = c[1] else: batched_caps[c[0]] = None # Not the last in a multi-line reply. First two args are * and LS. if trigger.args[2] == '*': return bot.server_capabilities = batched_caps # If some other module requests it, we don't need to add another request. # If some other module prohibits it, we shouldn't request it. core_caps = ['multi-prefix', 'away-notify', 'cap-notify', 'server-time'] for cap in core_caps: if cap not in bot._cap_reqs: bot._cap_reqs[cap] = [_CapReq('', 'coretasks')] def acct_warn(bot, cap): LOGGER.info('Server does not support %s, or it conflicts with a custom ' 'module. User account validation unavailable or limited.', cap[1:]) if bot.config.core.owner_account or bot.config.core.admin_accounts: LOGGER.warning( 'Owner or admin accounts are configured, but %s is not ' 'supported by the server. This may cause unexpected behavior.', cap[1:]) auth_caps = ['account-notify', 'extended-join', 'account-tag'] for cap in auth_caps: if cap not in bot._cap_reqs: bot._cap_reqs[cap] = [_CapReq('', 'coretasks', acct_warn)] for cap, reqs in iteritems(bot._cap_reqs): # At this point, we know mandatory and prohibited don't co-exist, but # we need to call back for optionals if they're also prohibited prefix = '' for entry in reqs: if prefix == '-' and entry.prefix != '-': entry.failure(bot, entry.prefix + cap) continue if entry.prefix: prefix = entry.prefix # It's not required, or it's supported, so we can request it if prefix != '=' or cap in bot.server_capabilities: # REQs fail as a whole, so we send them one capability at a time bot.write(('CAP', 'REQ', entry.prefix + cap)) # If it's required but not in server caps, we need to call all the # callbacks else: for entry in reqs: if entry.failure and entry.prefix == '=': entry.failure(bot, entry.prefix + cap) # If we want to do SASL, we have to wait before we can send CAP END. So if # we are, wait on 903 (SASL successful) to send it. if bot.config.core.auth_method == 'sasl': bot.write(('CAP', 'REQ', 'sasl')) else: bot.write(('CAP', 'END')) def recieve_cap_ack_sasl(bot): # Presumably we're only here if we said we actually *want* sasl, but still # check anyway. password = bot.config.core.auth_password if not password: return mech = bot.config.core.auth_target or 'PLAIN' bot.write(('AUTHENTICATE', mech)) def send_authenticate(bot, token): """Send ``AUTHENTICATE`` command to server with the given ``token``. :param bot: instance of IRC bot that must authenticate :param str token: authentication token In case the ``token`` is more than 400 bytes, we need to split it and send as many ``AUTHENTICATE`` commands as needed. If the last chunk is 400 bytes long, we must also send a last empty command (`AUTHENTICATE +` is for empty line), so the server knows we are done with ``AUTHENTICATE``. .. seealso:: https://ircv3.net/specs/extensions/sasl-3.1.html#the-authenticate-command """ # payload is a base64 encoded token payload = base64.b64encode(token.encode('utf-8')) # split the payload into chunks of at most 400 bytes chunk_size = 400 for i in range(0, len(payload), chunk_size): offset = i + chunk_size chunk = payload[i:offset] bot.write(('AUTHENTICATE', chunk)) # send empty (+) AUTHENTICATE when payload's length is a multiple of 400 if len(payload) % chunk_size == 0: bot.write(('AUTHENTICATE', '+')) @sopel.module.event('AUTHENTICATE') @sopel.module.rule('.*') def auth_proceed(bot, trigger): if trigger.args[0] != '+': # How did we get here? I am not good with computer. return # Is this right? sasl_username = bot.config.core.auth_username or bot.nick sasl_password = bot.config.core.auth_password sasl_token = '\0'.join((sasl_username, sasl_username, sasl_password)) send_authenticate(bot, sasl_token) @sopel.module.event(events.RPL_SASLSUCCESS) @sopel.module.rule('.*') def sasl_success(bot, trigger): bot.write(('CAP', 'END')) # Live blocklist editing @sopel.module.commands('blocks') @sopel.module.priority('low') @sopel.module.thread(False) @sopel.module.unblockable def blocks(bot, trigger): """ Manage Sopel's blocking features.\ See [ignore system documentation]({% link _usage/ignoring-people.md %}). """ if not trigger.admin: return STRINGS = { "success_del": "Successfully deleted block: %s", "success_add": "Successfully added block: %s", "no_nick": "No matching nick block found for: %s", "no_host": "No matching hostmask block found for: %s", "invalid": "Invalid format for %s a block. Try: .blocks add (nick|hostmask) sopel", "invalid_display": "Invalid input for displaying blocks.", "nonelisted": "No %s listed in the blocklist.", 'huh': "I could not figure out what you wanted to do.", } masks = set(s for s in bot.config.core.host_blocks if s != '') nicks = set(Identifier(nick) for nick in bot.config.core.nick_blocks if nick != '') text = trigger.group().split() if len(text) == 3 and text[1] == "list": if text[2] == "hostmask": if len(masks) > 0: blocked = ', '.join(unicode(mask) for mask in masks) bot.say("Blocked hostmasks: {}".format(blocked)) else: bot.reply(STRINGS['nonelisted'] % ('hostmasks')) elif text[2] == "nick": if len(nicks) > 0: blocked = ', '.join(unicode(nick) for nick in nicks) bot.say("Blocked nicks: {}".format(blocked)) else: bot.reply(STRINGS['nonelisted'] % ('nicks')) else: bot.reply(STRINGS['invalid_display']) elif len(text) == 4 and text[1] == "add": if text[2] == "nick": nicks.add(text[3]) bot.config.core.nick_blocks = nicks bot.config.save() elif text[2] == "hostmask": masks.add(text[3].lower()) bot.config.core.host_blocks = list(masks) else: bot.reply(STRINGS['invalid'] % ("adding")) return bot.reply(STRINGS['success_add'] % (text[3])) elif len(text) == 4 and text[1] == "del": if text[2] == "nick": if Identifier(text[3]) not in nicks: bot.reply(STRINGS['no_nick'] % (text[3])) return nicks.remove(Identifier(text[3])) bot.config.core.nick_blocks = [unicode(n) for n in nicks] bot.config.save() bot.reply(STRINGS['success_del'] % (text[3])) elif text[2] == "hostmask": mask = text[3].lower() if mask not in masks: bot.reply(STRINGS['no_host'] % (text[3])) return masks.remove(mask) bot.config.core.host_blocks = [unicode(m) for m in masks] bot.config.save() bot.reply(STRINGS['success_del'] % (text[3])) else: bot.reply(STRINGS['invalid'] % ("deleting")) return else: bot.reply(STRINGS['huh']) @sopel.module.event('ACCOUNT') @sopel.module.rule('.*') def account_notify(bot, trigger): if trigger.nick not in bot.users: bot.users[trigger.nick] = User(trigger.nick, trigger.user, trigger.host) account = trigger.args[0] if account == '*': account = None bot.users[trigger.nick].account = account @sopel.module.event(events.RPL_WHOSPCRPL) @sopel.module.rule('.*') @sopel.module.priority('high') @sopel.module.unblockable def recv_whox(bot, trigger): if len(trigger.args) < 2 or trigger.args[1] not in who_reqs: # Ignored, some module probably called WHO return if len(trigger.args) != 8: return LOGGER.warning('While populating `bot.accounts` a WHO response was malformed.') _, _, channel, user, host, nick, status, account = trigger.args away = 'G' in status modes = ''.join([c for c in status if c in '~&@%+']) _record_who(bot, channel, user, host, nick, account, away, modes) def _record_who(bot, channel, user, host, nick, account=None, away=None, modes=None): nick = Identifier(nick) channel = Identifier(channel) if nick not in bot.users: usr = User(nick, user, host) bot.users[nick] = usr else: usr = bot.users[nick] # check for & fill in sparse User added by handle_names() if usr.host is None and host: usr.host = host if usr.user is None and user: usr.user = user if account == '0': usr.account = None else: usr.account = account usr.away = away priv = 0 if modes: mapping = {'+': sopel.module.VOICE, '%': sopel.module.HALFOP, '@': sopel.module.OP, '&': sopel.module.ADMIN, '~': sopel.module.OWNER} for c in modes: priv = priv | mapping[c] if channel not in bot.channels: bot.channels[channel] = Channel(channel) bot.channels[channel].add_user(usr, privs=priv) if channel not in bot.privileges: bot.privileges[channel] = dict() bot.privileges[channel][nick] = priv @sopel.module.event(events.RPL_WHOREPLY) @sopel.module.rule('.*') @sopel.module.priority('high') @sopel.module.unblockable def recv_who(bot, trigger): channel, user, host, _, nick, status = trigger.args[1:7] modes = ''.join([c for c in status if c in '~&@%+']) _record_who(bot, channel, user, host, nick, modes=modes) @sopel.module.event(events.RPL_ENDOFWHO) @sopel.module.rule('.*') @sopel.module.priority('high') @sopel.module.unblockable def end_who(bot, trigger): if _whox_enabled(bot): who_reqs.pop(trigger.args[1], None) @sopel.module.rule('.*') @sopel.module.event('AWAY') @sopel.module.priority('high') @sopel.module.thread(False) @sopel.module.unblockable def track_notify(bot, trigger): if trigger.nick not in bot.users: bot.users[trigger.nick] = User(trigger.nick, trigger.user, trigger.host) user = bot.users[trigger.nick] user.away = bool(trigger.args) @sopel.module.rule('.*') @sopel.module.event('TOPIC') @sopel.module.event(events.RPL_TOPIC) @sopel.module.priority('high') @sopel.module.thread(False) @sopel.module.unblockable def track_topic(bot, trigger): if trigger.event != 'TOPIC': channel = trigger.args[1] else: channel = trigger.args[0] if channel not in bot.channels: return bot.channels[channel].topic = trigger.args[-1] sopel-6.6.9/sopel/db.py000066400000000000000000000220131347452002400147360ustar00rootroot00000000000000# coding=utf-8 from __future__ import unicode_literals, absolute_import, print_function, division import json import os.path import sys import sqlite3 from sopel.tools import Identifier if sys.version_info.major >= 3: unicode = str basestring = str def _deserialize(value): if value is None: return None # sqlite likes to return ints for strings that look like ints, even though # the column type is string. That's how you do dynamic typing wrong. value = unicode(value) # Just in case someone's mucking with the DB in a way we can't account for, # ignore json parsing errors try: value = json.loads(value) except ValueError: pass return value class SopelDB(object): """*Availability: 5.0+* This defines an interface for basic, common operations on a sqlite database. It simplifies those common operations, and allows direct access to the database, wherever the user has configured it to be. When configured with a relative filename, it is assumed to be in the same directory as the config.""" def __init__(self, config): path = config.core.db_filename config_dir, config_file = os.path.split(config.filename) config_name, _ = os.path.splitext(config_file) if path is None: path = os.path.join(config_dir, config_name + '.db') path = os.path.expanduser(path) if not os.path.isabs(path): path = os.path.normpath(os.path.join(config_dir, path)) self.filename = path self._create() def connect(self): """Return a raw database connection object.""" return sqlite3.connect(self.filename, timeout=10) def execute(self, *args, **kwargs): """Execute an arbitrary SQL query against the database. Returns a cursor object, on which things like `.fetchall()` can be called per PEP 249.""" with self.connect() as conn: cur = conn.cursor() return cur.execute(*args, **kwargs) def _create(self): """Create the basic database structure.""" self.execute( 'CREATE TABLE IF NOT EXISTS nick_ids (nick_id INTEGER PRIMARY KEY AUTOINCREMENT)' ) self.execute( 'CREATE TABLE IF NOT EXISTS nicknames ' '(nick_id INTEGER REFERENCES nick_ids, ' 'slug STRING PRIMARY KEY, canonical string)' ) self.execute( 'CREATE TABLE IF NOT EXISTS nick_values ' '(nick_id INTEGER REFERENCES nick_ids(nick_id), ' 'key STRING, value STRING, ' 'PRIMARY KEY (nick_id, key))' ) self.execute( 'CREATE TABLE IF NOT EXISTS channel_values ' '(channel STRING, key STRING, value STRING, ' 'PRIMARY KEY (channel, key))' ) def get_uri(self): """Returns a URL for the database, usable to connect with SQLAlchemy.""" return 'sqlite:///{}'.format(self.filename) # NICK FUNCTIONS def get_nick_id(self, nick, create=True): """Return the internal identifier for a given nick. This identifier is unique to a user, and shared across all of that user's aliases. If create is True, a new ID will be created if one does not already exist""" slug = nick.lower() nick_id = self.execute('SELECT nick_id from nicknames where slug = ?', [slug]).fetchone() if nick_id is None: if not create: raise ValueError('No ID exists for the given nick') with self.connect() as conn: cur = conn.cursor() cur.execute('INSERT INTO nick_ids VALUES (NULL)') nick_id = cur.execute('SELECT last_insert_rowid()').fetchone()[0] cur.execute( 'INSERT INTO nicknames (nick_id, slug, canonical) VALUES ' '(?, ?, ?)', [nick_id, slug, nick] ) nick_id = self.execute('SELECT nick_id from nicknames where slug = ?', [slug]).fetchone() return nick_id[0] def alias_nick(self, nick, alias): """Create an alias for a nick. Raises ValueError if the alias already exists. If nick does not already exist, it will be added along with the alias.""" nick = Identifier(nick) alias = Identifier(alias) nick_id = self.get_nick_id(nick) sql = 'INSERT INTO nicknames (nick_id, slug, canonical) VALUES (?, ?, ?)' values = [nick_id, alias.lower(), alias] try: self.execute(sql, values) except sqlite3.IntegrityError: raise ValueError('Alias already exists.') def set_nick_value(self, nick, key, value): """Sets the value for a given key to be associated with the nick.""" nick = Identifier(nick) value = json.dumps(value, ensure_ascii=False) nick_id = self.get_nick_id(nick) self.execute('INSERT OR REPLACE INTO nick_values VALUES (?, ?, ?)', [nick_id, key, value]) def get_nick_value(self, nick, key): """Retrieves the value for a given key associated with a nick.""" nick = Identifier(nick) result = self.execute( 'SELECT value FROM nicknames JOIN nick_values ' 'ON nicknames.nick_id = nick_values.nick_id ' 'WHERE slug = ? AND key = ?', [nick.lower(), key] ).fetchone() if result is not None: result = result[0] return _deserialize(result) def unalias_nick(self, alias): """Removes an alias. Raises ValueError if there is not at least one other nick in the group. To delete an entire group, use `delete_group`. """ alias = Identifier(alias) nick_id = self.get_nick_id(alias, False) count = self.execute('SELECT COUNT(*) FROM nicknames WHERE nick_id = ?', [nick_id]).fetchone()[0] if count <= 1: raise ValueError('Given alias is the only entry in its group.') self.execute('DELETE FROM nicknames WHERE slug = ?', [alias.lower()]) def delete_nick_group(self, nick): """Removes a nickname, and all associated aliases and settings.""" nick = Identifier(nick) nick_id = self.get_nick_id(nick, False) self.execute('DELETE FROM nicknames WHERE nick_id = ?', [nick_id]) self.execute('DELETE FROM nick_values WHERE nick_id = ?', [nick_id]) def merge_nick_groups(self, first_nick, second_nick): """Merges the nick groups for the specified nicks. Takes two nicks, which may or may not be registered. Unregistered nicks will be registered. Keys which are set for only one of the given nicks will be preserved. Where multiple nicks have values for a given key, the value set for the first nick will be used. Note that merging of data only applies to the native key-value store. If modules define their own tables which rely on the nick table, they will need to have their merging done separately.""" first_id = self.get_nick_id(Identifier(first_nick)) second_id = self.get_nick_id(Identifier(second_nick)) self.execute( 'UPDATE OR IGNORE nick_values SET nick_id = ? WHERE nick_id = ?', [first_id, second_id]) self.execute('DELETE FROM nick_values WHERE nick_id = ?', [second_id]) self.execute('UPDATE nicknames SET nick_id = ? WHERE nick_id = ?', [first_id, second_id]) # CHANNEL FUNCTIONS def set_channel_value(self, channel, key, value): """Sets the value for a given key to be associated with the channel.""" channel = Identifier(channel).lower() value = json.dumps(value, ensure_ascii=False) self.execute('INSERT OR REPLACE INTO channel_values VALUES (?, ?, ?)', [channel, key, value]) def get_channel_value(self, channel, key): """Retrieves the value for a given key associated with a channel.""" channel = Identifier(channel).lower() result = self.execute( 'SELECT value FROM channel_values WHERE channel = ? AND key = ?', [channel, key] ).fetchone() if result is not None: result = result[0] return _deserialize(result) # NICK AND CHANNEL FUNCTIONS def get_nick_or_channel_value(self, name, key): """Gets the value `key` associated to the nick or channel `name`.""" name = Identifier(name) if name.is_nick(): return self.get_nick_value(name, key) else: return self.get_channel_value(name, key) def get_preferred_value(self, names, key): """Gets the value for the first name which has it set. `names` is a list of channel and/or user names. Returns None if none of the names have the key set.""" for name in names: value = self.get_nick_or_channel_value(name, key) if value is not None: return value sopel-6.6.9/sopel/formatting.py000066400000000000000000000126361347452002400165350ustar00rootroot00000000000000# coding=utf-8 """The formatting module includes functions to apply IRC formatting to text. *Availability: 4.5+* """ # Copyright 2014, Elsie Powell, embolalia.com # Licensed under the Eiffel Forum License 2. from __future__ import unicode_literals, absolute_import, print_function, division import string import sys if sys.version_info.major >= 3: unicode = str # Color names are as specified at http://www.mirc.com/colors.html CONTROL_NORMAL = '\x0f' """The control code to reset formatting""" CONTROL_COLOR = '\x03' """The control code to start or end color formatting""" CONTROL_HEX_COLOR = '\x04' """The control code to start or end hexadecimal color formatting""" CONTROL_BOLD = '\x02' """The control code to start or end bold formatting""" CONTROL_ITALIC = '\x1d' """The control code to start or end italic formatting""" CONTROL_UNDERLINE = '\x1f' """The control code to start or end underlining""" CONTROL_STRIKETHROUGH = '\x1e' """The control code to start or end strikethrough formatting""" CONTROL_MONOSPACE = '\x11' """The control code to start or end monospace formatting""" CONTROL_REVERSE = '\x16' """The control code to start or end reverse-color formatting""" # TODO when we can move to 3.3+ completely, make this an Enum. class colors: WHITE = '00' BLACK = '01' BLUE = '02' NAVY = BLUE GREEN = '03' RED = '04' BROWN = '05' MAROON = BROWN PURPLE = '06' ORANGE = '07' OLIVE = ORANGE YELLOW = '08' LIGHT_GREEN = '09' LIME = LIGHT_GREEN TEAL = '10' LIGHT_CYAN = '11' CYAN = LIGHT_CYAN LIGHT_BLUE = '12' ROYAL = LIGHT_BLUE PINK = '13' LIGHT_PURPLE = PINK FUCHSIA = PINK GREY = '14' LIGHT_GREY = '15' SILVER = LIGHT_GREY # Create aliases. GRAY = GREY LIGHT_GRAY = LIGHT_GREY def _get_color(color): if color is None: return None # You can pass an int or string of the code try: color = int(color) except ValueError: pass if isinstance(color, int): if color > 99: raise ValueError('Can not specify a color above 99.') return unicode(color).rjust(2, '0') # You can also pass the name of the color color_name = color.upper() color_dict = colors.__dict__ try: return color_dict[color_name] except KeyError: raise ValueError('Unknown color name {}'.format(color)) def color(text, fg=None, bg=None): """Return the text, with the given colors applied in IRC formatting. The color can be a string of the color name, or an integer between 0 and 99. The known color names can be found in the :class:`colors` class of this module. """ if not fg and not bg: return text fg = _get_color(fg) bg = _get_color(bg) if not bg: text = ''.join([CONTROL_COLOR, fg, text, CONTROL_COLOR]) else: text = ''.join([CONTROL_COLOR, fg, ',', bg, text, CONTROL_COLOR]) return text def _get_hex_color(color): if color is None: return None try: color = color.upper() if not all(c in string.hexdigits for c in color): raise AttributeError except AttributeError: raise ValueError('Hexadecimal color value must be passed as string.') if len(color) == 3: return ''.join([c * 2 for c in color]) elif len(color) == 6: return color else: # invalid length raise ValueError('Hexadecimal color value must have either 3 or 6 digits.') def hex_color(text, fg=None, bg=None): """Return the text, with the given colors applied in IRC formatting. The color can be provided with a string of either 3 or 6 hexadecimal digits. As in CSS, 3-digit colors will be interpreted as if they were 6-digit colors with each digit repeated (e.g. color ``c90`` is identical to ``cc9900``). """ if not fg and not bg: return text fg = _get_hex_color(fg) bg = _get_hex_color(bg) if not bg: text = ''.join([CONTROL_HEX_COLOR, fg, text, CONTROL_HEX_COLOR]) else: text = ''.join([CONTROL_HEX_COLOR, fg, ',', bg, text, CONTROL_HEX_COLOR]) return text def bold(text): """Return the text, with bold IRC formatting.""" return ''.join([CONTROL_BOLD, text, CONTROL_BOLD]) def italic(text): """Return the text, with italic IRC formatting.""" return ''.join([CONTROL_ITALIC, text, CONTROL_ITALIC]) def underline(text): """Return the text, with underline IRC formatting.""" return ''.join([CONTROL_UNDERLINE, text, CONTROL_UNDERLINE]) def strikethrough(text): """Return the text, with strikethrough IRC formatting. Note: This is a relatively new addition to IRC formatting conventions. Use only when you can afford to have its meaning lost, as not many clients support it yet.""" return ''.join([CONTROL_STRIKETHROUGH, text, CONTROL_STRIKETHROUGH]) def monospace(text): """Return the text, with monospace IRC formatting. Note: This is a relatively new addition to IRC formatting conventions. Use only when you can afford to have its meaning lost, as not many clients support it yet.""" return ''.join([CONTROL_MONOSPACE, text, CONTROL_MONOSPACE]) def reverse(text): """Return the text, with reverse-color IRC formatting. Note: This code isn't super well supported, and its behavior even in clients that understand it (e.g. mIRC) can be unpredictable. Use it carefully.""" return ''.join([CONTROL_REVERSE, text, CONTROL_REVERSE]) sopel-6.6.9/sopel/irc.py000066400000000000000000000440401347452002400151320ustar00rootroot00000000000000# coding=utf-8 # irc.py - An Utility IRC Bot # Copyright 2008, Sean B. Palmer, inamidst.com # Copyright 2012, Elsie Powell, http://embolalia.com # Copyright © 2012, Elad Alfassa # # Licensed under the Eiffel Forum License 2. # # When working on core IRC protocol related features, consult protocol # documentation at http://www.irchelp.org/irchelp/rfc/ from __future__ import unicode_literals, absolute_import, print_function, division import sys import time import socket import asyncore import asynchat import os import codecs import traceback from sopel.logger import get_logger from sopel.tools import stderr, Identifier from sopel.trigger import PreTrigger try: import ssl if not hasattr(ssl, 'match_hostname'): # Attempt to import ssl_match_hostname from python-backports import backports.ssl_match_hostname ssl.match_hostname = backports.ssl_match_hostname.match_hostname ssl.CertificateError = backports.ssl_match_hostname.CertificateError has_ssl = True except ImportError: # no SSL support has_ssl = False import errno import threading from datetime import datetime if sys.version_info.major >= 3: unicode = str LOGGER = get_logger(__name__) class Bot(asynchat.async_chat): def __init__(self, config): ca_certs = config.core.ca_certs asynchat.async_chat.__init__(self) self.set_terminator(b'\n') self.buffer = '' self.nick = Identifier(config.core.nick) """Sopel's current ``Identifier``. Changing this while Sopel is running is untested.""" self.user = config.core.user """Sopel's user/ident.""" self.name = config.core.name """Sopel's "real name", as used for whois.""" self.stack = {} self.ca_certs = ca_certs self.enabled_capabilities = set() self.hasquit = False self.sending = threading.RLock() self.writing_lock = threading.Lock() self.raw = None # Right now, only accounting for two op levels. # This might be expanded later. # These lists are filled in startup.py, as of right now. # Are these even touched at all anymore? Remove in 7.0. self.ops = dict() """Deprecated. Use bot.channels instead.""" self.halfplus = dict() """Deprecated. Use bot.channels instead.""" self.voices = dict() """Deprecated. Use bot.channels instead.""" # We need this to prevent error loops in handle_error self.error_count = 0 self.connection_registered = False """ Set to True when a server has accepted the client connection and messages can be sent and received. """ # Work around bot.connecting missing in Python older than 2.7.4 if not hasattr(self, "connecting"): self.connecting = False def log_raw(self, line, prefix): """Log raw line to the raw log.""" if not self.config.core.log_raw: return if not os.path.isdir(self.config.core.logdir): try: os.mkdir(self.config.core.logdir) except Exception as e: stderr('There was a problem creating the logs directory.') stderr('%s %s' % (str(e.__class__), str(e))) stderr('Please fix this and then run Sopel again.') os._exit(1) f = codecs.open(os.path.join(self.config.core.logdir, 'raw.log'), 'a', encoding='utf-8') f.write(prefix + unicode(time.time()) + "\t") temp = line.replace('\n', '') f.write(temp) f.write("\n") f.close() def safe(self, string): """Remove newlines from a string.""" if sys.version_info.major >= 3 and isinstance(string, bytes): string = string.decode('utf8') elif sys.version_info.major < 3: if not isinstance(string, unicode): string = unicode(string, encoding='utf8') string = string.replace('\n', '') string = string.replace('\r', '') return string def write(self, args, text=None): args = [self.safe(arg) for arg in args] if text is not None: text = self.safe(text) try: # Blocking lock, can't send two things at a time self.writing_lock.acquire() # From RFC2812 Internet Relay Chat: Client Protocol # Section 2.3 # # https://tools.ietf.org/html/rfc2812.html # # IRC messages are always lines of characters terminated with a # CR-LF (Carriage Return - Line Feed) pair, and these messages SHALL # NOT exceed 512 characters in length, counting all characters # including the trailing CR-LF. Thus, there are 510 characters # maximum allowed for the command and its parameters. There is no # provision for continuation of message lines. max_length = unicode_max_length = 510 if text is not None: temp = (' '.join(args) + ' :' + text) else: temp = ' '.join(args) # The max length of 512 is in bytes, not unicode while len(temp.encode('utf-8')) > max_length: temp = temp[:unicode_max_length] unicode_max_length = unicode_max_length - 1 # Ends the message with CR-LF temp = temp + '\r\n' # Log and output the message self.log_raw(temp, '>>') self.send(temp.encode('utf-8')) finally: self.writing_lock.release() def run(self, host, port=6667): try: self.initiate_connect(host, port) except socket.error as e: stderr('Connection error: %s' % e) self.handle_close() def initiate_connect(self, host, port): stderr('Connecting to %s:%s...' % (host, port)) source_address = ((self.config.core.bind_host, 0) if self.config.core.bind_host else None) self.set_socket(socket.create_connection((host, port), source_address=source_address)) if self.config.core.use_ssl and has_ssl: self.send = self._ssl_send self.recv = self._ssl_recv elif not has_ssl and self.config.core.use_ssl: stderr('SSL is not avilable on your system, attempting connection ' 'without it') self.connect((host, port)) try: asyncore.loop() except KeyboardInterrupt: print('KeyboardInterrupt') self.quit('KeyboardInterrupt') def quit(self, message): """Disconnect from IRC and close the bot.""" if self.connected: # Only send QUIT message if socket is open self.write(['QUIT'], message) self.hasquit = True # Wait for acknowledgement from the server. By RFC 2812 it should be # an ERROR msg, but many servers just close the connection. Either way # is fine by us. # Closing the connection now would mean that stuff in the buffers that # has not yet been processed would never be processed. It would also # release the main thread, which is problematic because whomever called # quit might still want to do something before main thread quits. def handle_close(self): self.connection_registered = False if hasattr(self, '_shutdown'): self._shutdown() stderr('Closed!') # This will eventually call asyncore dispatchers close method, which # will release the main thread. This should be called last to avoid # race conditions. if self.socket: self.close() def handle_connect(self): """ Connect to IRC server, handle TLS and authenticate user if an account exists. """ # handle potential TLS connection if self.config.core.use_ssl and has_ssl: if not self.config.core.verify_ssl: self.ssl = ssl.wrap_socket(self.socket, do_handshake_on_connect=True, suppress_ragged_eofs=True) else: self.ssl = ssl.wrap_socket(self.socket, do_handshake_on_connect=True, suppress_ragged_eofs=True, cert_reqs=ssl.CERT_REQUIRED, ca_certs=self.ca_certs) # connect to host specified in config first try: ssl.match_hostname(self.ssl.getpeercert(), self.config.core.host) except ssl.CertificateError: # the host in config and certificate don't match LOGGER.error("hostname mismatch between configuration and certificate") # check (via exception) if a CNAME matches as a fallback has_matched = False for hostname in self._get_cnames(self.config.core.host): try: ssl.match_hostname(self.ssl.getpeercert(), hostname) LOGGER.warning("using {0} instead of {1} for TLS connection" .format(hostname, self.config.core.host)) has_matched = True break except ssl.CertificateError: pass if not has_matched: # everything is broken stderr("Invalid certificate, hostname mismatch!") LOGGER.error("invalid certificate, no hostname matches") if hasattr(self.config.core, 'pid_file_path'): os.unlink(self.config.core.pid_file_path) os._exit(1) self.set_socket(self.ssl) # Request list of server capabilities. IRCv3 servers will respond with # CAP * LS (which we handle in coretasks). v2 servers will respond with # 421 Unknown command, which we'll ignore self.write(('CAP', 'LS', '302')) # authenticate account if needed if self.config.core.auth_method == 'server': password = self.config.core.auth_password self.write(('PASS', password)) self.write(('NICK', self.nick)) self.write(('USER', self.user, '+iw', self.nick), self.name) # maintain connection stderr('Connected.') self.last_ping_time = datetime.now() timeout_check_thread = threading.Thread(target=self._timeout_check) timeout_check_thread.daemon = True timeout_check_thread.start() ping_thread = threading.Thread(target=self._send_ping) ping_thread.daemon = True ping_thread.start() def _get_cnames(self, domain): """ Determine the CNAMEs for a given domain. :param domain: domain to check :type domain: str :returns: list (of str) """ import dns.resolver cnames = [] try: answer = dns.resolver.query(domain, "CNAME") except dns.resolver.NoAnswer: return [] for data in answer: if isinstance(data, dns.rdtypes.ANY.CNAME.CNAME): cname = data.to_text()[:-1] cnames.append(cname) return cnames def _timeout_check(self): while self.connected or self.connecting: if (datetime.now() - self.last_ping_time).seconds > int(self.config.core.timeout): stderr('Ping timeout reached after %s seconds, closing connection' % self.config.core.timeout) self.handle_close() break else: time.sleep(int(self.config.core.timeout)) def _send_ping(self): while self.connected or self.connecting: if self.connected and (datetime.now() - self.last_ping_time).seconds > int(self.config.core.timeout) / 2: try: self.write(('PING', self.config.core.host)) except socket.error: pass time.sleep(int(self.config.core.timeout) / 2) def _ssl_send(self, data): """Replacement for self.send() during SSL connections.""" try: result = self.socket.send(data) return result except ssl.SSLError as why: if why[0] in (asyncore.EWOULDBLOCK, errno.ESRCH): return 0 else: raise why return 0 def _ssl_recv(self, buffer_size): """Replacement for self.recv() during SSL connections. From: http://evanfosmark.com/2010/09/ssl-support-in-asynchatasync_chat """ try: data = self.socket.read(buffer_size) if not data: self.handle_close() return b'' return data except ssl.SSLError as why: if why[0] in (asyncore.ECONNRESET, asyncore.ENOTCONN, asyncore.ESHUTDOWN): self.handle_close() return '' elif why[0] == errno.ENOENT: # Required in order to keep it non-blocking return b'' else: raise def collect_incoming_data(self, data): # We can't trust clients to pass valid unicode. try: data = unicode(data, encoding='utf-8') except UnicodeDecodeError: # not unicode, let's try cp1252 try: data = unicode(data, encoding='cp1252') except UnicodeDecodeError: # Okay, let's try ISO8859-1 try: data = unicode(data, encoding='iso8859-1') except UnicodeDecodeError: # Discard line if encoding is unknown return if data: self.log_raw(data, '<<') self.buffer += data def found_terminator(self): line = self.buffer if line.endswith('\r'): line = line[:-1] self.buffer = '' self.last_ping_time = datetime.now() pretrigger = PreTrigger(self.nick, line) if all(cap not in self.enabled_capabilities for cap in ['account-tag', 'extended-join']): pretrigger.tags.pop('account', None) if pretrigger.event == 'PING': self.write(('PONG', pretrigger.args[-1])) elif pretrigger.event == 'ERROR': LOGGER.error("ERROR received from server: %s", pretrigger.args[-1]) if self.hasquit: self.close_when_done() elif pretrigger.event == '433': stderr('Nickname already in use!') self.handle_close() self.dispatch(pretrigger) def dispatch(self, pretrigger): pass def error(self, trigger=None): """Called internally when a module causes an error.""" try: trace = traceback.format_exc() if sys.version_info.major < 3: trace = trace.decode('utf-8', errors='xmlcharrefreplace') stderr(trace) try: lines = list(reversed(trace.splitlines())) report = [lines[0].strip()] for line in lines: line = line.strip() if line.startswith('File "'): report.append(line[0].lower() + line[1:]) break else: report.append('source unknown') signature = '%s (%s)' % (report[0], report[1]) # TODO: make not hardcoded log_filename = os.path.join(self.config.core.logdir, 'exceptions.log') with codecs.open(log_filename, 'a', encoding='utf-8') as logfile: logfile.write('Signature: %s\n' % signature) if trigger: logfile.write('from {} at {}. Message was: {}\n'.format( trigger.nick, str(datetime.now()), trigger.group(0))) logfile.write(trace) logfile.write( '----------------------------------------\n\n' ) except Exception as e: stderr("Could not save full traceback!") LOGGER.error("Could not save traceback from %s to file: %s", trigger.sender, str(e)) if trigger and self.config.core.reply_errors and trigger.sender is not None: self.msg(trigger.sender, signature) if trigger: LOGGER.error('Exception from {}: {} ({})'.format(trigger.sender, str(signature), trigger.raw)) except Exception as e: if trigger and self.config.core.reply_errors and trigger.sender is not None: self.msg(trigger.sender, "Got an error.") if trigger: LOGGER.error('Exception from {}: {} ({})'.format(trigger.sender, str(e), trigger.raw)) def handle_error(self): """Handle any uncaptured error in the core. Overrides asyncore's handle_error. """ trace = traceback.format_exc() stderr(trace) LOGGER.error('Fatal error in core, please review exception log') # TODO: make not hardcoded logfile = codecs.open( os.path.join(self.config.core.logdir, 'exceptions.log'), 'a', encoding='utf-8' ) logfile.write('Fatal error in core, handle_error() was called\n') logfile.write('last raw line was %s' % self.raw) logfile.write(trace) logfile.write('Buffer:\n') logfile.write(self.buffer) logfile.write('----------------------------------------\n\n') logfile.close() if self.error_count > 10: if (datetime.now() - self.last_error_timestamp).seconds < 5: stderr("Too many errors, can't continue") os._exit(1) self.last_error_timestamp = datetime.now() self.error_count = self.error_count + 1 sopel-6.6.9/sopel/loader.py000066400000000000000000000173421347452002400156300ustar00rootroot00000000000000# coding=utf-8 from __future__ import unicode_literals, absolute_import, print_function, division import imp import os.path import re import sys from sopel.tools import compile_rule, itervalues, get_command_regexp, get_nickname_command_regexp from sopel.config import core_section default_prefix = core_section.CoreSection.help_prefix.default del core_section if sys.version_info.major >= 3: basestring = (str, bytes) def get_module_description(path): good_file = (os.path.isfile(path) and path.endswith('.py') and not path.startswith('_')) good_dir = (os.path.isdir(path) and os.path.isfile(os.path.join(path, '__init__.py'))) if good_file: name = os.path.basename(path)[:-3] return (name, path, imp.PY_SOURCE) elif good_dir: name = os.path.basename(path) return (name, path, imp.PKG_DIRECTORY) else: return None def _update_modules_from_dir(modules, directory): # Note that this modifies modules in place for path in os.listdir(directory): path = os.path.join(directory, path) result = get_module_description(path) if result: modules[result[0]] = result[1:] def enumerate_modules(config, show_all=False): """Map the names of modules to the location of their file. Return a dict mapping the names of modules to a tuple of the module name, the pathname and either `imp.PY_SOURCE` or `imp.PKG_DIRECTORY`. This searches the regular modules directory and all directories specified in the `core.extra` attribute of the `config` object. If two modules have the same name, the last one to be found will be returned and the rest will be ignored. Modules are found starting in the regular directory, followed by `~/.sopel/modules`, and then through the extra directories in the order that the are specified. If `show_all` is given as `True`, the `enable` and `exclude` configuration options will be ignored, and all modules will be shown (though duplicates will still be ignored as above). """ modules = {} # First, add modules from the regular modules directory main_dir = os.path.dirname(os.path.abspath(__file__)) modules_dir = os.path.join(main_dir, 'modules') _update_modules_from_dir(modules, modules_dir) for path in os.listdir(modules_dir): break # Then, find PyPI installed modules # TODO does this work with all possible install mechanisms? try: import sopel_modules except Exception: # TODO: Be specific pass else: for directory in sopel_modules.__path__: _update_modules_from_dir(modules, directory) # Next, look in ~/.sopel/modules home_modules_dir = os.path.join(config.homedir, 'modules') if not os.path.isdir(home_modules_dir): os.makedirs(home_modules_dir) _update_modules_from_dir(modules, home_modules_dir) # Last, look at all the extra directories. for directory in config.core.extra: _update_modules_from_dir(modules, directory) # Coretasks is special. No custom user coretasks. ct_path = os.path.join(main_dir, 'coretasks.py') modules['coretasks'] = (ct_path, imp.PY_SOURCE) # If caller wants all of them, don't apply white and blacklists if show_all: return modules # Apply whitelist, if present enable = config.core.enable if enable: enabled_modules = {'coretasks': modules['coretasks']} for module in enable: if module in modules: enabled_modules[module] = modules[module] modules = enabled_modules # Apply blacklist, if present exclude = config.core.exclude for module in exclude: if module in modules: del modules[module] return modules def trim_docstring(doc): """Get the docstring as a series of lines that can be sent""" if not doc: return [] lines = doc.expandtabs().splitlines() indent = sys.maxsize for line in lines[1:]: stripped = line.lstrip() if stripped: indent = min(indent, len(line) - len(stripped)) trimmed = [lines[0].strip()] if indent < sys.maxsize: for line in lines[1:]: trimmed.append(line[:].rstrip()) while trimmed and not trimmed[-1]: trimmed.pop() while trimmed and not trimmed[0]: trimmed.pop(0) return trimmed def clean_callable(func, config): """Compiles the regexes, moves commands into func.rule, fixes up docs and puts them in func._docs, and sets defaults""" nick = config.core.nick alias_nicks = config.core.alias_nicks prefix = config.core.prefix help_prefix = config.core.help_prefix func._docs = {} doc = trim_docstring(func.__doc__) example = None func.unblockable = getattr(func, 'unblockable', False) func.priority = getattr(func, 'priority', 'medium') func.thread = getattr(func, 'thread', True) func.rate = getattr(func, 'rate', 0) func.channel_rate = getattr(func, 'channel_rate', 0) func.global_rate = getattr(func, 'global_rate', 0) if not hasattr(func, 'event'): func.event = ['PRIVMSG'] else: if isinstance(func.event, basestring): func.event = [func.event.upper()] else: func.event = [event.upper() for event in func.event] if hasattr(func, 'rule'): if isinstance(func.rule, basestring): func.rule = [func.rule] func.rule = [compile_rule(nick, rule, alias_nicks) for rule in func.rule] if hasattr(func, 'commands') or hasattr(func, 'nickname_commands'): func.rule = getattr(func, 'rule', []) for command in getattr(func, 'commands', []): regexp = get_command_regexp(prefix, command) func.rule.append(regexp) for command in getattr(func, 'nickname_commands', []): regexp = get_nickname_command_regexp(nick, command, alias_nicks) func.rule.append(regexp) if hasattr(func, 'example'): example = func.example[0]["example"] example = example.replace('$nickname', nick) if example[0] != help_prefix and not example.startswith(nick): example = example.replace(default_prefix, help_prefix, 1) if doc or example: cmds = [] cmds.extend(getattr(func, 'commands', [])) cmds.extend(getattr(func, 'nickname_commands', [])) for command in cmds: func._docs[command] = (doc, example) if hasattr(func, 'intents'): func.intents = [re.compile(intent, re.IGNORECASE) for intent in func.intents] def load_module(name, path, type_): """Load a module, and sort out the callables and shutdowns""" if type_ == imp.PY_SOURCE: with open(path) as mod: module = imp.load_module(name, mod, path, ('.py', 'U', type_)) elif type_ == imp.PKG_DIRECTORY: module = imp.load_module(name, None, path, ('', '', type_)) else: raise TypeError('Unsupported module type') return module, os.path.getmtime(path) def is_triggerable(obj): return any(hasattr(obj, attr) for attr in ('rule', 'intents', 'commands', 'nickname_commands')) def clean_module(module, config): callables = [] shutdowns = [] jobs = [] urls = [] for obj in itervalues(vars(module)): if callable(obj): if getattr(obj, '__name__', None) == 'shutdown': shutdowns.append(obj) elif is_triggerable(obj): clean_callable(obj, config) callables.append(obj) elif hasattr(obj, 'interval'): clean_callable(obj, config) jobs.append(obj) elif hasattr(obj, 'url_regex'): urls.append(obj) return callables, jobs, shutdowns, urls sopel-6.6.9/sopel/logger.py000066400000000000000000000034451347452002400156400ustar00rootroot00000000000000# coding=utf-8 from __future__ import unicode_literals, absolute_import, print_function, division import logging class IrcLoggingHandler(logging.Handler): def __init__(self, bot, level): super(IrcLoggingHandler, self).__init__(level) self._bot = bot self._channel = bot.config.core.logging_channel def emit(self, record): try: msg = self.format(record) self._bot.msg(self._channel, msg) except (KeyboardInterrupt, SystemExit): raise except Exception: # TODO: Be specific self.handleError(record) class ChannelOutputFormatter(logging.Formatter): def __init__(self): super(ChannelOutputFormatter, self).__init__( fmt='[%(filename)s] %(message)s' ) def formatException(self, exc_info): # logging will through a newline between the message and this, but # that's fine because Sopel will strip it back out anyway return ' - ' + repr(exc_info[1]) def setup_logging(bot): level = bot.config.core.logging_level or 'WARNING' logging.basicConfig(level=level) logger = logging.getLogger('sopel') if bot.config.core.logging_channel: handler = IrcLoggingHandler(bot, level) handler.setFormatter(ChannelOutputFormatter()) logger.addHandler(handler) def get_logger(name=None): """Return a logger for a module, if the name is given. This is equivalent to `logging.getLogger('sopel.modules.' + name)` when name is given, and `logging.getLogger('sopel')` when it is not. The latter case is intended for use in Sopel's core; modules should call `get_logger(__name__)` to get a logger.""" if name: return logging.getLogger('sopel.modules.' + name) else: return logging.getLogger('sopel') sopel-6.6.9/sopel/module.py000066400000000000000000000407511347452002400156470ustar00rootroot00000000000000# coding=utf-8 """This contains decorators and tools for creating callable plugin functions. """ # Copyright 2013, Ari Koivula, # Copyright © 2013, Elad Alfassa # Copyright 2013, Lior Ramati # Licensed under the Eiffel Forum License 2. from __future__ import unicode_literals, absolute_import, print_function, division import re import sopel.test_tools import functools NOLIMIT = 1 """Return value for ``callable``\\s, which suppresses rate limiting for the call. Returning this value means the triggering user will not be prevented from triggering the command again within the rate limit. This can be used, for example, to allow a user to retry a failed command immediately. .. versionadded:: 4.0 """ VOICE = 1 """Privilege level for the +v channel permission .. versionadded:: 4.1 """ HALFOP = 2 """Privilege level for the +h channel permission .. versionadded:: 4.1 """ OP = 4 """Privilege level for the +o channel permission .. versionadded:: 4.1 """ ADMIN = 8 """Privilege level for the +a channel permission .. versionadded:: 4.1 """ OWNER = 16 """Privilege level for the +q channel permission .. versionadded:: 4.1 """ def unblockable(function): """Decorator which exempts the function from nickname and hostname blocking. This can be used to ensure events such as JOIN are always recorded. """ function.unblockable = True return function def interval(*args): """Decorates a function to be called by the bot every X seconds. This decorator can be used multiple times for multiple intervals, or all intervals can be given at once as arguments. The first time the function will be called is X seconds after the bot was started. Unlike other plugin functions, ones decorated by interval must only take a :class:`sopel.bot.Sopel` as their argument; they do not get a trigger. The bot argument will not have a context, so functions like ``bot.say()`` will not have a default destination. There is no guarantee that the bot is connected to a server or joined a channel when the function is called, so care must be taken. Example::: import sopel.module @sopel.module.interval(5) def spam_every_5s(bot): if "#here" in bot.channels: bot.msg("#here", "It has been five seconds!") """ def add_attribute(function): if not hasattr(function, "interval"): function.interval = [] for arg in args: function.interval.append(arg) return function return add_attribute def rule(value): """Decorate a function to be called when a line matches the given pattern This decorator can be used multiple times to add more rules. Args: value: A regular expression which will trigger the function. If the Sopel instance is in a channel, or sent a PRIVMSG, where a string matching this expression is said, the function will execute. Note that captured groups here will be retrievable through the Trigger object later. Inside the regular expression, some special directives can be used. $nick will be replaced with the nick of the bot and , or :, and $nickname will be replaced with the nick of the bot. """ def add_attribute(function): if not hasattr(function, "rule"): function.rule = [] function.rule.append(value) return function return add_attribute def thread(value): """Decorate a function to specify if it should be run in a separate thread. Functions run in a separate thread (as is the default) will not prevent the bot from executing other functions at the same time. Functions not run in a separate thread may be started while other functions are still running, but additional functions will not start until it is completed. Args: value: Either True or False. If True the function is called in a separate thread. If False from the main thread. """ def add_attribute(function): function.thread = value return function return add_attribute def commands(*command_list): """Decorate a function to set one or more commands to trigger it. This decorator can be used to add multiple commands to one callable in a single line. The resulting match object will have the command as the first group, rest of the line, excluding leading whitespace, as the second group. Parameters 1 through 4, separated by whitespace, will be groups 3-6. Args: command: A string, which can be a regular expression. Returns: A function with a new command appended to the commands attribute. If there is no commands attribute, it is added. Example: @commands("hello"): If the command prefix is "\\.", this would trigger on lines starting with ".hello". @commands('j', 'join') If the command prefix is "\\.", this would trigger on lines starting with either ".j" or ".join". """ def add_attribute(function): if not hasattr(function, "commands"): function.commands = [] function.commands.extend(command_list) return function return add_attribute def nickname_commands(*command_list): """Decorate a function to trigger on lines starting with "$nickname: command". This decorator can be used multiple times to add multiple rules. The resulting match object will have the command as the first group, rest of the line, excluding leading whitespace, as the second group. Parameters 1 through 4, separated by whitespace, will be groups 3-6. Args: command: A string, which can be a regular expression. Returns: A function with a new regular expression appended to the rule attribute. If there is no rule attribute, it is added. Example: @nickname_commands("hello!"): Would trigger on "$nickname: hello!", "$nickname, hello!", "$nickname hello!", "$nickname hello! parameter1" and "$nickname hello! p1 p2 p3 p4 p5 p6 p7 p8 p9". @nickname_commands(".*"): Would trigger on anything starting with "$nickname[:,]? ", and would never have any additional parameters, as the command would match the rest of the line. """ def add_attribute(function): function.nickname_commands = [cmd for cmd in command_list] return function return add_attribute def priority(value): """Decorate a function to be executed with higher or lower priority. Args: value: Priority can be one of "high", "medium", "low". Defaults to medium. Priority allows you to control the order of callable execution, if your module needs it. """ def add_attribute(function): function.priority = value return function return add_attribute def event(*event_list): """Decorate a function to be triggered on specific IRC events. This is one of a number of events, such as 'JOIN', 'PART', 'QUIT', etc. (More details can be found in RFC 1459.) When the Sopel bot is sent one of these events, the function will execute. Note that functions with an event must also be given a rule to match (though it may be '.*', which will always match) or they will not be triggered. :class:`sopel.tools.events` provides human-readable names for many of the numeric events, which may help your code be clearer. """ def add_attribute(function): if not hasattr(function, "event"): function.event = [] function.event.extend(event_list) return function return add_attribute def intent(*intent_list): """Decorate a callable trigger on a message with any of the given intents. .. versionadded:: 5.2.0 """ def add_attribute(function): if not hasattr(function, "intents"): function.intents = [] function.intents.extend(intent_list) return function return add_attribute def rate(user=0, channel=0, server=0): """Decorate a function to limit how often it can be triggered on a per-user basis, in a channel, or across the server (bot). A value of zero means no limit. If a function is given a rate of 20, that function may only be used once every 20 seconds in the scope corresponding to the parameter. Users on the admin list in Sopel’s configuration are exempted from rate limits. Rate-limited functions that use scheduled future commands should import threading.Timer() instead of sched, or rate limiting will not work properly. """ def add_attribute(function): function.rate = user function.channel_rate = channel function.global_rate = server return function return add_attribute def require_privmsg(message=None): """Decorate a function to only be triggerable from a private message. If it is triggered in a channel message, `message` will be said if given. """ def actual_decorator(function): @functools.wraps(function) def _nop(*args, **kwargs): # Assign trigger and bot for easy access later bot, trigger = args[0:2] if trigger.is_privmsg: return function(*args, **kwargs) else: if message and not callable(message): bot.say(message) return _nop # Hack to allow decorator without parens if callable(message): return actual_decorator(message) return actual_decorator def require_chanmsg(message=None): """Decorate a function to only be triggerable from a channel message. If it is triggered in a private message, `message` will be said if given. """ def actual_decorator(function): @functools.wraps(function) def _nop(*args, **kwargs): # Assign trigger and bot for easy access later bot, trigger = args[0:2] if not trigger.is_privmsg: return function(*args, **kwargs) else: if message and not callable(message): bot.say(message) return _nop # Hack to allow decorator without parens if callable(message): return actual_decorator(message) return actual_decorator def require_privilege(level, message=None): """Decorate a function to require at least the given channel permission. `level` can be one of the privilege levels defined in this module. If the user does not have the privilege, `message` will be said if given. If it is a private message, no checking will be done.""" def actual_decorator(function): @functools.wraps(function) def guarded(bot, trigger, *args, **kwargs): # If this is a privmsg, ignore privilege requirements if trigger.is_privmsg: return function(bot, trigger, *args, **kwargs) channel_privs = bot.channels[trigger.sender].privileges allowed = channel_privs.get(trigger.nick, 0) >= level if not trigger.is_privmsg and not allowed: if message and not callable(message): bot.say(message) else: return function(bot, trigger, *args, **kwargs) return guarded return actual_decorator def require_admin(message=None): """Decorate a function to require the triggering user to be a bot admin. If they are not, `message` will be said if given.""" def actual_decorator(function): @functools.wraps(function) def guarded(bot, trigger, *args, **kwargs): if not trigger.admin: if message and not callable(message): bot.say(message) else: return function(bot, trigger, *args, **kwargs) return guarded # Hack to allow decorator without parens if callable(message): return actual_decorator(message) return actual_decorator def require_owner(message=None): """Decorate a function to require the triggering user to be the bot owner. If they are not, `message` will be said if given.""" def actual_decorator(function): @functools.wraps(function) def guarded(bot, trigger, *args, **kwargs): if not trigger.owner: if message and not callable(message): bot.say(message) else: return function(bot, trigger, *args, **kwargs) return guarded # Hack to allow decorator without parens if callable(message): return actual_decorator(message) return actual_decorator def url(url_rule): """Decorate a function to handle URLs. This decorator takes a regex string that will be matched against URLs in a message. The function it decorates, in addition to the bot and trigger, must take a third argument ``match``, which is the regular expression match of the URL. This should be used rather than the matching in trigger, in order to support e.g. the ``.title`` command. """ def actual_decorator(function): @functools.wraps(function) def helper(bot, trigger, match=None): match = match or trigger return function(bot, trigger, match) helper.url_regex = re.compile(url_rule) return helper return actual_decorator class example(object): """Decorate a function with an example. Args: msg: (required) The example command as sent by a user on IRC. If it is a prefixed command, the command prefix used in the example must match the default `config.core.help_prefix` for compatibility with the built-in help module. result: What the example command is expected to output. If given, a test is generated using `msg` as input. The test behavior can be modified by the remaining optional arguments. privmsg: If true, the test will behave as if the input was sent to the bot in a private message. If false (default), the test will treat the input as having come from a channel. admin: Whether to treat the test message as having been sent by a bot admin (`trigger.admin == True`). owner: Whether to treat the test message as having been sent by the bot's owner (`trigger.owner == True`). repeat: Integer number of times to repeat the test. Useful for commands that return random results. re: If true, `result` is parsed as a regular expression. Also useful for commands that return random results, or that call an external API that doesn't always return the same value. ignore: List of outputs to ignore. Strings in this list are always interpreted as regular expressions. """ def __init__(self, msg, result=None, privmsg=False, admin=False, owner=False, repeat=1, re=False, ignore=None): # Wrap result into a list for get_example_test if isinstance(result, list): self.result = result elif result is not None: self.result = [result] else: self.result = None self.use_re = re self.msg = msg self.privmsg = privmsg self.admin = admin self.owner = owner self.repeat = repeat if isinstance(ignore, list): self.ignore = ignore elif ignore is not None: self.ignore = [ignore] else: self.ignore = [] def __call__(self, func): if not hasattr(func, "example"): func.example = [] import sys # only inject test-related stuff if we're running tests # see https://stackoverflow.com/a/44595269/5991 if 'pytest' in sys.modules and self.result: test = sopel.test_tools.get_example_test( func, self.msg, self.result, self.privmsg, self.admin, self.owner, self.repeat, self.use_re, self.ignore ) sopel.test_tools.insert_into_module( test, func.__module__, func.__name__, 'test_example' ) sopel.test_tools.insert_into_module( sopel.test_tools.get_disable_setup(), func.__module__, func.__name__, 'disable_setup' ) record = { "example": self.msg, "result": self.result, "privmsg": self.privmsg, "admin": self.admin, } func.example.append(record) return func sopel-6.6.9/sopel/modules/000077500000000000000000000000001347452002400154515ustar00rootroot00000000000000sopel-6.6.9/sopel/modules/__init__.py000066400000000000000000000001421347452002400175570ustar00rootroot00000000000000# coding=utf-8 from __future__ import unicode_literals, absolute_import, print_function, division sopel-6.6.9/sopel/modules/admin.py000066400000000000000000000175201347452002400171200ustar00rootroot00000000000000# coding=utf-8 """ admin.py - Sopel Admin Module Copyright 2010-2011, Sean B. Palmer (inamidst.com) and Michael Yanovich (yanovich.net) Copyright © 2012, Elad Alfassa, Copyright 2013, Ari Koivula Licensed under the Eiffel Forum License 2. https://sopel.chat """ from __future__ import unicode_literals, absolute_import, print_function, division from sopel.config.types import ( StaticSection, ValidatedAttribute, FilenameAttribute ) import sopel.module class AdminSection(StaticSection): hold_ground = ValidatedAttribute('hold_ground', bool, default=False) """Auto re-join on kick""" auto_accept_invite = ValidatedAttribute('auto_accept_invite', bool, default=True) def configure(config): """ | name | example | purpose | | ---- | ------- | ------- | | hold\\_ground | False | Auto-rejoin the channel after being kicked. | | auto\\_accept\\_invite | True | Auto-join channels when invited. | """ config.define_section('admin', AdminSection) config.admin.configure_setting('hold_ground', "Automatically re-join after being kicked?") config.admin.configure_setting('auto_accept_invite', 'Automatically join channels when invited?') def setup(bot): bot.config.define_section('admin', AdminSection) @sopel.module.require_privmsg @sopel.module.require_admin @sopel.module.commands('join') @sopel.module.priority('low') @sopel.module.example('.join #example or .join #example key') def join(bot, trigger): """Join the specified channel. This is an admin-only command.""" channel, key = trigger.group(3), trigger.group(4) if not channel: return elif not key: bot.join(channel) else: bot.join(channel, key) @sopel.module.require_privmsg @sopel.module.require_admin @sopel.module.commands('part') @sopel.module.priority('low') @sopel.module.example('.part #example') def part(bot, trigger): """Part the specified channel. This is an admin-only command.""" channel, _sep, part_msg = trigger.group(2).partition(' ') if part_msg: bot.part(channel, part_msg) else: bot.part(channel) @sopel.module.require_privmsg @sopel.module.require_owner @sopel.module.commands('quit') @sopel.module.priority('low') def quit(bot, trigger): """Quit from the server. This is an owner-only command.""" quit_message = trigger.group(2) if not quit_message: quit_message = 'Quitting on command from %s' % trigger.nick bot.quit(quit_message) @sopel.module.require_privmsg @sopel.module.require_admin @sopel.module.commands('msg') @sopel.module.priority('low') @sopel.module.example('.msg #YourPants Does anyone else smell neurotoxin?') def msg(bot, trigger): """ Send a message to a given channel or nick. Can only be done in privmsg by an admin. """ if trigger.group(2) is None: return channel, _sep, message = trigger.group(2).partition(' ') message = message.strip() if not channel or not message: return bot.msg(channel, message) @sopel.module.require_privmsg @sopel.module.require_admin @sopel.module.commands('me') @sopel.module.priority('low') def me(bot, trigger): """ Send an ACTION (/me) to a given channel or nick. Can only be done in privmsg by an admin. """ if trigger.group(2) is None: return channel, _sep, action = trigger.group(2).partition(' ') action = action.strip() if not channel or not action: return msg = '\x01ACTION %s\x01' % action bot.msg(channel, msg) @sopel.module.event('INVITE') @sopel.module.rule('.*') @sopel.module.priority('low') def invite_join(bot, trigger): """ Join a channel Sopel is invited to, if the inviter is an admin. """ if trigger.admin or bot.config.admin.auto_accept_invite: bot.join(trigger.args[1]) return @sopel.module.event('KICK') @sopel.module.rule(r'.*') @sopel.module.priority('low') def hold_ground(bot, trigger): """ This function monitors all kicks across all channels Sopel is in. If it detects that it is the one kicked it'll automatically join that channel. WARNING: This may not be needed and could cause problems if Sopel becomes annoying. Please use this with caution. """ if bot.config.admin.hold_ground: channel = trigger.sender if trigger.args[1] == bot.nick: bot.join(channel) @sopel.module.require_privmsg @sopel.module.require_admin @sopel.module.commands('mode') @sopel.module.priority('low') def mode(bot, trigger): """Set a user mode on Sopel. Can only be done in privmsg by an admin.""" mode = trigger.group(3) bot.write(('MODE', bot.nick + ' ' + mode)) @sopel.module.require_privmsg("This command only works as a private message.") @sopel.module.require_admin("This command requires admin privileges.") @sopel.module.commands('set') @sopel.module.example('.set core.owner Me') def set_config(bot, trigger): """See and modify values of Sopel's config object. Trigger args: arg1 - section and option, in the form "section.option" arg2 - value If there is no section, section will default to "core". If value is None, the option will be deleted. """ # Get section and option from first argument. match = trigger.group(3) if match is None: bot.reply("Usage: .set section.option value") return arg1 = match.split('.') if len(arg1) == 1: section_name, option = "core", arg1[0] elif len(arg1) == 2: section_name, option = arg1 else: bot.reply("Usage: .set section.option value") return section = getattr(bot.config, section_name) static_sec = isinstance(section, StaticSection) if static_sec and not hasattr(section, option): bot.say('[{}] section has no option {}.'.format(section_name, option)) return delim = trigger.group(2).find(' ') # Skip preceding whitespaces, if any. while delim > 0 and delim < len(trigger.group(2)) and trigger.group(2)[delim] == ' ': delim = delim + 1 # Display current value if no value is given. if delim == -1 or delim == len(trigger.group(2)): if not static_sec and bot.config.parser.has_option(section, option): bot.reply("Option %s.%s does not exist." % (section_name, option)) return # Except if the option looks like a password. Censor those to stop them # from being put on log files. if option.endswith("password") or option.endswith("pass"): value = "(password censored)" else: value = getattr(section, option) bot.reply("%s.%s = %s" % (section_name, option, value)) return # Owner-related settings cannot be modified interactively. Any changes to these # settings must be made directly in the config file. if section_name == 'core' and option in ['owner', 'owner_account']: bot.say("Changing '{}.{}' requires manually editing the configuration file." .format(section_name, option)) return # Otherwise, set the value to one given as argument 2. value = trigger.group(2)[delim:] if static_sec: descriptor = getattr(section.__class__, option) try: if isinstance(descriptor, FilenameAttribute): value = descriptor.parse(bot.config, descriptor, value) else: value = descriptor.parse(value) except ValueError as exc: bot.say("Can't set attribute: " + str(exc)) return setattr(section, option, value) @sopel.module.require_privmsg @sopel.module.require_admin @sopel.module.commands('save') @sopel.module.example('.save') def save_config(bot, trigger): """Save state of Sopel's config object to the configuration file.""" bot.config.save() sopel-6.6.9/sopel/modules/adminchannel.py000066400000000000000000000175351347452002400204570ustar00rootroot00000000000000# coding=utf-8 # Copyright 2010-2011, Michael Yanovich, Alek Rollyson, and Elsie Powell # Copyright © 2012, Elad Alfassa # Licensed under the Eiffel Forum License 2. from __future__ import unicode_literals, absolute_import, print_function, division import re from sopel import formatting from sopel.module import commands, priority, OP, HALFOP, require_privilege, require_chanmsg from sopel.tools import Identifier def default_mask(trigger): welcome = formatting.color('Welcome to:', formatting.colors.PURPLE) chan = formatting.color(trigger.sender, formatting.colors.TEAL) topic_ = formatting.bold('Topic:') topic_ = formatting.color('| ' + topic_, formatting.colors.PURPLE) arg = formatting.color('{}', formatting.colors.GREEN) return '{} {} {} {}'.format(welcome, chan, topic_, arg) @require_chanmsg @require_privilege(OP, 'You are not a channel operator.') @commands('kick') @priority('high') def kick(bot, trigger): """ Kick a user from the channel. """ if bot.channels[trigger.sender].privileges[bot.nick] < HALFOP: return bot.reply("I'm not a channel operator!") text = trigger.group().split() argc = len(text) if argc < 2: return opt = Identifier(text[1]) nick = opt channel = trigger.sender reasonidx = 2 if not opt.is_nick(): if argc < 3: return nick = text[2] channel = opt reasonidx = 3 reason = ' '.join(text[reasonidx:]) if nick != bot.config.core.nick: bot.write(['KICK', channel, nick], reason) def configureHostMask(mask): if mask == '*!*@*': return mask if re.match('^[^.@!/]+$', mask) is not None: return '%s!*@*' % mask if re.match('^[^@!]+$', mask) is not None: return '*!*@%s' % mask m = re.match('^([^!@]+)@$', mask) if m is not None: return '*!%s@*' % m.group(1) m = re.match('^([^!@]+)@([^@!]+)$', mask) if m is not None: return '*!%s@%s' % (m.group(1), m.group(2)) m = re.match('^([^!@]+)!(^[!@]+)@?$', mask) if m is not None: return '%s!%s@*' % (m.group(1), m.group(2)) return '' @require_chanmsg @require_privilege(OP, 'You are not a channel operator.') @commands('ban') @priority('high') def ban(bot, trigger): """ This give admins the ability to ban a user. The bot must be a Channel Operator for this command to work. """ if bot.channels[trigger.sender].privileges[bot.nick] < HALFOP: return bot.reply("I'm not a channel operator!") text = trigger.group().split() argc = len(text) if argc < 2: return opt = Identifier(text[1]) banmask = opt channel = trigger.sender if not opt.is_nick(): if argc < 3: return channel = opt banmask = text[2] banmask = configureHostMask(banmask) if banmask == '': return bot.write(['MODE', channel, '+b', banmask]) @require_chanmsg @require_privilege(OP, 'You are not a channel operator.') @commands('unban') def unban(bot, trigger): """ This give admins the ability to unban a user. The bot must be a Channel Operator for this command to work. """ if bot.channels[trigger.sender].privileges[bot.nick] < HALFOP: return bot.reply("I'm not a channel operator!") text = trigger.group().split() argc = len(text) if argc < 2: return opt = Identifier(text[1]) banmask = opt channel = trigger.sender if not opt.is_nick(): if argc < 3: return channel = opt banmask = text[2] banmask = configureHostMask(banmask) if banmask == '': return bot.write(['MODE', channel, '-b', banmask]) @require_chanmsg @require_privilege(OP, 'You are not a channel operator.') @commands('quiet') def quiet(bot, trigger): """ This gives admins the ability to quiet a user. The bot must be a Channel Operator for this command to work. """ if bot.channels[trigger.sender].privileges[bot.nick] < OP: return bot.reply("I'm not a channel operator!") text = trigger.group().split() argc = len(text) if argc < 2: return opt = Identifier(text[1]) quietmask = opt channel = trigger.sender if not opt.is_nick(): if argc < 3: return quietmask = text[2] channel = opt quietmask = configureHostMask(quietmask) if quietmask == '': return bot.write(['MODE', channel, '+q', quietmask]) @require_chanmsg @require_privilege(OP, 'You are not a channel operator.') @commands('unquiet') def unquiet(bot, trigger): """ This gives admins the ability to unquiet a user. The bot must be a Channel Operator for this command to work. """ if bot.channels[trigger.sender].privileges[bot.nick] < OP: return bot.reply("I'm not a channel operator!") text = trigger.group().split() argc = len(text) if argc < 2: return opt = Identifier(text[1]) quietmask = opt channel = trigger.sender if not opt.is_nick(): if argc < 3: return quietmask = text[2] channel = opt quietmask = configureHostMask(quietmask) if quietmask == '': return bot.write(['MODE', channel, '-q', quietmask]) @require_chanmsg @require_privilege(OP, 'You are not a channel operator.') @commands('kickban', 'kb') @priority('high') def kickban(bot, trigger): """ This gives admins the ability to kickban a user. The bot must be a Channel Operator for this command to work. .kickban [#chan] user1 user!*@* get out of here """ if bot.channels[trigger.sender].privileges[bot.nick] < HALFOP: return bot.reply("I'm not a channel operator!") text = trigger.group().split() argc = len(text) if argc < 4: return opt = Identifier(text[1]) nick = opt mask = text[2] channel = trigger.sender reasonidx = 3 if not opt.is_nick(): if argc < 5: return channel = opt nick = text[2] mask = text[3] reasonidx = 4 reason = ' '.join(text[reasonidx:]) mask = configureHostMask(mask) if mask == '': return bot.write(['MODE', channel, '+b', mask]) bot.write(['KICK', channel, nick], reason) @require_chanmsg @require_privilege(OP, 'You are not a channel operator.') @commands('topic') def topic(bot, trigger): """ This gives ops the ability to change the topic. The bot must be a Channel Operator for this command to work. """ if bot.channels[trigger.sender].privileges[bot.nick] < HALFOP: return bot.reply("I'm not a channel operator!") if not trigger.group(2): return channel = trigger.sender.lower() narg = 1 mask = None mask = bot.db.get_channel_value(channel, 'topic_mask') mask = mask or default_mask(trigger) mask = mask.replace('%s', '{}') narg = len(re.findall('{}', mask)) top = trigger.group(2) args = [] if top: args = top.split('~', narg) if len(args) != narg: message = "Not enough arguments. You gave {}, it requires {}.".format( len(args), narg) return bot.say(message) topic = mask.format(*args) bot.write(('TOPIC', channel + ' :' + topic)) @require_chanmsg @require_privilege(OP, 'You are not a channel operator.') @commands('tmask') def set_mask(bot, trigger): """ Set the mask to use for .topic in the current channel. {} is used to allow substituting in chunks of text. """ bot.db.set_channel_value(trigger.sender, 'topic_mask', trigger.group(2)) bot.say("Gotcha, " + trigger.nick) @require_chanmsg @require_privilege(OP, 'You are not a channel operator.') @commands('showmask') def show_mask(bot, trigger): """Show the topic mask for the current channel.""" mask = bot.db.get_channel_value(trigger.sender, 'topic_mask') mask = mask or default_mask(trigger) bot.say(mask) sopel-6.6.9/sopel/modules/announce.py000066400000000000000000000012721347452002400176330ustar00rootroot00000000000000# coding=utf-8 """ announce.py - Send a message to all channels Copyright © 2013, Elad Alfassa, Licensed under the Eiffel Forum License 2. """ from __future__ import unicode_literals, absolute_import, print_function, division from sopel.module import commands, example @commands('announce') @example('.announce Some important message here') def announce(bot, trigger): """ Send an announcement to all channels the bot is in """ if not trigger.admin: bot.reply('Sorry, I can\'t let you do that') return for channel in bot.channels: bot.msg(channel, '[ANNOUNCEMENT] %s' % trigger.group(2)) bot.reply('Announce complete.') sopel-6.6.9/sopel/modules/bugzilla.py000066400000000000000000000063271347452002400176440ustar00rootroot00000000000000# coding=utf-8 """Bugzilla issue reporting module Copyright 2013-2015, Embolalia, embolalia.com Licensed under the Eiffel Forum License 2. """ from __future__ import unicode_literals, absolute_import, print_function, division import re import requests import xmltodict from sopel import tools from sopel.config.types import StaticSection, ListAttribute from sopel.logger import get_logger from sopel.module import rule regex = None LOGGER = get_logger(__name__) class BugzillaSection(StaticSection): domains = ListAttribute('domains') """The domains of the Bugzilla instances from which to get information.""" def configure(config): """ | name | example | purpose | | ---- | ------- | ------- | | domains | bugzilla.redhat.com,bugzilla.mozilla.org | A list of Bugzilla issue tracker domains | """ config.define_section('bugzilla', BugzillaSection) config.bugzilla.configure_setting( 'domains', 'Enter the domains of the Bugzillas you want extra information ' 'from (e.g. bugzilla.gnome.org)' ) def setup(bot): global regex bot.config.define_section('bugzilla', BugzillaSection) if not bot.config.bugzilla.domains: return if not bot.memory.contains('url_callbacks'): bot.memory['url_callbacks'] = tools.SopelMemory() domains = '|'.join(bot.config.bugzilla.domains) regex = re.compile((r'https?://(%s)' r'(/show_bug.cgi\?\S*?)' r'(id=\d+)') % domains) bot.memory['url_callbacks'][regex] = show_bug def shutdown(bot): try: del bot.memory['url_callbacks'][regex] except KeyError: # bot.config.bugzilla.domains was probably just empty on startup # everything's daijoubu pass @rule(r'.*https?://(\S+?)' r'(/show_bug.cgi\?\S*?)' r'(id=\d+).*') def show_bug(bot, trigger, match=None): """Show information about a Bugzilla bug.""" match = match or trigger domain = match.group(1) if domain not in bot.config.bugzilla.domains: return url = 'https://%s%sctype=xml&%s' % match.groups() data = requests.get(url).content bug = xmltodict.parse(data).get('bugzilla').get('bug') error = bug.get('@error', None) # error="NotPermitted" if error: LOGGER.warning('Bugzilla error: %s' % error) bot.say('[BUGZILLA] Unable to get infomation for ' 'linked bug (%s)' % error) return message = ('[BUGZILLA] %s | Product: %s | Component: %s | Version: %s | ' + 'Importance: %s | Status: %s | Assigned to: %s | ' + 'Reported: %s | Modified: %s') resolution = bug.get('resolution') if resolution is not None: status = bug.get('bug_status') + ' ' + resolution else: status = bug.get('bug_status') assigned_to = bug.get('assigned_to') if isinstance(assigned_to, dict): assigned_to = assigned_to.get('@name') message = message % ( bug.get('short_desc'), bug.get('product'), bug.get('component'), bug.get('version'), (bug.get('priority') + ' ' + bug.get('bug_severity')), status, assigned_to, bug.get('creation_ts'), bug.get('delta_ts')) bot.say(message) sopel-6.6.9/sopel/modules/calc.py000066400000000000000000000037711347452002400167350ustar00rootroot00000000000000# coding=utf-8 """ calc.py - Sopel Calculator Module Copyright 2008, Sean B. Palmer, inamidst.com Licensed under the Eiffel Forum License 2. https://sopel.chat """ from __future__ import unicode_literals, absolute_import, print_function, division from sopel.module import commands, example from sopel.tools.calculation import eval_equation from requests import get import sys if sys.version_info.major < 3: from urllib import quote as _quote quote = lambda s: _quote(s.encode('utf-8')).decode('utf-8') else: from urllib.parse import quote if sys.version_info.major >= 3: unichr = chr BASE_TUMBOLIA_URI = 'https://tumbolia-sopel.appspot.com/' @commands('c', 'calc') @example('.c 5 + 3', '8') @example('.c 0.9*10', '9') @example('.c 10*0.9', '9') @example('.c 2*(1+2)*3', '18') @example('.c 2**10', '1024') @example('.c 5 // 2', '2') @example('.c 5 / 2', '2.5') def c(bot, trigger): """Evaluate some calculation.""" if not trigger.group(2): return bot.reply("Nothing to calculate.") # Account for the silly non-Anglophones and their silly radix point. eqn = trigger.group(2).replace(',', '.') try: result = eval_equation(eqn) result = "{:.10g}".format(result) except ZeroDivisionError: result = "Division by zero is not supported in this universe." except Exception as e: result = "{error}: {msg}".format(error=type(e), msg=e) bot.reply(result) @commands('py') @example('.py len([1,2,3])', '3') def py(bot, trigger): """Evaluate a Python expression.""" if not trigger.group(2): return bot.say("Need an expression to evaluate") query = trigger.group(2) uri = BASE_TUMBOLIA_URI + 'py/' answer = get(uri + quote(query)).content.decode('utf-8') if answer: # bot.say can potentially lead to 3rd party commands triggering. bot.reply(answer) else: bot.reply('Sorry, no result.') if __name__ == "__main__": from sopel.test_tools import run_example_tests run_example_tests(__file__) sopel-6.6.9/sopel/modules/clock.py000066400000000000000000000223171347452002400171230ustar00rootroot00000000000000# coding=utf-8 # Copyright 2008-9, Sean B. Palmer, inamidst.com # Copyright 2012, Elsie Powell, embolalia.com # Licensed under the Eiffel Forum License 2. from __future__ import unicode_literals, absolute_import, print_function, division try: import pytz except ImportError: pytz = None from sopel.module import commands, example, OP from sopel.tools.time import ( get_timezone, format_time, validate_format, validate_timezone ) from sopel.config.types import StaticSection, ValidatedAttribute class TimeSection(StaticSection): tz = ValidatedAttribute( 'tz', parse=validate_timezone, serialize=validate_timezone, default='UTC' ) """Default time zone (see https://sopel.chat/tz)""" time_format = ValidatedAttribute( 'time_format', parse=validate_format, default='%Y-%m-%d - %T%Z' ) """Default time format (see http://strftime.net)""" def configure(config): """ | name | example | purpose | | ---- | ------- | ------- | | tz | America/Chicago | Preferred time zone (see ); defaults to UTC | | time\\_format | %Y-%m-%d - %T%Z | Preferred time format (see ) | """ config.define_section('clock', TimeSection) config.clock.configure_setting( 'tz', 'Preferred time zone (https://sopel.chat/tz)') config.clock.configure_setting( 'time_format', 'Preferred time format (http://strftime.net)') def setup(bot): bot.config.define_section('clock', TimeSection) @commands('t', 'time') @example('.t America/New_York') def f_time(bot, trigger): """Returns the current time.""" if trigger.group(2): zone = get_timezone(bot.db, bot.config, trigger.group(2).strip(), None, None) if not zone: bot.say('Could not find timezone %s.' % trigger.group(2).strip()) return else: zone = get_timezone(bot.db, bot.config, None, trigger.nick, trigger.sender) time = format_time(bot.db, bot.config, zone, trigger.nick, trigger.sender) bot.say(time) @commands('settz', 'settimezone') @example('.settz America/New_York') def update_user(bot, trigger): """ Set your preferred time zone. Most timezones will work, but it's best to use one from . """ if not pytz: bot.reply("Sorry, I don't have timezone support installed.") else: tz = trigger.group(2) if not tz: bot.reply("What timezone do you want to set? Try one from " "https://sopel.chat/tz") return if tz not in pytz.all_timezones: bot.reply("I don't know that time zone. Try one from " "https://sopel.chat/tz") return bot.db.set_nick_value(trigger.nick, 'timezone', tz) if len(tz) < 7: bot.say("Okay, {}, but you should use one from https://sopel.chat/tz " "if you use DST.".format(trigger.nick)) else: bot.reply('I now have you in the %s time zone.' % tz) @commands('gettz', 'gettimezone') @example('.gettz [nick]') def get_user_tz(bot, trigger): """ Gets a user's preferred time zone; will show yours if no user specified. """ if not pytz: bot.reply("Sorry, I don't have timezone support installed.") else: nick = trigger.group(2) if not nick: nick = trigger.nick nick = nick.strip() tz = bot.db.get_nick_value(nick, 'timezone') if tz: bot.say('%s\'s time zone is %s.' % (nick, tz)) else: bot.say('%s has not set their time zone' % nick) @commands('settimeformat', 'settf') @example('.settf %Y-%m-%dT%T%z') def update_user_format(bot, trigger): """ Sets your preferred format for time. Uses the standard strftime format. You can use or your favorite search engine to learn more. """ tformat = trigger.group(2) if not tformat: bot.reply("What format do you want me to use? Try using" " http://strftime.net to make one.") return tz = get_timezone(bot.db, bot.config, None, trigger.nick, trigger.sender) # Get old format as back-up old_format = bot.db.get_nick_value(trigger.nick, 'time_format') # Save the new format in the database so we can test it. bot.db.set_nick_value(trigger.nick, 'time_format', tformat) try: timef = format_time(db=bot.db, zone=tz, nick=trigger.nick) except Exception: # TODO: Be specific bot.reply("That format doesn't work. Try using" " http://strftime.net to make one.") # New format doesn't work. Revert save in database. bot.db.set_nick_value(trigger.nick, 'time_format', old_format) return bot.reply("Got it. Your time will now appear as %s. (If the " "timezone is wrong, you might try the settz command)" % timef) @commands('gettimeformat', 'gettf') @example('.gettf [nick]') def get_user_format(bot, trigger): """ Gets a user's preferred time format; will show yours if no user specified. """ nick = trigger.group(2) if not nick: nick = trigger.nick nick = nick.strip() # Get old format as back-up format = bot.db.get_nick_value(nick, 'time_format') if format: bot.say("%s's time format: %s." % (nick, format)) else: bot.say("%s hasn't set a custom time format" % nick) @commands('setchanneltz', 'setctz') @example('.setctz America/New_York') def update_channel(bot, trigger): """ Set the preferred timezone for the channel. """ if bot.channels[trigger.sender].privileges[trigger.nick] < OP: return elif not pytz: bot.reply("Sorry, I don't have timezone support installed.") else: tz = trigger.group(2) if not tz: bot.reply("What timezone do you want to set? Try one from " "https://sopel.chat/tz") return if tz not in pytz.all_timezones: bot.reply("I don't know that time zone. Try one from " "https://sopel.chat/tz") return bot.db.set_channel_value(trigger.sender, 'timezone', tz) if len(tz) < 7: bot.say("Okay, {}, but you should use one from https://sopel.chat/tz " "if you use DST.".format(trigger.nick)) else: bot.reply( 'I now have {} in the {} time zone.'.format(trigger.sender, tz)) @commands('getchanneltz', 'getctz') @example('.getctz [channel]') def get_channel_tz(bot, trigger): """ Gets the channel's preferred timezone; returns the current channel's if no channel name is given. """ if not pytz: bot.reply("Sorry, I don't have timezone support installed.") else: channel = trigger.group(2) if not channel: channel = trigger.sender channel = channel.strip() timezone = bot.db.get_channel_value(channel, 'timezone') if timezone: bot.say('%s\'s timezone: %s' % (channel, timezone)) else: bot.say('%s has no preferred timezone' % channel) @commands('setchanneltimeformat', 'setctf') @example('.setctf %Y-%m-%dT%T%z') def update_channel_format(bot, trigger): """ Sets your preferred format for time. Uses the standard strftime format. You can use or your favorite search engine to learn more. """ if bot.channels[trigger.sender].privileges[trigger.nick] < OP: return tformat = trigger.group(2) if not tformat: bot.reply("What format do you want me to use? Try using" " http://strftime.net to make one.") tz = get_timezone(bot.db, bot.config, None, None, trigger.sender) # Get old format as back-up old_format = bot.db.get_channel_value(trigger.sender, 'time_format') # Save the new format in the database so we can test it. bot.db.set_channel_value(trigger.sender, 'time_format', tformat) try: timef = format_time(db=bot.db, zone=tz, channel=trigger.sender) except Exception: # TODO: Be specific bot.reply("That format doesn't work. Try using" " http://strftime.net to make one.") # New format doesn't work. Revert save in database. bot.db.set_channel_value(trigger.sender, 'time_format', old_format) return bot.db.set_channel_value(trigger.sender, 'time_format', tformat) bot.reply("Got it. Times in this channel will now appear as %s " "unless a user has their own format set. (If the timezone" " is wrong, you might try the settz and channeltz " "commands)" % timef) @commands('getchanneltimeformat', 'getctf') @example('.getctf [channel]') def get_channel_format(bot, trigger): """ Gets the channel's preferred time format; will return current channel's if no channel name is given. """ channel = trigger.group(2) if not channel: channel = trigger.sender channel = channel.strip() tformat = bot.db.get_channel_value(channel, 'time_format') if tformat: bot.say('%s\'s time format: %s' % (channel, tformat)) else: bot.say('%s has no preferred time format' % channel) sopel-6.6.9/sopel/modules/countdown.py000066400000000000000000000025211347452002400200430ustar00rootroot00000000000000# coding=utf-8 """ countdown.py - Sopel Countdown Module Copyright 2011, Michael Yanovich, yanovich.net Licensed under the Eiffel Forum License 2. https://sopel.chat """ from __future__ import unicode_literals, absolute_import, print_function, division from sopel.module import commands, NOLIMIT import datetime @commands('countdown') def generic_countdown(bot, trigger): """ .countdown - displays a countdown to a given date. """ text = trigger.group(2) if not text: bot.say("Please use correct format: .countdown 2012 12 21") return NOLIMIT text = trigger.group(2).split() if text and (len(text) == 3 and text[0].isdigit() and text[1].isdigit() and text[2].isdigit()): try: diff = (datetime.datetime(int(text[0]), int(text[1]), int(text[2])) - datetime.datetime.today()) except Exception: # TODO: Be specific bot.say("Please use correct format: .countdown 2012 12 21") return NOLIMIT bot.say(str(diff.days) + " days, " + str(diff.seconds // 3600) + " hours and " + str(diff.seconds % 3600 // 60) + " minutes until " + text[0] + " " + text[1] + " " + text[2]) else: bot.say("Please use correct format: .countdown 2012 12 21") return NOLIMIT sopel-6.6.9/sopel/modules/currency.py000066400000000000000000000066341347452002400176660ustar00rootroot00000000000000# coding=utf-8 # Copyright 2013 Elsie Powell, embolalia.com # Licensed under the Eiffel Forum License 2 from __future__ import unicode_literals, absolute_import, print_function, division import re from requests import get from sopel.module import commands, example, NOLIMIT # The Canadian central bank has better exchange rate data than the Fed, the # Bank of England, or the European Central Bank. Who knew? base_url = 'https://www.bankofcanada.ca/valet/observations/FX{}CAD/json' regex = re.compile(r''' (\d+(?:\.\d+)?) # Decimal number \s*([a-zA-Z]{3}) # 3-letter currency code \s+(?:in|as|of|to)\s+ # preposition ([a-zA-Z]{3}) # 3-letter currency code ''', re.VERBOSE) def get_rate(code): code = code.upper() if code == 'CAD': return 1, 'Canadian Dollar' elif code == 'BTC': btc_rate = get('https://apiv2.bitcoinaverage.com/indices/global/ticker/BTCCAD') rates = btc_rate.json() return 1 / rates['averages']['day'], 'Bitcoin—24hr average' data = get(base_url.format(code)) name = data.json()['seriesDetail']['FX{}CAD'.format(code)]['description'] name = name.split(" to Canadian")[0] json = data.json()['observations'] for element in reversed(json): if 'v' in element['FX{}CAD'.format(code)]: return 1 / float(element['FX{}CAD'.format(code)]['v']), name @commands('cur', 'currency', 'exchange') @example('.cur 20 EUR in USD') def exchange(bot, trigger): """Show the exchange rate between two currencies""" if not trigger.group(2): return bot.reply("No search term. An example: .cur 20 EUR in USD") match = regex.match(trigger.group(2)) if not match: # It's apologetic, because it's using Canadian data. bot.reply("Sorry, I didn't understand the input.") return NOLIMIT amount, of, to = match.groups() try: amount = float(amount) except ValueError: bot.reply("Sorry, I didn't understand the input.") except OverflowError: bot.reply("Sorry, input amount was out of range.") display(bot, amount, of, to) def display(bot, amount, of, to): if not amount: bot.reply("Zero is zero, no matter what country you're in.") try: of_rate, of_name = get_rate(of) if not of_name: bot.reply("Unknown currency: %s" % of) return to_rate, to_name = get_rate(to) if not to_name: bot.reply("Unknown currency: %s" % to) return except Exception: # TODO: Be specific bot.reply("Something went wrong while I was getting the exchange rate.") return NOLIMIT result = amount / of_rate * to_rate bot.say("{:.2f} {} ({}) = {:.2f} {} ({})".format(amount, of.upper(), of_name, result, to.upper(), to_name)) @commands('btc', 'bitcoin') @example('.btc 20 EUR') def bitcoin(bot, trigger): # if 2 args, 1st is number and 2nd is currency. If 1 arg, it's either the number or the currency. to = trigger.group(4) amount = trigger.group(3) if not to: to = trigger.group(3) or 'USD' amount = 1 try: amount = float(amount) except ValueError: bot.reply("Sorry, I didn't understand the input.") return NOLIMIT except OverflowError: bot.reply("Sorry, input amount was out of range.") return NOLIMIT display(bot, amount, 'BTC', to) sopel-6.6.9/sopel/modules/dice.py000066400000000000000000000214311347452002400167300ustar00rootroot00000000000000# coding=utf-8 """ dice.py - Dice Module Copyright 2010-2013, Dimitri "Tyrope" Molenaars, TyRope.nl Copyright 2013, Ari Koivula, Licensed under the Eiffel Forum License 2. https://sopel.chat/ """ from __future__ import unicode_literals, absolute_import, print_function, division import random import re import operator import sopel.module from sopel.tools.calculation import eval_equation class DicePouch: def __init__(self, num_of_die, type_of_die, addition): """Initialize dice pouch and roll the dice. Args: num_of_die: number of dice in the pouch. type_of_die: how many faces the dice have. addition: how much is added to the result of the dice. """ self.num = num_of_die self.type = type_of_die self.addition = addition self.dice = {} self.dropped = {} self.roll_dice() def roll_dice(self): """Roll all the dice in the pouch.""" self.dice = {} self.dropped = {} for __ in range(self.num): number = random.randint(1, self.type) count = self.dice.setdefault(number, 0) self.dice[number] = count + 1 def drop_lowest(self, n): """Drop n lowest dice from the result. Args: n: the number of dice to drop. """ sorted_x = sorted(self.dice.items(), key=operator.itemgetter(0)) for i, count in sorted_x: count = self.dice[i] if n == 0: break elif n < count: self.dice[i] = count - n self.dropped[i] = n break else: self.dice[i] = 0 self.dropped[i] = count n = n - count for i, count in self.dropped.items(): if self.dice[i] == 0: del self.dice[i] def get_simple_string(self): """Return the values of the dice like (2+2+2[+1+1])+1.""" dice = self.dice.items() faces = ("+".join([str(face)] * times) for face, times in dice) dice_str = "+".join(faces) dropped_str = "" if self.dropped: dropped = self.dropped.items() dfaces = ("+".join([str(face)] * times) for face, times in dropped) dropped_str = "[+%s]" % ("+".join(dfaces),) plus_str = "" if self.addition: plus_str = "{:+d}".format(self.addition) return "(%s%s)%s" % (dice_str, dropped_str, plus_str) def get_compressed_string(self): """Return the values of the dice like (3x2[+2x1])+1.""" dice = self.dice.items() faces = ("%dx%d" % (times, face) for face, times in dice) dice_str = "+".join(faces) dropped_str = "" if self.dropped: dropped = self.dropped.items() dfaces = ("%dx%d" % (times, face) for face, times in dropped) dropped_str = "[+%s]" % ("+".join(dfaces),) plus_str = "" if self.addition: plus_str = "{:+d}".format(self.addition) return "(%s%s)%s" % (dice_str, dropped_str, plus_str) def get_sum(self): """Get the sum of non-dropped dice and the addition.""" result = self.addition for face, times in self.dice.items(): result += face * times return result def get_number_of_faces(self): """Returns sum of different faces for dropped and not dropped dice This can be used to estimate, whether the result can be shown in compressed form in a reasonable amount of space. """ return len(self.dice) + len(self.dropped) def _roll_dice(bot, dice_expression): result = re.search( r""" (?P-?\d*) d (?P-?\d+) (v(?P-?\d+))? $""", dice_expression, re.IGNORECASE | re.VERBOSE) dice_num = int(result.group('dice_num') or 1) dice_type = int(result.group('dice_type')) # Dice can't have zero or a negative number of sides. if dice_type <= 0: bot.reply("I don't have any dice with %d sides. =(" % dice_type) return None # Signal there was a problem # Can't roll a negative number of dice. if dice_num < 0: bot.reply("I'd rather not roll a negative amount of dice. =(") return None # Signal there was a problem # Upper limit for dice should be at most a million. Creating a dict with # more than a million elements already takes a noticeable amount of time # on a fast computer and ~55kB of memory. if dice_num > 1000: bot.reply('I only have 1000 dice. =(') return None # Signal there was a problem dice = DicePouch(dice_num, dice_type, 0) if result.group('drop_lowest'): drop = int(result.group('drop_lowest')) if drop >= 0: dice.drop_lowest(drop) else: bot.reply("I can't drop the lowest %d dice. =(" % drop) return dice @sopel.module.commands("roll") @sopel.module.commands("dice") @sopel.module.commands("d") @sopel.module.priority("medium") @sopel.module.example(".roll 3d1+1", 'You roll 3d1+1: (1+1+1)+1 = 4') @sopel.module.example(".roll 3d1v2+1", 'You roll 3d1v2+1: (1[+1+1])+1 = 2') @sopel.module.example(".roll 2d4", r'You roll 2d4: \(\d\+\d\) = \d', re=True) @sopel.module.example(".roll 100d1", r'[^:]*: \(100x1\) = 100', re=True) @sopel.module.example(".roll 1001d1", 'I only have 1000 dice. =(') @sopel.module.example(".roll 1d1 + 1d1", 'You roll 1d1 + 1d1: (1) + (1) = 2') @sopel.module.example(".roll 1d1+1d1", 'You roll 1d1+1d1: (1)+(1) = 2') def roll(bot, trigger): """.dice XdY[vZ][+N], rolls dice and reports the result. X is the number of dice. Y is the number of faces in the dice. Z is the number of lowest dice to be dropped from the result. N is the constant to be applied to the end result. """ # This regexp is only allowed to have one captured group, because having # more would alter the output of re.findall. dice_regexp = r"-?\d*[dD]-?\d+(?:[vV]-?\d+)?" # Get a list of all dice expressions, evaluate them and then replace the # expressions in the original string with the results. Replacing is done # using string formatting, so %-characters must be escaped. if not trigger.group(2): return bot.reply("No dice to roll.") arg_str = trigger.group(2) dice_expressions = re.findall(dice_regexp, arg_str) arg_str = arg_str.replace("%", "%%") arg_str = re.sub(dice_regexp, "%s", arg_str) f = lambda dice_expr: _roll_dice(bot, dice_expr) dice = list(map(f, dice_expressions)) if None in dice: # Stop computing roll if there was a problem rolling dice. return def _get_eval_str(dice): return "(%d)" % (dice.get_sum(),) def _get_pretty_str(dice): if dice.num <= 10: return dice.get_simple_string() elif dice.get_number_of_faces() <= 10: return dice.get_compressed_string() else: return "(...)" eval_str = arg_str % (tuple(map(_get_eval_str, dice))) pretty_str = arg_str % (tuple(map(_get_pretty_str, dice))) try: result = eval_equation(eval_str) except TypeError: bot.reply("The type of this equation is, apparently, not a string. " + "How did you do that, anyway?") except ValueError: # As it seems that ValueError is raised if the resulting equation would # be too big, give a semi-serious answer to reflect on this. bot.reply("You roll %s: %s = very big" % ( trigger.group(2), pretty_str)) return except (SyntaxError, eval_equation.Error): bot.reply("I don't know how to process that. " + "Are the dice as well as the algorithms correct?") return bot.reply("You roll %s: %s = %d" % ( trigger.group(2), pretty_str, result)) @sopel.module.commands("choice") @sopel.module.commands("ch") @sopel.module.commands("choose") @sopel.module.priority("medium") def choose(bot, trigger): """ .choice option1|option2|option3 - Makes a difficult choice easy. """ if not trigger.group(2): return bot.reply('I\'d choose an option, but you didn\'t give me any.') choices = [trigger.group(2)] for delim in '|\\/,': choices = trigger.group(2).split(delim) if len(choices) > 1: break choices = [choice.strip() for choice in choices] # Use a different delimiter in the output, to prevent ambiguity. for show_delim in ',|/\\': if show_delim not in trigger.group(2): show_delim += ' ' break pick = random.choice(choices) return bot.reply('Your options: %s. My choice: %s' % (show_delim.join(choices), pick)) if __name__ == "__main__": from sopel.test_tools import run_example_tests run_example_tests(__file__) sopel-6.6.9/sopel/modules/emoticons.py000066400000000000000000000044241347452002400200270ustar00rootroot00000000000000# coding=utf-8 """ emoticons.py - Sopel Emoticons Module Copyright 2018, brasstax Licensed under the Eiffel Forum License 2 https://sopel.chat """ from __future__ import unicode_literals, absolute_import from sopel.module import commands, example @commands('shrug') @example('.shrug', r'¯\_(ツ)_/¯') def shrug(bot, trigger): bot.say('¯\\_(ツ)_/¯') @commands('happy') @example('.happy', 'ᕕ( ᐛ )ᕗ') def happy(bot, trigger): bot.say('ᕕ( ᐛ )ᕗ') @commands('tableflip', 'tflip') @example('.tableflip', '(╯°□°)╯︵ ┻━┻') @example('.tflip', '(╯°□°)╯︵ ┻━┻') def tableflip(bot, trigger): bot.say('(╯°□°)╯︵ ┻━┻') @commands('unflip') @example('.unflip', '┬┬ ノ( ゜-゜ノ)') def unflip(bot, trigger): bot.say('┬┬ ノ( ゜-゜ノ)') @commands('lenny') @example('.lenny', '( ͡° ͜ʖ ͡°)') def lenny(bot, trigger): bot.say('( ͡° ͜ʖ ͡°)') @commands('rage', 'anger') @example('.rage', 'щ(ಠ益ಠщ)') @example('.anger', 'щ(ಠ益ಠщ)') def anger(bot, trigger): bot.say('щ(ಠ益ಠщ)') @commands('cry') @example('.cry', '( p′︵‵。)') def cry(bot, trigger): bot.say('( p′︵‵。)') @commands('love') @example('.love', '(●♡∀♡)') def love(bot, trigger): bot.say('(●♡∀♡)') @commands('success', 'winner') @example('.success', '٩( ᐛ )و') @example('.winner', '٩( ᐛ )و') def success(bot, trigger): bot.say('٩( ᐛ )و') @commands('confused', 'wat') @example('.confused', '(●__●)???') @example('.wat', '(●__●)???') def wat(bot, trigger): bot.say('(●__●)???') @commands('crazy') @example('.crazy', '⊙_ʘ') def crazy(bot, trigger): bot.say('⊙_ʘ') @commands('hungry') @example('.hungry', 'ლ(´ڡ`ლ)') def hungry(bot, trigger): bot.say('ლ(´ڡ`ლ)') @commands('surprised') @example('.surprised', '(((( ;°Д°))))') def surprised(bot, trigger): bot.say('(((( ;°Д°))))') @commands('sick') @example('.sick', '(-﹏-。)') def sick(bot, trigger): bot.say('(-﹏-。)') @commands('afraid') @example('.afraid', '( 〇□〇)') def afraid(bot, trigger): bot.say('( 〇□〇)') @commands('worried') @example('.worried', '( ゚д゚)') def worried(bot, trigger): bot.say('( ゚д゚)') sopel-6.6.9/sopel/modules/etymology.py000066400000000000000000000047631347452002400200650ustar00rootroot00000000000000# coding=utf-8 """ etymology.py - Sopel Etymology Module Copyright 2007-9, Sean B. Palmer, inamidst.com Copyright 2018-9, Sopel contributors Licensed under the Eiffel Forum License 2. https://sopel.chat """ from __future__ import unicode_literals, absolute_import, print_function, division from re import sub from requests import get from sopel import web from sopel.module import commands, example, NOLIMIT try: # Python 2.7 from HTMLParser import HTMLParser h = HTMLParser() unescape = h.unescape except ImportError: try: # Python 3.4+ from html import unescape # https://stackoverflow.com/a/2087433 except ImportError: # Python 3.3... sigh from html.parser import HTMLParser h = HTMLParser() unescape = h.unescape ETYURI = 'https://www.etymonline.com/word/%s' ETYSEARCH = 'https://www.etymonline.com/search?q=%s' def etymology(word): # @@ sbp, would it be possible to have a flag for .ety to get 2nd/etc # entries? - http://swhack.com/logs/2006-07-19#T15-05-29 if len(word) > 25: raise ValueError("Word too long: %s[…]" % word[:10]) ety = get(ETYURI % web.quote(word)) if ety.status_code != 200: return None # Let's find it start = ety.text.find("word__defination") start = ety.text.find("

", start) stop = ety.text.find("

", start) sentence = ety.text[start + 3:stop] # Clean up sentence = unescape(sentence) sentence = sub('<[^<]+?>', '', sentence) maxlength = 275 if len(sentence) > maxlength: sentence = sentence[:maxlength] words = sentence[:-5].split(' ') words.pop() sentence = ' '.join(words) + ' […]' sentence = '"' + sentence.replace('"', "'") + '"' return sentence + ' - ' + (ETYURI % web.quote(word)) @commands('ety') @example('.ety word') def f_etymology(bot, trigger): """Look up the etymology of a word""" word = trigger.group(2) try: result = etymology(word) except IOError: msg = "Can't connect to etymonline.com (%s)" % (ETYURI % web.quote(word)) bot.msg(trigger.sender, msg) return NOLIMIT except (AttributeError, TypeError): result = None except ValueError as ve: result = str(ve) if result is not None: bot.msg(trigger.sender, result) else: uri = ETYSEARCH % web.quote(word) msg = 'Can\'t find the etymology for "%s". Try %s' % (word, uri) bot.msg(trigger.sender, msg) return NOLIMIT sopel-6.6.9/sopel/modules/find.py000066400000000000000000000116521347452002400167500ustar00rootroot00000000000000# coding=utf-8 """Sopel Spelling correction module This module will fix spelling errors if someone corrects them using the sed notation (s///) commonly found in vi/vim. """ # Copyright 2011, Michael Yanovich, yanovich.net # Copyright 2013, Elsie Powell, embolalia.com # Licensed under the Eiffel Forum License 2. # Contributions from: Matt Meinwald and Morgan Goose from __future__ import unicode_literals, absolute_import, print_function, division import re from sopel.tools import Identifier, SopelMemory from sopel.module import rule, priority from sopel.formatting import bold def setup(bot): bot.memory['find_lines'] = SopelMemory() @rule('.*') @priority('low') def collectlines(bot, trigger): """Create a temporary log of what people say""" # Don't log things in PM if trigger.is_privmsg: return # Add a log for the channel and nick, if there isn't already one if trigger.sender not in bot.memory['find_lines']: bot.memory['find_lines'][trigger.sender] = SopelMemory() if Identifier(trigger.nick) not in bot.memory['find_lines'][trigger.sender]: bot.memory['find_lines'][trigger.sender][Identifier(trigger.nick)] = list() # Create a temporary list of the user's lines in a channel templist = bot.memory['find_lines'][trigger.sender][Identifier(trigger.nick)] line = trigger.group() if line.startswith("s/"): # Don't remember substitutions return elif line.startswith("\x01ACTION"): # For /me messages line = line[:-1] templist.append(line) else: templist.append(line) del templist[:-10] # Keep the log to 10 lines per person bot.memory['find_lines'][trigger.sender][Identifier(trigger.nick)] = templist # Match nick, s/find/replace/flags. Flags and nick are optional, nick can be # followed by comma or colon, anything after the first space after the third # slash is ignored, you can escape slashes with backslashes, and if you want to # search for an actual backslash followed by an actual slash, you're shit out of # luck because this is the fucking regex of death as it is. @rule(r"""(?: (\S+) # Catch a nick in group 1 [:,]\s+)? # Followed by colon/comma and whitespace, if given s/ # The literal s/ ( # Group 2 is the thing to find (?:\\/ | [^/])+ # One or more non-slashes or escaped slashes )/( # Group 3 is what to replace with (?:\\/ | [^/])* # One or more non-slashes or escaped slashes ) (?:/(\S+))? # Optional slash, followed by group 4 (flags) """) @priority('high') def findandreplace(bot, trigger): # Don't bother in PM if trigger.is_privmsg: return # Correcting other person vs self. rnick = Identifier(trigger.group(1) or trigger.nick) search_dict = bot.memory['find_lines'] # only do something if there is conversation to work with if trigger.sender not in search_dict: return if Identifier(rnick) not in search_dict[trigger.sender]: return # TODO rest[0] is find, rest[1] is replace. These should be made variables of # their own at some point. rest = [trigger.group(2), trigger.group(3)] rest[0] = rest[0].replace(r'\/', '/') rest[1] = rest[1].replace(r'\/', '/') me = False # /me command flags = (trigger.group(4) or '') # If g flag is given, replace all. Otherwise, replace once. if 'g' in flags: count = -1 else: count = 1 # repl is a lambda function which performs the substitution. i flag turns # off case sensitivity. re.U turns on unicode replacement. if 'i' in flags: regex = re.compile(re.escape(rest[0]), re.U | re.I) repl = lambda s: re.sub(regex, rest[1], s, count == 1) else: repl = lambda s: s.replace(rest[0], rest[1], count) # Look back through the user's lines in the channel until you find a line # where the replacement works new_phrase = None for line in reversed(search_dict[trigger.sender][rnick]): if line.startswith("\x01ACTION"): me = True # /me command line = line[8:] else: me = False new_phrase = repl(line) if new_phrase != line: # we are done break if not new_phrase or new_phrase == line: return # Didn't find anything # Save the new "edited" message. action = (me and '\x01ACTION ') or '' # If /me message, prepend \x01ACTION templist = search_dict[trigger.sender][rnick] templist.append(action + new_phrase) search_dict[trigger.sender][rnick] = templist bot.memory['find_lines'] = search_dict # output if not me: new_phrase = '%s to say: %s' % (bold('meant'), new_phrase) if trigger.group(1): phrase = '%s thinks %s %s' % (trigger.nick, rnick, new_phrase) else: phrase = '%s %s' % (trigger.nick, new_phrase) bot.say(phrase) sopel-6.6.9/sopel/modules/find_updates.py000066400000000000000000000034461347452002400204770ustar00rootroot00000000000000# coding=utf-8 """Update checking module for Sopel. This is separated from version.py, so that it can be easily overridden by distribution packagers, and they can check their repositories rather than the Sopel website. """ # Copyright 2014, Elsie Powell, embolalia.com # Licensed under the Eiffel Forum License 2. from __future__ import unicode_literals, absolute_import, print_function, division import sopel import sopel.module import requests import sopel.tools wait_time = 24 * 60 * 60 # check once per day startup_check_run = False version_url = 'https://sopel.chat/latest.json' message = ( 'A new Sopel version, {}, is available. I am running {}. Please update ' 'me. Full release notes at {}' ) unstable_message = ( 'A new pre-release version, {}, is available. I am running {}. Please ' 'update me. {}' ) @sopel.module.event(sopel.tools.events.RPL_LUSERCLIENT) @sopel.module.rule('.*') def startup_version_check(bot, trigger): global startup_check_run if not startup_check_run: startup_check_run = True check_version(bot) @sopel.module.interval(wait_time) def check_version(bot): version = sopel.version_info # TODO: Python3 specific. Disable urllib warning from config file. # requests.packages.urllib3.disable_warnings() info = requests.get(version_url, verify=bot.config.core.verify_ssl).json() if version.releaselevel == 'final': latest = info['version'] notes = info['release_notes'] else: latest = info['unstable'] notes = info.get('unstable_notes', '') if notes: notes = 'Full release notes at ' + notes latest_version = sopel._version_info(latest) msg = message.format(latest, sopel.__version__, notes) if version < latest_version: bot.msg(bot.config.core.owner, msg) sopel-6.6.9/sopel/modules/help.py000066400000000000000000000076301347452002400167610ustar00rootroot00000000000000# coding=utf-8 """ help.py - Sopel Help Module Copyright 2008, Sean B. Palmer, inamidst.com Copyright © 2013, Elad Alfassa, Copyright © 2018, Adam Erdman, pandorah.org Licensed under the Eiffel Forum License 2. https://sopel.chat """ from __future__ import unicode_literals, absolute_import, print_function, division import textwrap import collections import requests from sopel.logger import get_logger from sopel.module import commands, rule, example, priority logger = get_logger(__name__) def setup(bot): global help_prefix help_prefix = bot.config.core.help_prefix @rule('$nick' r'(?i)(help|doc) +([A-Za-z]+)(?:\?+)?$') @example('.help tell') @commands('help', 'commands') @priority('low') def help(bot, trigger): """Shows a command's documentation, and possibly an example.""" if trigger.group(2): name = trigger.group(2) name = name.lower() # number of lines of help to show threshold = 3 if name in bot.doc: if len(bot.doc[name][0]) + (1 if bot.doc[name][1] else 0) > threshold: if trigger.nick != trigger.sender: # don't say that if asked in private bot.reply('The documentation for this command is too long; I\'m sending it to you in a private message.') msgfun = lambda l: bot.msg(trigger.nick, l) else: msgfun = bot.reply for line in bot.doc[name][0]: msgfun(line) if bot.doc[name][1]: msgfun('e.g. ' + bot.doc[name][1]) else: # This'll probably catch most cases, without having to spend the time # actually creating the list first. Maybe worth storing the link and a # heuristic in config, too, so it persists across restarts. Would need a # command to regenerate, too... if 'command-list' in bot.memory and bot.memory['command-list'][0] == len(bot.command_groups): url = bot.memory['command-list'][1] else: bot.say("Hang on, I'm creating a list.") msgs = [] name_length = max(6, max(len(k) for k in bot.command_groups.keys())) for category, cmds in collections.OrderedDict(sorted(bot.command_groups.items())).items(): category = category.upper().ljust(name_length) cmds = set(cmds) # remove duplicates cmds = ' '.join(cmds) msg = category + ' ' + cmds indent = ' ' * (name_length + 2) # Honestly not sure why this is a list here msgs.append('\n'.join(textwrap.wrap(msg, subsequent_indent=indent))) url = create_list(bot, '\n\n'.join(msgs)) if not url: return bot.memory['command-list'] = (len(bot.command_groups), url) bot.say("I've posted a list of my commands at {0} - You can see " "more info about any of these commands by doing {1}help " " (e.g. {1}help time)".format(url, help_prefix)) def create_list(bot, msg): msg = 'Command listing for {}@{}\n\n'.format(bot.nick, bot.config.core.host) + msg try: result = requests.post('https://clbin.com/', data={'clbin': msg}) except requests.RequestException: bot.say("Sorry! Something went wrong.") logger.exception("Error posting commands") return result = result.text if "https://clbin.com/" in result: return result else: bot.say("Sorry! Something went wrong.") logger.error("Invalid result %s", result) return @rule('$nick' r'(?i)help(?:[?!]+)?$') @priority('low') def help2(bot, trigger): response = ( "Hi, I'm a bot. Say {1}commands to me in private for a list " "of my commands, or see https://sopel.chat for more " "general details. My owner is {0}." .format(bot.config.core.owner, help_prefix)) bot.reply(response) sopel-6.6.9/sopel/modules/instagram.py000066400000000000000000000055301347452002400200130ustar00rootroot00000000000000# coding=utf-8 """ instagram.py - API-key-less Instagram module for Sopel Copyright 2018, Sopel contributors Licensed under the Eiffel Forum License 2. https://sopel.chat """ from __future__ import unicode_literals, absolute_import, print_function, division import re from datetime import datetime from requests import get from sopel import module, tools try: from ujson import loads except ImportError: from json import loads instagram_regex = r'.*(https?:\/\/(?:www\.){0,1}instagram\.com\/([a-zA-Z0-9_\.]{,30}\/)?p\/[a-zA-Z0-9_-]+)\s?.*' instagram_pattern = re.compile(instagram_regex) def setup(bot): if not bot.memory.contains('url_callbacks'): bot.memory['url_callbacks'] = tools.SopelMemory() bot.memory['url_callbacks'][instagram_pattern] = instaparse def shutdown(bot): del bot.memory['url_callbacks'][instagram_pattern] # TODO: Parse Instagram profile page @module.rule(instagram_regex) def instaparse(bot, trigger): # Get the embedded JSON json = get_insta_json(trigger.group(1)) bot.say(parse_insta_json(json)) def get_insta_json(url): headers = {"Accept-Language": "en"} url = url.replace("https://", "http://") r = get(url, headers=headers) # Extract JSON from html source code json_start = r.text.find("window._sharedData") + 21 json_stops = r.text.find("", json_start) - 1 json_astxt = r.text[json_start:json_stops] return loads(json_astxt) def parse_insta_json(json): # Parse JSON content needed = json['entry_data']['PostPage'][0]['graphql']['shortcode_media'] iwidth = needed['dimensions']['width'] iheight = needed['dimensions']['height'] iuser = needed['owner']['username'] ifname = needed['owner']['full_name'] ilikes = needed['edge_media_preview_like']['count'] icomms = needed['edge_media_to_parent_comment']['count'] idate = needed['taken_at_timestamp'] pubdate = datetime.utcfromtimestamp(idate).strftime('%Y-%m-%d %H:%M:%S') ivideo = needed['is_video'] # Does the post have a caption? try: icap = needed['edge_media_to_caption']['edges'][0]['node']['text'] # Strip newlines icap = icap.replace('\n', ' ') # Truncate caption icap = (icap[:256] + '…') if len(icap) > 256 else icap except Exception: # TODO: be specific icap = False # Build bot response if ivideo is True: botmessage = "[insta] Video by " else: botmessage = "[insta] Photo by " if ifname is None: botmessage += "@%s" % iuser else: botmessage += "%s (@%s)" % (ifname, iuser) if icap is not False: botmessage += " | " + icap botmessage += " | " + str(iwidth) + "x" + str(iheight) botmessage += " | Likes: {:,} | Comments: {:,}".format(ilikes, icomms) botmessage += " | Uploaded: " + pubdate # Ta-da! return botmessage sopel-6.6.9/sopel/modules/ip.py000066400000000000000000000145621347452002400164430ustar00rootroot00000000000000# coding=utf-8 """GeoIP lookup module""" # Copyright 2011, Dimitri Molenaars, TyRope.nl, # Copyright © 2013, Elad Alfassa # Licensed under the Eiffel Forum License 2. from __future__ import unicode_literals, absolute_import, print_function, division import os import socket import tarfile import geoip2.database from sopel.config.types import FilenameAttribute, StaticSection from sopel.logger import get_logger from sopel.module import commands, example urlretrieve = None try: from urllib import urlretrieve except ImportError: try: # urlretrieve has been put under urllib.request in Python 3. # It's also deprecated so this should probably be replaced with # urllib2. from urllib.request import urlretrieve except ImportError: pass LOGGER = get_logger(__name__) class GeoipSection(StaticSection): GeoIP_db_path = FilenameAttribute('GeoIP_db_path', directory=True) """Path of the directory containing the GeoIP db files.""" def configure(config): """ | name | example | purpose | | ---- | ------- | ------- | | GeoIP\\_db\\_path | /home/sopel/GeoIP/ | Path to the GeoIP database files | """ config.define_section('ip', GeoipSection) config.ip.configure_setting('GeoIP_db_path', 'Path of the GeoIP db files') def setup(bot): bot.config.define_section('ip', GeoipSection) def _decompress(source, target, delete_after_decompression=True): """ Decompress just the database from the archive """ # https://stackoverflow.com/a/16452962 tar = tarfile.open(source) for member in tar.getmembers(): if ".mmdb" in member.name: member.name = os.path.basename(member.name) tar.extract(member, target) if delete_after_decompression: os.remove(source) def _find_geoip_db(bot): """ Find the GeoIP database """ config = bot.config if config.ip.GeoIP_db_path: cities_db = os.path.join(config.ip.GeoIP_db_path, 'GeoLite2-City.mmdb') ipasnum_db = os.path.join(config.ip.GeoIP_db_path, 'GeoLite2-ASN.mmdb') if (os.path.isfile(cities_db) and os.path.isfile(ipasnum_db)): return config.ip.GeoIP_db_path else: LOGGER.warning( 'GeoIP path configured but DB not found in configured path' ) if (os.path.isfile(os.path.join(config.core.homedir, 'GeoLite2-City.mmdb')) and os.path.isfile(os.path.join(config.core.homedir, 'GeoLite2-ASN.mmdb'))): return config.core.homedir elif (os.path.isfile(os.path.join('/usr/share/GeoIP', 'GeoLite2-City.mmdb')) and os.path.isfile(os.path.join('/usr/share/GeoIP', 'GeoLite2-ASN.mmdb'))): return '/usr/share/GeoIP' elif urlretrieve: LOGGER.warning('Downloading GeoIP database') bot.say('Downloading GeoIP database, please wait...') geolite_urls = [ 'https://geolite.maxmind.com/download/geoip/database/GeoLite2-City.tar.gz', 'https://geolite.maxmind.com/download/geoip/database/GeoLite2-ASN.tar.gz' ] for url in geolite_urls: full_path = os.path.join(config.core.homedir, url.split("/")[-1]) urlretrieve(url, full_path) _decompress(full_path, config.core.homedir) return bot.config.core.homedir else: return False @commands('iplookup', 'ip') @example('.ip 8.8.8.8', r'\[IP\/Host Lookup\] Hostname: \S*dns\S*\.google\S* \| Location: United States \| ISP: AS15169 Google LLC', re=True, ignore='Downloading GeoIP database, please wait...') def ip(bot, trigger): """IP Lookup tool""" # Check if there is input at all if not trigger.group(2): return bot.reply("No search term.") # Check whether the input is an IP or hostmask or a nickname decide = ['.', ':'] if any(x in trigger.group(2) for x in decide): # It's an IP/hostname! query = trigger.group(2).strip() else: # Need to get the host for the username username = trigger.group(2).strip() user_in_botdb = bot.users.get(username) if user_in_botdb is not None: query = user_in_botdb.host # Sanity check - sometimes user information isn't populated yet if query is None: return bot.say("I don't know that user's host.") else: return bot.say("I\'m not aware of this user.") db_path = _find_geoip_db(bot) if db_path is False: LOGGER.error('Can\'t find (or download) usable GeoIP database.') bot.say('Sorry, I don\'t have a GeoIP database to use for this lookup.') return False if ':' in query: try: socket.inet_pton(socket.AF_INET6, query) except (OSError, socket.error): # Python 2/3 compatibility return bot.say("[IP/Host Lookup] Unable to resolve IP/Hostname") elif '.' in query: try: socket.inet_pton(socket.AF_INET, query) except (socket.error, socket.herror): try: query = socket.getaddrinfo(query, None)[0][4][0] except socket.gaierror: return bot.say("[IP/Host Lookup] Unable to resolve IP/Hostname") else: return bot.say("[IP/Host Lookup] Unable to resolve IP/Hostname") city = geoip2.database.Reader(os.path.join(db_path, 'GeoLite2-City.mmdb')) asn = geoip2.database.Reader(os.path.join(db_path, 'GeoLite2-ASN.mmdb')) host = socket.getfqdn(query) try: city_response = city.city(query) asn_response = asn.asn(query) except geoip2.errors.AddressNotFoundError: return bot.say("[IP/Host Lookup] The address is not in the database.") response = "[IP/Host Lookup] Hostname: %s" % host try: response += " | Location: %s" % city_response.country.name except AttributeError: response += ' | Location: Unknown' region = city_response.subdivisions.most_specific.name response += " | Region: %s" % region if region else "" city = city_response.city.name response += " | City: %s" % city if city else "" isp = "AS" + str(asn_response.autonomous_system_number) + \ " " + asn_response.autonomous_system_organization response += " | ISP: %s" % isp if isp else "" bot.say(response) if __name__ == "__main__": from sopel.test_tools import run_example_tests run_example_tests(__file__) sopel-6.6.9/sopel/modules/ipython.py000066400000000000000000000045221347452002400175200ustar00rootroot00000000000000# coding=utf-8 """ ipython.py - sopel ipython console! Copyright © 2014, Elad Alfassa Licensed under the Eiffel Forum License 2. Sopel: https://sopel.chat/ """ from __future__ import unicode_literals, absolute_import, print_function, division import sopel import sopel.module import sys if sys.version_info.major >= 3: # Backup stderr/stdout wrappers old_stdout = sys.stdout old_stderr = sys.stderr # IPython wants actual stderr and stdout. In Python 2, it only needed that # when actually starting the console, but in Python 3 it seems to need that # on import as well sys.stdout = sys.__stdout__ sys.stderr = sys.__stderr__ try: import IPython if hasattr(IPython, 'terminal'): from IPython.terminal.embed import InteractiveShellEmbed else: from IPython.frontend.terminal.embed import InteractiveShellEmbed finally: if sys.version_info.major >= 3: # Restore stderr/stdout wrappers sys.stdout = old_stdout sys.stderr = old_stderr console = None @sopel.module.commands('console') def interactive_shell(bot, trigger): """ Starts an interactive IPython console """ global console if not trigger.admin: bot.say('Only admins can start the interactive console') return if 'iconsole_running' in bot.memory and bot.memory['iconsole_running']: bot.say('Console already running') return if not sys.__stdout__.isatty(): bot.say('A tty is required to start the console') return if bot._daemon: bot.say('Can\'t start console when running as a daemon') return # Backup stderr/stdout wrappers old_stdout = sys.stdout old_stderr = sys.stderr # IPython wants actual stderr and stdout sys.stdout = sys.__stdout__ sys.stderr = sys.__stderr__ banner1 = 'Sopel interactive shell (embedded IPython)' banner2 = '`bot` and `trigger` are available. To exit, type exit' exitmsg = 'Interactive shell closed' console = InteractiveShellEmbed(banner1=banner1, banner2=banner2, exit_msg=exitmsg) bot.memory['iconsole_running'] = True bot.say('console started') console() bot.memory['iconsole_running'] = False # Restore stderr/stdout wrappers sys.stdout = old_stdout sys.stderr = old_stderr sopel-6.6.9/sopel/modules/isup.py000066400000000000000000000023621347452002400170060ustar00rootroot00000000000000# coding=utf-8 """Simple website status check with isup.me""" # Author: Elsie Powell http://embolalia.com from __future__ import unicode_literals, absolute_import, print_function, division import requests from sopel.module import commands from requests.exceptions import SSLError @commands('isup', 'isupinsecure') def isup(bot, trigger): """isup.me website status checker""" site = trigger.group(2) secure = trigger.group(1).lower() != 'isupinsecure' if not site: return bot.reply("What site do you want to check?") if site[:7] != 'http://' and site[:8] != 'https://': if '://' in site: protocol = site.split('://')[0] + '://' return bot.reply("Try it again without the %s" % protocol) else: site = 'http://' + site if not '.' in site: site += ".com" try: response = requests.head(site, verify=secure).headers except SSLError: bot.say(site + ' looks down from here. Try using %sisupinsecure' % bot.config.core.help_prefix) return except Exception: bot.say(site + ' looks down from here.') return if response: bot.say(site + ' looks fine to me.') else: bot.say(site + ' is down from here.') sopel-6.6.9/sopel/modules/lmgtfy.py000066400000000000000000000014621347452002400173300ustar00rootroot00000000000000# coding=utf-8 """ lmgtfy.py - Sopel Let me Google that for you module Copyright 2013, Dimitri Molenaars http://tyrope.nl/ Licensed under the Eiffel Forum License 2. https://sopel.chat/ """ from __future__ import unicode_literals, absolute_import, print_function, division from sopel.module import commands, example from sopel.web import quote @commands('lmgtfy', 'lmgify', 'gify', 'gtfy') @example('.lmgtfy sopel', 'https://lmgtfy.com/?q=sopel') @example('.lmgtfy sopel bot', 'https://lmgtfy.com/?q=sopel+bot') @example('.lmgtfy', 'https://www.google.com/') def googleit(bot, trigger): """Let me just... google that for you.""" # No input if not trigger.group(2): return bot.say('https://www.google.com/') bot.say('https://lmgtfy.com/?q=' + quote(trigger.group(2).replace(' ', '+'), '+')) sopel-6.6.9/sopel/modules/meetbot.py000066400000000000000000000416671347452002400175000ustar00rootroot00000000000000# coding=utf-8 """ meetbot.py - Sopel meeting logger module Copyright © 2012, Elad Alfassa, Licensed under the Eiffel Forum License 2. This module is an attempt to implement at least some of the functionallity of Debian's meetbot """ from __future__ import unicode_literals, absolute_import, print_function, division import time import os from sopel.config.types import ( StaticSection, FilenameAttribute, ValidatedAttribute ) from sopel.formatting import bold from sopel.web import quote from sopel.modules.url import find_title from sopel.module import example, commands, rule, priority from sopel.tools import Ddict, Identifier import codecs class MeetbotSection(StaticSection): meeting_log_path = FilenameAttribute('meeting_log_path', directory=True, default='~/www/meetings') """Path to meeting logs storage directory This should be an absolute path, accessible on a webserver.""" meeting_log_baseurl = ValidatedAttribute( 'meeting_log_baseurl', default='http://localhost/~sopel/meetings' ) """Base URL for the meeting logs directory""" def configure(config): """ | name | example | purpose | | ---- | ------- | ------- | | meeting\\_log\\_path | /home/sopel/www/meetings | Path to meeting logs storage directory (should be an absolute path, accessible on a webserver) | | meeting\\_log\\_baseurl | http://example.com/~sopel/meetings | Base URL for the meeting logs directory | """ config.define_section('meetbot', MeetbotSection) config.meetbot.configure_setting( 'meeting_log_path', 'Enter the directory to store logs in.' ) config.meetbot.configure_setting( 'meeting_log_baseurl', 'Enter the base URL for the meeting logs.', ) def setup(bot): bot.config.define_section('meetbot', MeetbotSection) meetings_dict = Ddict(dict) # Saves metadata about currently running meetings """ meetings_dict is a 2D dict. Each meeting should have: channel time of start head (can stop the meeting, plus all abilities of chairs) chairs (can add infolines to the logs) title current subject comments (what people who aren't voiced want to add) Using channel as the meeting ID as there can't be more than one meeting in a channel at the same time. """ meeting_log_path = '' # To be defined on meeting start as part of sanity checks, used by logging functions so we don't have to pass them bot meeting_log_baseurl = '' # To be defined on meeting start as part of sanity checks, used by logging functions so we don't have to pass them bot meeting_actions = {} # A dict of channels to the actions that have been created in them. This way we can have .listactions spit them back out later on. # Get the logfile name for the meeting in the requested channel # Used by all logging functions def figure_logfile_name(channel): if meetings_dict[channel]['title'] is 'Untitled meeting': name = 'untitled' else: name = meetings_dict[channel]['title'] # Real simple sluggifying. This bunch of characters isn't exhaustive, but # whatever. It's close enough for most situations, I think. for c in ' ./\\:*?"<>|&*`': name = name.replace(c, '-') timestring = time.strftime('%Y-%m-%d-%H:%M', time.gmtime(meetings_dict[channel]['start'])) filename = timestring + '_' + name return filename # Start HTML log def logHTML_start(channel): logfile = codecs.open(meeting_log_path + channel + '/' + figure_logfile_name(channel) + '.html', 'a', encoding='utf-8') timestring = time.strftime('%Y-%m-%d %H:%M', time.gmtime(meetings_dict[channel]['start'])) title = '%s at %s, %s' % (meetings_dict[channel]['title'], channel, timestring) logfile.write('\n\n\n\n%TITLE%\n\n\n

%TITLE%

\n'.replace('%TITLE%', title)) logfile.write('

Meeting started by %s

    \n' % meetings_dict[channel]['head']) logfile.close() # Write a list item in the HTML log def logHTML_listitem(item, channel): logfile = codecs.open(meeting_log_path + channel + '/' + figure_logfile_name(channel) + '.html', 'a', encoding='utf-8') logfile.write('
  • ' + item + '
  • \n') logfile.close() # End the HTML log def logHTML_end(channel): logfile = codecs.open(meeting_log_path + channel + '/' + figure_logfile_name(channel) + '.html', 'a', encoding='utf-8') current_time = time.strftime('%H:%M:%S', time.gmtime()) logfile.write('
\n

Meeting ended at %s UTC

\n' % current_time) plainlog_url = meeting_log_baseurl + quote(channel + '/' + figure_logfile_name(channel) + '.log') logfile.write('Full log' % plainlog_url) logfile.write('\n\n') logfile.close() # Write a string to the plain text log def logplain(item, channel): current_time = time.strftime('%H:%M:%S', time.gmtime()) logfile = codecs.open(meeting_log_path + channel + '/' + figure_logfile_name(channel) + '.log', 'a', encoding='utf-8') logfile.write('[' + current_time + '] ' + item + '\r\n') logfile.close() # Check if a meeting is currently running def ismeetingrunning(channel): try: if meetings_dict[channel]['running']: return True else: return False except KeyError: return False # Check if nick is a chair or head of the meeting def ischair(nick, channel): try: if nick.lower() == meetings_dict[channel]['head'] or nick.lower() in meetings_dict[channel]['chairs']: return True else: return False except KeyError: return False # Start meeting (also preforms all required sanity checks) @commands('startmeeting') @example('.startmeeting title or .startmeeting') def startmeeting(bot, trigger): """ Start a meeting.\ See [meetbot module usage]({% link _usage/meetbot-module.md %}) """ if ismeetingrunning(trigger.sender): bot.say('Can\'t do that, there is already a meeting in progress here!') return if trigger.is_privmsg: bot.say('Can only start meetings in channels') return # Start the meeting meetings_dict[trigger.sender]['start'] = time.time() if not trigger.group(2): meetings_dict[trigger.sender]['title'] = 'Untitled meeting' else: meetings_dict[trigger.sender]['title'] = trigger.group(2) meetings_dict[trigger.sender]['head'] = trigger.nick.lower() meetings_dict[trigger.sender]['running'] = True meetings_dict[trigger.sender]['comments'] = [] global meeting_log_path meeting_log_path = bot.config.meetbot.meeting_log_path if not meeting_log_path.endswith('/'): meeting_log_path = meeting_log_path + '/' global meeting_log_baseurl meeting_log_baseurl = bot.config.meetbot.meeting_log_baseurl if not meeting_log_baseurl.endswith('/'): meeting_log_baseurl = meeting_log_baseurl + '/' if not os.path.isdir(meeting_log_path + trigger.sender): try: os.makedirs(meeting_log_path + trigger.sender) except Exception: # TODO: Be specific bot.say("Can't create log directory for this channel, meeting not started!") meetings_dict[trigger.sender] = Ddict(dict) raise return # Okay, meeting started! logplain('Meeting started by ' + trigger.nick.lower(), trigger.sender) logHTML_start(trigger.sender) meeting_actions[trigger.sender] = [] bot.say(bold('Meeting started!') + ' use .action, .agreed, .info, ' '.chairs, .subject and .comments to control the meeting. to end ' 'the meeting, type .endmeeting') bot.say('Users without speaking permission can use .comment ' + trigger.sender + ' followed by their comment in a PM with me to ' 'vocalize themselves.') # Change the current subject (will appear as

in the HTML log) @commands('subject') @example('.subject roll call') def meetingsubject(bot, trigger): """ Change the meeting subject.\ See [meetbot module usage]({% link _usage/meetbot-module.md %}) """ if not ismeetingrunning(trigger.sender): bot.say('Can\'t do that, start meeting first') return if not trigger.group(2): bot.say('what is the subject?') return if not ischair(trigger.nick, trigger.sender): bot.say('Only meeting head or chairs can do that') return meetings_dict[trigger.sender]['current_subject'] = trigger.group(2) logfile = codecs.open(meeting_log_path + trigger.sender + '/' + figure_logfile_name(trigger.sender) + '.html', 'a', encoding='utf-8') logfile.write('

' + trigger.group(2) + '

    ') logfile.close() logplain('Current subject: ' + trigger.group(2) + ', (set by ' + trigger.nick + ')', trigger.sender) bot.say(bold('Current subject:') + ' ' + trigger.group(2)) # End the meeting @commands('endmeeting') @example('.endmeeting') def endmeeting(bot, trigger): """ End a meeting.\ See [meetbot module usage]({% link _usage/meetbot-module.md %}) """ if not ismeetingrunning(trigger.sender): bot.say('Can\'t do that, start meeting first') return if not ischair(trigger.nick, trigger.sender): bot.say('Only meeting head or chairs can do that') return meeting_length = time.time() - meetings_dict[trigger.sender]['start'] # TODO: Humanize time output bot.say(bold("Meeting ended!") + " total meeting length %d seconds" % meeting_length) logHTML_end(trigger.sender) htmllog_url = meeting_log_baseurl + quote(trigger.sender + '/' + figure_logfile_name(trigger.sender) + '.html') logplain('Meeting ended by %s, total meeting length %d seconds' % (trigger.nick, meeting_length), trigger.sender) bot.say('Meeting minutes: ' + htmllog_url) meetings_dict[trigger.sender] = Ddict(dict) del meeting_actions[trigger.sender] # Set meeting chairs (people who can control the meeting) @commands('chairs') @example('.chairs Tyrope Jason elad') def chairs(bot, trigger): """ Set the meeting chairs.\ See [meetbot module usage]({% link _usage/meetbot-module.md %}) """ if not ismeetingrunning(trigger.sender): bot.say('Can\'t do that, start meeting first') return if not trigger.group(2): bot.say('Who are the chairs?') return if trigger.nick.lower() == meetings_dict[trigger.sender]['head']: meetings_dict[trigger.sender]['chairs'] = trigger.group(2).lower().split(' ') chairs_readable = trigger.group(2).lower().replace(' ', ', ') logplain('Meeting chairs are: ' + chairs_readable, trigger.sender) logHTML_listitem('Meeting chairs are: ' + chairs_readable, trigger.sender) bot.say(bold('Meeting chairs are:') + ' ' + chairs_readable) else: bot.say("Only meeting head can set chairs") # Log action item in the HTML log @commands('action') @example('.action elad will develop a meetbot') def meetingaction(bot, trigger): """ Log an action in the meeting log.\ See [meetbot module usage]({% link _usage/meetbot-module.md %}) """ if not ismeetingrunning(trigger.sender): bot.say('Can\'t do that, start meeting first') return if not trigger.group(2): bot.say('try .action someone will do something') return if not ischair(trigger.nick, trigger.sender): bot.say('Only meeting head or chairs can do that') return logplain('ACTION: ' + trigger.group(2), trigger.sender) logHTML_listitem('Action: ' + trigger.group(2), trigger.sender) meeting_actions[trigger.sender].append(trigger.group(2)) bot.say(bold('ACTION:') + ' ' + trigger.group(2)) @commands('listactions') @example('.listactions') def listactions(bot, trigger): if not ismeetingrunning(trigger.sender): bot.say('Can\'t do that, start meeting first') return for action in meeting_actions[trigger.sender]: bot.say(bold('ACTION:') + ' ' + action) # Log agreed item in the HTML log @commands('agreed') @example('.agreed Bowties are cool') def meetingagreed(bot, trigger): """ Log an agreement in the meeting log.\ See [meetbot module usage]({% link _usage/meetbot-module.md %}) """ if not ismeetingrunning(trigger.sender): bot.say('Can\'t do that, start meeting first') return if not trigger.group(2): bot.say('try .action someone will do something') return if not ischair(trigger.nick, trigger.sender): bot.say('Only meeting head or chairs can do that') return logplain('AGREED: ' + trigger.group(2), trigger.sender) logHTML_listitem('Agreed: ' + trigger.group(2), trigger.sender) bot.say(bold('AGREED:') + ' ' + trigger.group(2)) # Log link item in the HTML log @commands('link') @example('.link http://example.com') def meetinglink(bot, trigger): """ Log a link in the meeing log.\ See [meetbot module usage]({% link _usage/meetbot-module.md %}) """ if not ismeetingrunning(trigger.sender): bot.say('Can\'t do that, start meeting first') return if not trigger.group(2): bot.say('try .action someone will do something') return if not ischair(trigger.nick, trigger.sender): bot.say('Only meeting head or chairs can do that') return link = trigger.group(2) if not link.startswith("http"): link = "http://" + link try: title = find_title(link, verify=bot.config.core.verify_ssl) except Exception: # TODO: Be specific title = '' logplain('LINK: %s [%s]' % (link, title), trigger.sender) logHTML_listitem('%s' % (link, title), trigger.sender) bot.say(bold('LINK:') + ' ' + link) # Log informational item in the HTML log @commands('info') @example('.info all board members present') def meetinginfo(bot, trigger): """ Log an informational item in the meeting log.\ See [meetbot module usage]({% link _usage/meetbot-module.md %}) """ if not ismeetingrunning(trigger.sender): bot.say('Can\'t do that, start meeting first') return if not trigger.group(2): bot.say('try .info some informative thing') return if not ischair(trigger.nick, trigger.sender): bot.say('Only meeting head or chairs can do that') return logplain('INFO: ' + trigger.group(2), trigger.sender) logHTML_listitem(trigger.group(2), trigger.sender) bot.say(bold('INFO:') + ' ' + trigger.group(2)) # called for every single message # Will log to plain text only @rule('(.*)') @priority('low') def log_meeting(bot, trigger): if not ismeetingrunning(trigger.sender): return if trigger.startswith('.endmeeting') or trigger.startswith('.chairs') or trigger.startswith('.action') or trigger.startswith('.info') or trigger.startswith('.startmeeting') or trigger.startswith('.agreed') or trigger.startswith('.link') or trigger.startswith('.subject'): return logplain('<' + trigger.nick + '> ' + trigger, trigger.sender) @commands('comment') def take_comment(bot, trigger): """ Log a comment, to be shown with other comments when a chair uses .comments. Intended to allow commentary from those outside the primary group of people in the meeting. Used in private message only, as `.comment <#channel> ` See [meetbot module usage]({% link _usage/meetbot-module.md %}) """ if not trigger.sender.is_nick(): return if not trigger.group(4): # <2 arguements were given bot.say('Usage: .comment <#channel> ') return target, message = trigger.group(2).split(None, 1) target = Identifier(target) if not ismeetingrunning(target): bot.say("There's not currently a meeting in that channel.") else: meetings_dict[trigger.group(3)]['comments'].append((trigger.nick, message)) bot.say("Your comment has been recorded. It will be shown when the" " chairs tell me to show the comments.") bot.msg(meetings_dict[trigger.group(3)]['head'], "A new comment has been recorded.") @commands('comments') def show_comments(bot, trigger): """ Show the comments that have been logged for this meeting with .comment. See [meetbot module usage]({% link _usage/meetbot-module.md %}) """ if not ismeetingrunning(trigger.sender): return if not ischair(trigger.nick, trigger.sender): bot.say('Only meeting head or chairs can do that') return comments = meetings_dict[trigger.sender]['comments'] if comments: msg = 'The following comments were made:' bot.say(msg) logplain('<%s> %s' % (bot.nick, msg), trigger.sender) for comment in comments: msg = '<%s> %s' % comment bot.say(msg) logplain('<%s> %s' % (bot.nick, msg), trigger.sender) meetings_dict[trigger.sender]['comments'] = [] else: bot.say('No comments have been logged.') sopel-6.6.9/sopel/modules/ping.py000066400000000000000000000013751347452002400167660ustar00rootroot00000000000000# coding=utf-8 """ ping.py - Sopel Ping Module Author: Sean B. Palmer, inamidst.com About: https://sopel.chat """ from __future__ import unicode_literals, absolute_import, print_function, division import random from sopel.module import rule, priority, thread @rule(r'(?i)(hi|hello|hey),? $nickname[ \t]*$') def hello(bot, trigger): greeting = random.choice(('Hi', 'Hey', 'Hello')) punctuation = random.choice(('', '!')) bot.say(greeting + ' ' + trigger.nick + punctuation) @rule(r'(?i)(Fuck|Screw) you,? $nickname[ \t]*$') def rude(bot, trigger): bot.say('Watch your mouth, ' + trigger.nick + ', or I\'ll tell your mother!') @rule('$nickname!') @priority('high') @thread(False) def interjection(bot, trigger): bot.say(trigger.nick + '!') sopel-6.6.9/sopel/modules/pronouns.py000066400000000000000000000066541347452002400177210ustar00rootroot00000000000000# coding=utf-8 """ pronouns.py - Sopel Pronouns Module Copyright © 2016, Elsie Powell Licensed under the Eiffel Forum License 2. https://sopel.chat """ from __future__ import unicode_literals, absolute_import, print_function, division from sopel.logger import get_logger from sopel.module import commands, example logger = get_logger(__name__) # Copied from pronoun.is, leaving a *lot* out. If # https://github.com/witch-house/pronoun.is/pull/40 gets merged, using that # would be a lot easier. KNOWN_SETS = { 'ze': 'ze/hir/hir/hirs/hirself', 'ze/hir': 'ze/hir/hir/hirs/hirself', 'ze/zir': 'ze/zir/zir/zirs/zirself', 'they': 'they/them/their/theirs/themselves', 'they/.../themselves': 'they/them/their/theirs/themselves', 'they/.../themself': 'they/them/their/theirs/themself', 'she': 'she/her/her/hers/herself', 'he': 'he/him/his/his/himself', 'xey': 'xey/xem/xyr/xyrs/xemself', 'sie': 'sie/hir/hir/hirs/hirself', 'it': 'it/it/its/its/itself', 'ey': 'ey/em/eir/eirs/eirslef', } @commands('pronouns') @example('.pronouns Embolalia') def pronouns(bot, trigger): if not trigger.group(3): pronouns = bot.db.get_nick_value(trigger.nick, 'pronouns') if pronouns: say_pronouns(bot, trigger.nick, pronouns) else: bot.reply("I don't know your pronouns! You can set them with " ".setpronouns") else: pronouns = bot.db.get_nick_value(trigger.group(3), 'pronouns') if pronouns: say_pronouns(bot, trigger.group(3), pronouns) elif trigger.group(3) == bot.nick: # You can stuff an entry into the database manually for your bot's # gender, but like… it's a bot. bot.say( "I am a bot. Beep boop. My pronouns are it/it/its/its/itself. " "See https://pronoun.is/it for examples." ) else: bot.say("I don't know {}'s pronouns. They can set them with " ".setpronouns".format(trigger.group(3))) def say_pronouns(bot, nick, pronouns): for short, set_ in KNOWN_SETS.items(): if pronouns == set_: break short = pronouns bot.say("{}'s pronouns are {}. See https://pronoun.is/{} for " "examples.".format(nick, pronouns, short)) @commands('setpronouns') @example('.setpronouns they/them/their/theirs/themselves') def set_pronouns(bot, trigger): if trigger.group(2): pronouns = trigger.group(2) disambig = '' if pronouns == 'they': disambig = ' You can also use they/.../themself, if you prefer.' pronouns = KNOWN_SETS.get(pronouns) elif pronouns == 'ze': disambig = ' I have ze/hir. If you meant ze/zir, you can use that instead.' pronouns = KNOWN_SETS.get(pronouns) elif len(pronouns.split('/')) != 5: pronouns = KNOWN_SETS.get(pronouns) if not pronouns: bot.say( "I'm sorry, I don't know those pronouns. You can give me a set " "I don't know by formatting it " "subject/object/possessive-determiner/posessive-pronoun/" "reflexive, as in they/them/their/theirs/themselves" ) return bot.db.set_nick_value(trigger.nick, 'pronouns', pronouns) bot.reply("Thanks for telling me!" + disambig) else: bot.reply("What?") sopel-6.6.9/sopel/modules/rand.py000066400000000000000000000025601347452002400167520ustar00rootroot00000000000000# coding=utf-8 """ rand.py - Rand Module Copyright 2013, Ari Koivula, Licensed under the Eiffel Forum License 2. https://sopel.chat """ from __future__ import unicode_literals, absolute_import, print_function, division from sopel.module import commands, example import random import sys @commands('rand') @example('.rand 2', r'random\(0, 2\) = (0|1|2)', re=True, repeat=10) @example('.rand -1 -1', 'random(-1, -1) = -1') @example('.rand', r'random\(0, \d+\) = \d+', re=True) @example('.rand 99 10', r'random\(10, 99\) = \d\d', re=True, repeat=10) @example('.rand 10 99', r'random\(10, 99\) = \d\d', re=True, repeat=10) def rand(bot, trigger): """Replies with a random number between first and second argument.""" arg1 = trigger.group(3) arg2 = trigger.group(4) try: if arg2 is not None: low = int(arg1) high = int(arg2) elif arg1 is not None: low = 0 high = int(arg1) else: low = 0 high = sys.maxsize except (ValueError, TypeError): return bot.reply("Arguments must be of integer type") if low > high: low, high = high, low number = random.randint(low, high) bot.reply("random(%d, %d) = %d" % (low, high, number)) if __name__ == "__main__": from sopel.test_tools import run_example_tests run_example_tests(__file__) sopel-6.6.9/sopel/modules/reddit.py000066400000000000000000000156651347452002400173130ustar00rootroot00000000000000# coding=utf-8 # Author: Elsie Powell, embolalia.com from __future__ import unicode_literals, absolute_import, print_function, division from sopel.module import commands, rule, example, require_chanmsg, NOLIMIT, OP from sopel.formatting import bold, color, colors from sopel.web import USER_AGENT from sopel.tools import SopelMemory, time import datetime as dt import praw import re import sys if sys.version_info.major >= 3: unicode = str if sys.version_info.minor >= 4: from html import unescape else: from html.parser import HTMLParser unescape = HTMLParser().unescape else: from HTMLParser import HTMLParser unescape = HTMLParser().unescape domain = r'https?://(?:www\.|old\.|pay\.|ssl\.|[a-z]{2}\.)?reddit\.com' post_url = r'%s/r/(.*?)/comments/([\w-]+)' % domain user_url = r'%s/u(ser)?/([\w-]+)' % domain post_regex = re.compile(post_url) user_regex = re.compile(user_url) spoiler_subs = [ 'stevenuniverse', 'onepunchman', ] def setup(bot): if not bot.memory.contains('url_callbacks'): bot.memory['url_callbacks'] = SopelMemory() bot.memory['url_callbacks'][post_regex] = rpost_info bot.memory['url_callbacks'][user_regex] = redditor_info def shutdown(bot): del bot.memory['url_callbacks'][post_regex] del bot.memory['url_callbacks'][user_regex] @rule('.*%s.*' % post_url) def rpost_info(bot, trigger, match=None): match = match or trigger try: r = praw.Reddit( user_agent=USER_AGENT, client_id='6EiphT6SSQq7FQ', client_secret=None, ) s = r.submission(id=match.group(2)) except Exception: r = praw.Reddit(user_agent=USER_AGENT) s = r.get_submission(submission_id=match.group(2)) message = ('[REDDIT] {title} {link}{nsfw} | {points} points ({percent}) | ' '{comments} comments | Posted by {author} | ' 'Created at {created}') subreddit = s.subreddit.display_name if s.is_self: link = '(self.{})'.format(subreddit) else: link = '({}) to r/{}'.format(s.url, subreddit) if s.over_18: if subreddit.lower() in spoiler_subs: nsfw = ' ' + bold(color('[SPOILERS]', colors.RED)) else: nsfw = ' ' + bold(color('[NSFW]', colors.RED)) sfw = bot.db.get_channel_value(trigger.sender, 'sfw') if sfw: link = '(link hidden)' bot.write(['KICK', trigger.sender, trigger.nick, 'Linking to NSFW content in a SFW channel.']) else: nsfw = '' if s.author: author = s.author.name else: author = '[deleted]' tz = time.get_timezone(bot.db, bot.config, None, trigger.nick, trigger.sender) time_created = dt.datetime.utcfromtimestamp(s.created_utc) created = time.format_time(bot.db, bot.config, tz, trigger.nick, trigger.sender, time_created) if s.score > 0: point_color = colors.GREEN else: point_color = colors.RED percent = color(unicode(s.upvote_ratio * 100) + '%', point_color) title = unescape(s.title) message = message.format( title=title, link=link, nsfw=nsfw, points=s.score, percent=percent, comments=s.num_comments, author=author, created=created) bot.say(message) # If you change this, you'll have to change some other things... @commands('redditor') @example('.redditor poem_for_your_sprog') def redditor_info(bot, trigger, match=None): """Show information about the given Redditor""" commanded = re.match(bot.config.core.prefix + 'redditor', trigger) r = praw.Reddit( user_agent=USER_AGENT, client_id='6EiphT6SSQq7FQ', client_secret=None, ) match = match or trigger try: # praw <4.0 style u = r.get_redditor(match.group(2)) except AttributeError: # praw >=4.0 style u = r.redditor(match.group(2)) except Exception: # TODO: Be specific if commanded: bot.say('No such Redditor.') return NOLIMIT else: return # Fail silently if it wasn't an explicit command. message = '[REDDITOR] ' + u.name now = dt.datetime.utcnow() cakeday_start = dt.datetime.utcfromtimestamp(u.created_utc) cakeday_start = cakeday_start.replace(year=now.year) day = dt.timedelta(days=1) year_div_by_400 = now.year % 400 == 0 year_div_by_100 = now.year % 100 == 0 year_div_by_4 = now.year % 4 == 0 is_leap = year_div_by_400 or ((not year_div_by_100) and year_div_by_4) if (not is_leap) and ((cakeday_start.month, cakeday_start.day) == (2, 29)): # If cake day is 2/29 and it's not a leap year, cake day is 1/3. # Cake day begins at exact account creation time. is_cakeday = cakeday_start + day <= now <= cakeday_start + (2 * day) else: is_cakeday = cakeday_start <= now <= cakeday_start + day if is_cakeday: message = message + ' | ' + bold(color('Cake day', colors.LIGHT_PURPLE)) if commanded: message = message + ' | https://reddit.com/u/' + u.name if u.is_gold: message = message + ' | ' + bold(color('Gold', colors.YELLOW)) if u.is_mod: message = message + ' | ' + bold(color('Mod', colors.GREEN)) message = message + (' | Link: ' + str(u.link_karma) + ' | Comment: ' + str(u.comment_karma)) bot.say(message) # If you change the groups here, you'll have to change some things above. @rule('.*%s.*' % user_url) def auto_redditor_info(bot, trigger): redditor_info(bot, trigger) @require_chanmsg('.setsfw is only permitted in channels') @commands('setsafeforwork', 'setsfw') @example('.setsfw true') @example('.setsfw false') def update_channel(bot, trigger): """ Sets the Safe for Work status (true or false) for the current channel. Defaults to false. """ if bot.channels[trigger.sender].privileges[trigger.nick] < OP: return else: param = 'true' if trigger.group(2) and trigger.group(3): param = trigger.group(3).strip().lower() sfw = param == 'true' bot.db.set_channel_value(trigger.sender, 'sfw', sfw) if sfw: bot.reply('Got it. %s is now flagged as SFW.' % trigger.sender) else: bot.reply('Got it. %s is now flagged as NSFW.' % trigger.sender) @commands('getsafeforwork', 'getsfw') @example('.getsfw [channel]') def get_channel_sfw(bot, trigger): """ Gets the preferred channel's Safe for Work status, or the current channel's status if no channel given. """ channel = trigger.group(2) if not channel: channel = trigger.sender if channel.is_nick(): return bot.say('.getsfw with no channel param is only permitted in channels') channel = channel.strip() sfw = bot.db.get_channel_value(channel, 'sfw') if sfw: bot.say('%s is flagged as SFW' % channel) else: bot.say('%s is flagged as NSFW' % channel) sopel-6.6.9/sopel/modules/reload.py000066400000000000000000000132371347452002400172770ustar00rootroot00000000000000# coding=utf-8 """ reload.py - Sopel Module Reloader Module Copyright 2008, Sean B. Palmer, inamidst.com Licensed under the Eiffel Forum License 2. https://sopel.chat """ from __future__ import unicode_literals, absolute_import, print_function, division import collections import sys import time from sopel.tools import stderr, iteritems import sopel.loader import sopel.module import subprocess try: from importlib import reload except ImportError: try: from imp import reload except ImportError: pass # fallback to builtin if neither module is available @sopel.module.nickname_commands("reload") @sopel.module.priority("low") @sopel.module.thread(False) def f_reload(bot, trigger): """Reloads a module, for use by admins only.""" if not trigger.admin: return name = trigger.group(2) if not name or name == '*' or name.upper() == 'ALL THE THINGS': bot._callables = { 'high': collections.defaultdict(list), 'medium': collections.defaultdict(list), 'low': collections.defaultdict(list) } bot._command_groups = collections.defaultdict(list) for m in sopel.loader.enumerate_modules(bot.config): reload_module_tree(bot, m, silent=True) return bot.reply('done') if (name not in sys.modules and name not in sopel.loader.enumerate_modules(bot.config)): return bot.reply('"%s" not loaded, try the `load` command' % name) reload_module_tree(bot, name) def reload_module_tree(bot, name, seen=None, silent=False): from types import ModuleType old_module = sys.modules[name] if seen is None: seen = {} if name not in seen: seen[name] = [] old_callables = {} for obj_name, obj in iteritems(vars(old_module)): if callable(obj): if (getattr(obj, '__name__', None) == 'shutdown' and obj in bot.shutdown_methods): # If this is a shutdown method, call it first. try: stderr( "calling %s.%s" % ( obj.__module__, obj.__name__, ) ) obj(bot) except Exception as e: stderr( "Error calling shutdown method for module %s:%s" % ( obj.__module__, e ) ) bot.unregister(obj) elif (type(obj) is ModuleType and obj.__name__.startswith(name + '.') and obj.__name__ not in sys.builtin_module_names): # recurse into submodules, see issue 1056 if obj not in seen[name]: seen[name].append(obj) reload(obj) reload_module_tree(bot, obj.__name__, seen, silent) modules = sopel.loader.enumerate_modules(bot.config) if name not in modules: return # Only reload the top-level module, once recursion is finished # Also remove all references to sopel callables from top level of the # module, so that they will not get loaded again if reloading the # module does not override them. for obj_name in old_callables.keys(): delattr(old_module, obj_name) # Also delete the setup function # Sub-modules shouldn't have setup functions, so do after the recursion check if hasattr(old_module, "setup"): delattr(old_module, "setup") path, type_ = modules[name] load_module(bot, name, path, type_, silent) def load_module(bot, name, path, type_, silent=False): module, mtime = sopel.loader.load_module(name, path, type_) relevant_parts = sopel.loader.clean_module(module, bot.config) bot.register(*relevant_parts) # TODO sys.modules[name] = module if hasattr(module, 'setup'): module.setup(bot) modified = time.strftime('%Y-%m-%d %H:%M:%S', time.gmtime(mtime)) if not silent: bot.reply('%r (version: %s)' % (module, modified)) @sopel.module.nickname_commands('update') def f_update(bot, trigger): if not trigger.admin: return """Pulls the latest versions of all modules from Git""" proc = subprocess.Popen('/usr/bin/git pull', stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True) bot.reply(proc.communicate()[0]) f_reload(bot, trigger) @sopel.module.nickname_commands("load") @sopel.module.priority("low") @sopel.module.thread(False) def f_load(bot, trigger): """Loads a module, for use by admins only.""" if not trigger.admin: return name = trigger.group(2) path = '' if not name: return bot.reply('Load what?') if name in sys.modules: return bot.reply('Module already loaded, use reload') mods = sopel.loader.enumerate_modules(bot.config) if name not in mods: return bot.reply('Module %s not found' % name) path, type_ = mods[name] load_module(bot, name, path, type_) # Catch PM based messages @sopel.module.commands("reload") @sopel.module.priority("low") @sopel.module.thread(False) def pm_f_reload(bot, trigger): """Wrapper for allowing delivery of .reload command via PM""" if trigger.is_privmsg: f_reload(bot, trigger) @sopel.module.commands('update') def pm_f_update(bot, trigger): """Wrapper for allowing delivery of .update command via PM""" if trigger.is_privmsg: f_update(bot, trigger) @sopel.module.commands("load") @sopel.module.priority("low") @sopel.module.thread(False) def pm_f_load(bot, trigger): """Wrapper for allowing delivery of .load command via PM""" if trigger.is_privmsg: f_load(bot, trigger) sopel-6.6.9/sopel/modules/remind.py000066400000000000000000000152241347452002400173050ustar00rootroot00000000000000# coding=utf-8 """ remind.py - Sopel Reminder Module Copyright 2011, Sean B. Palmer, inamidst.com Licensed under the Eiffel Forum License 2. https://sopel.chat """ from __future__ import unicode_literals, absolute_import, print_function, division import os import re import time import threading import collections import codecs from datetime import datetime from sopel.module import commands, example, NOLIMIT import sopel.tools from sopel.tools.time import get_timezone, format_time try: import pytz except ImportError: pytz = None def filename(self): name = self.nick + '-' + self.config.core.host + '.reminders.db' return os.path.join(self.config.core.homedir, name) def load_database(name): data = {} if os.path.isfile(name): f = codecs.open(name, 'r', encoding='utf-8') for line in f: unixtime, channel, nick, message = line.split('\t') message = message.rstrip('\n') t = int(float(unixtime)) # WTFs going on here? reminder = (channel, nick, message) try: data[t].append(reminder) except KeyError: data[t] = [reminder] f.close() return data def dump_database(name, data): f = codecs.open(name, 'w', encoding='utf-8') for unixtime, reminders in sopel.tools.iteritems(data): for channel, nick, message in reminders: f.write('%s\t%s\t%s\t%s\n' % (unixtime, channel, nick, message)) f.close() def setup(bot): bot.rfn = filename(bot) bot.rdb = load_database(bot.rfn) def monitor(bot): time.sleep(5) while True: now = int(time.time()) unixtimes = [int(key) for key in bot.rdb] oldtimes = [t for t in unixtimes if t <= now] if oldtimes: for oldtime in oldtimes: for (channel, nick, message) in bot.rdb[oldtime]: if message: bot.msg(channel, nick + ': ' + message) else: bot.msg(channel, nick + '!') del bot.rdb[oldtime] dump_database(bot.rfn, bot.rdb) time.sleep(2.5) targs = (bot,) t = threading.Thread(target=monitor, args=targs) t.start() scaling = collections.OrderedDict([ ('years', 365.25 * 24 * 3600), ('year', 365.25 * 24 * 3600), ('yrs', 365.25 * 24 * 3600), ('y', 365.25 * 24 * 3600), ('months', 29.53059 * 24 * 3600), ('month', 29.53059 * 24 * 3600), ('mo', 29.53059 * 24 * 3600), ('weeks', 7 * 24 * 3600), ('week', 7 * 24 * 3600), ('wks', 7 * 24 * 3600), ('wk', 7 * 24 * 3600), ('w', 7 * 24 * 3600), ('days', 24 * 3600), ('day', 24 * 3600), ('d', 24 * 3600), ('hours', 3600), ('hour', 3600), ('hrs', 3600), ('hr', 3600), ('h', 3600), ('minutes', 60), ('minute', 60), ('mins', 60), ('min', 60), ('m', 60), ('seconds', 1), ('second', 1), ('secs', 1), ('sec', 1), ('s', 1), ]) periods = '|'.join(scaling.keys()) @commands('in') @example('.in 3h45m Go to class') def remind(bot, trigger): """Gives you a reminder in the given amount of time.""" if not trigger.group(2): bot.say("Missing arguments for reminder command.") return NOLIMIT if trigger.group(3) and not trigger.group(4): bot.say("No message given for reminder.") return NOLIMIT duration = 0 message = filter(None, re.split(r'(\d+(?:\.\d+)? ?(?:(?i)' + periods + ')) ?', trigger.group(2))[1:]) reminder = '' stop = False for piece in message: grp = re.match(r'(\d+(?:\.\d+)?) ?(.*) ?', piece) if grp and not stop: length = float(grp.group(1)) factor = scaling.get(grp.group(2).lower(), 60) duration += length * factor else: reminder = reminder + piece stop = True if duration == 0: return bot.reply("Sorry, didn't understand the input.") if duration % 1: duration = int(duration) + 1 else: duration = int(duration) timezone = get_timezone( bot.db, bot.config, None, trigger.nick, trigger.sender) create_reminder(bot, trigger, duration, reminder, timezone) @commands('at') @example('.at 13:47 Do your homework!') def at(bot, trigger): """ Gives you a reminder at the given time. Takes `hh:mm:ssTimezone message`. Timezone is any timezone Sopel takes elsewhere; the best choices are those from the tzdb; a list of valid options is available at . The seconds and timezone are optional. """ if not trigger.group(2): bot.say("No arguments given for reminder command.") return NOLIMIT if trigger.group(3) and not trigger.group(4): bot.say("No message given for reminder.") return NOLIMIT regex = re.compile(r'(\d+):(\d+)(?::(\d+))?([^\s\d]+)? (.*)') match = regex.match(trigger.group(2)) if not match: bot.reply("Sorry, but I didn't understand your input.") return NOLIMIT hour, minute, second, tz, message = match.groups() if not second: second = '0' if pytz: timezone = get_timezone(bot.db, bot.config, tz, trigger.nick, trigger.sender) if not timezone: timezone = 'UTC' now = datetime.now(pytz.timezone(timezone)) at_time = datetime(now.year, now.month, now.day, int(hour), int(minute), int(second), tzinfo=now.tzinfo) timediff = at_time - now else: if tz and tz.upper() != 'UTC': bot.reply("I don't have timezone support installed.") return NOLIMIT now = datetime.now() at_time = datetime(now.year, now.month, now.day, int(hour), int(minute), int(second)) timediff = at_time - now duration = timediff.seconds if duration < 0: duration += 86400 create_reminder(bot, trigger, duration, message, timezone) def create_reminder(bot, trigger, duration, message, tz): t = int(time.time()) + duration reminder = (trigger.sender, trigger.nick, message) try: bot.rdb[t].append(reminder) except KeyError: bot.rdb[t] = [reminder] dump_database(bot.rfn, bot.rdb) if duration >= 60: remind_at = datetime.utcfromtimestamp(t) timef = format_time(bot.db, bot.config, tz, trigger.nick, trigger.sender, remind_at) bot.reply('Okay, will remind at %s' % timef) else: bot.reply('Okay, will remind in %s secs' % duration) sopel-6.6.9/sopel/modules/safety.py000066400000000000000000000176351347452002400173320ustar00rootroot00000000000000# coding=utf-8 """ safety.py - Alerts about malicious URLs Copyright © 2014, Elad Alfassa, Licensed under the Eiffel Forum License 2. This module uses virustotal.com """ from __future__ import unicode_literals, absolute_import, print_function, division from sopel.config.types import StaticSection, ValidatedAttribute, ListAttribute from sopel.formatting import color, bold from sopel.logger import get_logger from sopel.module import OP import sopel.tools import sys import time import os.path import re import requests try: # This is done separately from the below version if/else because JSONDecodeError # didn't appear until Python 3.5, but Sopel claims support for 3.3+ from json import JSONDecodeError as InvalidJSONResponse except ImportError: InvalidJSONResponse = ValueError if sys.version_info.major > 2: unicode = str from urllib.request import urlretrieve from urllib.parse import urlparse else: from urllib import urlretrieve from urlparse import urlparse LOGGER = get_logger(__name__) vt_base_api_url = 'https://www.virustotal.com/vtapi/v2/url/' malware_domains = set() known_good = [] class SafetySection(StaticSection): enabled_by_default = ValidatedAttribute('enabled_by_default', bool, default=True) """Enable URL safety in all channels where it isn't explicitly disabled.""" known_good = ListAttribute('known_good') """List of "known good" domains to ignore.""" vt_api_key = ValidatedAttribute('vt_api_key') """Optional VirusTotal API key.""" def configure(config): """ | name | example | purpose | | ---- | ------- | ------- | | enabled\\_by\\_default | True | Enable URL safety in all channels where it isn't explicitly disabled. | | known\\_good | sopel.chat,dftba.net | List of "known good" domains to ignore. | | vt\\_api\\_key | 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef | Optional VirusTotal API key to improve malicious URL detection | """ config.define_section('safety', SafetySection) config.safety.configure_setting( 'enabled_by_default', "Enable URL safety in channels that don't specifically disable it?", ) config.safety.configure_setting( 'known_good', 'Enter any domains to whitelist', ) config.safety.configure_setting( 'vt_api_key', "Optionally, enter a VirusTotal API key to improve malicious URL " "protection.\nOtherwise, only the Malwarebytes DB will be used." ) def setup(bot): bot.config.define_section('safety', SafetySection) bot.memory['safety_cache'] = sopel.tools.SopelMemory() for item in bot.config.safety.known_good: known_good.append(re.compile(item, re.I)) loc = os.path.join(bot.config.homedir, 'malwaredomains.txt') if os.path.isfile(loc): if os.path.getmtime(loc) < time.time() - 24 * 60 * 60 * 7: # File exists but older than one week, update _download_malwaredomains_db(loc) else: _download_malwaredomains_db(loc) with open(loc, 'r') as f: for line in f: clean_line = unicode(line).strip().lower() if clean_line != '': malware_domains.add(clean_line) def _download_malwaredomains_db(path): print('Downloading malwaredomains db...') urlretrieve('https://mirror1.malwaredomains.com/files/justdomains', path) @sopel.module.rule(r'(?u).*(https?://\S+).*') @sopel.module.priority('high') def url_handler(bot, trigger): """ Check for malicious URLs """ check = True # Enable URL checking strict = False # Strict mode: kick on malicious URL positives = 0 # Number of engines saying it's malicious total = 0 # Number of total engines use_vt = True # Use VirusTotal check = bot.config.safety.enabled_by_default if check is None: # If not set, assume default check = True # DB overrides config: setting = bot.db.get_channel_value(trigger.sender, 'safety') if setting is not None: if setting == 'off': return # Not checking elif setting in ['on', 'strict', 'local', 'local strict']: check = True if setting == 'strict' or setting == 'local strict': strict = True if setting == 'local' or setting == 'local strict': use_vt = False if not check: return # Not overriden by DB, configured default off try: netloc = urlparse(trigger.group(1)).netloc except ValueError: return # Invalid IPv6 URL if any(regex.search(netloc) for regex in known_good): return # Whitelisted apikey = bot.config.safety.vt_api_key try: if apikey is not None and use_vt: payload = {'resource': unicode(trigger), 'apikey': apikey, 'scan': '1'} if trigger not in bot.memory['safety_cache']: r = requests.post(vt_base_api_url + 'report', data=payload) r.raise_for_status() result = r.json() age = time.time() data = {'positives': result['positives'], 'total': result['total'], 'age': age} bot.memory['safety_cache'][trigger] = data if len(bot.memory['safety_cache']) > 1024: _clean_cache(bot) else: print('using cache') result = bot.memory['safety_cache'][trigger] positives = result['positives'] total = result['total'] except requests.exceptions.RequestException: LOGGER.debug('[VirusTotal] Error obtaining response.', exc_info=True) pass # Ignoring exceptions with VT so MalwareDomains will always work except InvalidJSONResponse: LOGGER.debug('[VirusTotal] Malformed response (invalid JSON).', exc_info=True) pass # Ignoring exceptions with VT so MalwareDomains will always work if unicode(netloc).lower() in malware_domains: # malwaredomains is more trustworthy than some VT engines # therefor it gets a weight of 10 engines when calculating confidence positives += 10 total += 10 if positives > 1: # Possibly malicious URL detected! confidence = '{}%'.format(round((positives / total) * 100)) msg = 'link posted by %s is possibly malicious ' % bold(trigger.nick) msg += '(confidence %s - %s/%s)' % (confidence, positives, total) bot.say('[' + bold(color('WARNING', 'red')) + '] ' + msg) if strict: bot.write(['KICK', trigger.sender, trigger.nick, 'Posted a malicious link']) @sopel.module.commands('safety') def toggle_safety(bot, trigger): """ Set safety setting for channel """ if not trigger.admin and bot.channels[trigger.sender].privileges[trigger.nick] < OP: bot.reply('Only channel operators can change safety settings') return allowed_states = ['strict', 'on', 'off', 'local', 'local strict'] if not trigger.group(2) or trigger.group(2).lower() not in allowed_states: options = ' / '.join(allowed_states) bot.reply('Available options: %s' % options) return channel = trigger.sender.lower() bot.db.set_channel_value(channel, 'safety', trigger.group(2).lower()) bot.reply('Safety is now set to "%s" on this channel' % trigger.group(2)) # Clean the cache every day, also when > 1024 entries @sopel.module.interval(24 * 60 * 60) def _clean_cache(bot): """ Cleanup old entries in URL cache """ # TODO probably should be using locks here, to make sure stuff doesn't # explode oldest_key_age = 0 oldest_key = '' for key, data in sopel.tools.iteritems(bot.memory['safety_cache']): if data['age'] > oldest_key_age: oldest_key_age = data['age'] oldest_key = key if oldest_key in bot.memory['safety_cache']: del bot.memory['safety_cache'][oldest_key] sopel-6.6.9/sopel/modules/search.py000066400000000000000000000130241347452002400172700ustar00rootroot00000000000000# coding=utf-8 # Copyright 2008-9, Sean B. Palmer, inamidst.com # Copyright 2012, Elsie Powell, embolalia.com # Licensed under the Eiffel Forum License 2. from __future__ import unicode_literals, absolute_import, print_function, division import re import sys if sys.version_info.major < 3: from urllib import unquote as _unquote unquote = lambda s: _unquote(s.encode('utf-8')).decode('utf-8') else: from urllib.parse import unquote import requests import xmltodict from sopel import web from sopel.module import commands, example def formatnumber(n): """Format a number with beautiful commas.""" parts = list(str(n)) for i in range((len(parts) - 3), 0, -3): parts.insert(i, ',') return ''.join(parts) r_bing = re.compile(r'') def duck_search(query): query = query.replace('!', '') base = 'https://duckduckgo.com/html/' parameters = { 'kl': 'us-en', 'q': query, } headers = { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/55.0.2883.87 Safari/537.36' } bytes = requests.get(base, parameters, headers=headers).text if 'web-result' in bytes: # filter out the adds on top of the page bytes = bytes.split('web-result')[1] m = r_duck.search(bytes) if m: unquoted_m = unquote(m.group(1)) return web.decode(unquoted_m) # Alias google_search to duck_search google_search = duck_search def duck_api(query): if '!bang' in query.lower(): return 'https://duckduckgo.com/bang.html' base = 'https://api.duckduckgo.com/' parameters = { 'format': 'json', 'no_html': '1', 'no_redirect': '1', 'q': query, } try: results = requests.get(base, parameters).json() except ValueError: return None if results['Redirect']: return results['Redirect'] else: return None @commands('duck', 'ddg', 'g') # test for bad Unicode handling in py2 @example('.duck grandorder.wiki chulainn alter', 'https://grandorder.wiki/Cú_Chulainn_(Alter)') # the last example is what .help displays @example('.duck sopel irc bot', r'https?:\/\/sopel\.chat\/?', re=True) def duck(bot, trigger): """Queries Duck Duck Go for the specified input.""" query = trigger.group(2) if not query: return bot.reply('.ddg what?') # If the API gives us something, say it and stop result = duck_api(query) if result: bot.reply(result) return # Otherwise, look it up on the HTMl version uri = duck_search(query) if uri: bot.reply(uri) if 'last_seen_url' in bot.memory: bot.memory['last_seen_url'][trigger.sender] = uri else: msg = "No results found for '%s'." % query if query.count('site:') >= 2: # This check exists because of issue #1415. The git.io link will take the user there. # (Better a sopel.chat link, but it's not set up to do that. This is shorter anyway.) msg += " Try again with at most one 'site:' operator. See https://git.io/fpKtP for why." bot.reply(msg) @commands('bing') @example('.bing sopel irc bot') def bing(bot, trigger): """Queries Bing for the specified input.""" if not trigger.group(2): return bot.reply('.bing what?') query = trigger.group(2) result = bing_search(query) if result: bot.say(result) else: bot.reply("No results found for '%s'." % query) @commands('search') @example('.search sopel irc bot') def search(bot, trigger): """Searches Bing and Duck Duck Go.""" if not trigger.group(2): return bot.reply('.search for what?') query = trigger.group(2) bu = bing_search(query) or '-' du = duck_search(query) or '-' if bu == du: result = '%s (b, d)' % bu else: if len(bu) > 150: bu = '(extremely long link)' if len(du) > 150: du = '(extremely long link)' result = '%s (b), %s (d)' % (bu, du) bot.reply(result) @commands('suggest') @example('.suggest wikip', 'wikipedia') @example('.suggest ', 'No query term.') @example('.suggest lkashdfiauwgeaef', 'Sorry, no result.') def suggest(bot, trigger): """Suggest terms starting with given input""" if not trigger.group(2): return bot.reply("No query term.") query = trigger.group(2) # Using Google isn't necessarily ideal, but at most they'll be able to build # a composite profile of all users on a given instance, not a profile of any # single user. This can be switched out as soon as someone finds (or builds) # an alternative suggestion API. base = 'https://suggestqueries.google.com/complete/search' parameters = { 'output': 'toolbar', 'hl': 'en', 'q': query, } response = requests.get(base, parameters) answer = xmltodict.parse(response.text)['toplevel'] try: answer = answer['CompleteSuggestion'][0]['suggestion']['@data'] except TypeError: answer = None if answer: bot.say(answer) else: bot.reply('Sorry, no result.') sopel-6.6.9/sopel/modules/seen.py000066400000000000000000000042621347452002400167610ustar00rootroot00000000000000# coding=utf-8 """ seen.py - Sopel Seen Module Copyright 2008, Sean B. Palmer, inamidst.com Copyright © 2012, Elad Alfassa Licensed under the Eiffel Forum License 2. https://sopel.chat """ from __future__ import unicode_literals, absolute_import, print_function, division import time import datetime from sopel.tools import Identifier from sopel.tools.time import get_timezone, format_time from sopel.module import commands, rule, priority, thread @commands('seen') def seen(bot, trigger): """Reports when and where the user was last seen.""" if not trigger.group(2): bot.say(".seen - Reports when was last seen.") return nick = trigger.group(2).strip() if nick == bot.nick: bot.reply("I'm right here!") return timestamp = bot.db.get_nick_value(nick, 'seen_timestamp') if timestamp: channel = bot.db.get_nick_value(nick, 'seen_channel') message = bot.db.get_nick_value(nick, 'seen_message') action = bot.db.get_nick_value(nick, 'seen_action') tz = get_timezone(bot.db, bot.config, None, trigger.nick, trigger.sender) saw = datetime.datetime.utcfromtimestamp(timestamp) timestamp = format_time(bot.db, bot.config, tz, trigger.nick, trigger.sender, saw) msg = "I last saw {} at {}".format(nick, timestamp) if Identifier(channel) == trigger.sender: if action: msg = msg + " in here, doing " + nick + " " + message else: msg = msg + " in here, saying " + message else: msg += " in another channel." bot.say(str(trigger.nick) + ': ' + msg) else: bot.say("Sorry, I haven't seen {} around.".format(nick)) @thread(False) @rule('(.*)') @priority('low') def note(bot, trigger): if not trigger.is_privmsg: bot.db.set_nick_value(trigger.nick, 'seen_timestamp', time.time()) bot.db.set_nick_value(trigger.nick, 'seen_channel', trigger.sender) bot.db.set_nick_value(trigger.nick, 'seen_message', trigger) bot.db.set_nick_value(trigger.nick, 'seen_action', 'intent' in trigger.tags) sopel-6.6.9/sopel/modules/spellcheck.py000066400000000000000000000035431347452002400201450ustar00rootroot00000000000000# coding=utf-8 """ spellcheck.py - Sopel spell check Module Copyright © 2012, Elad Alfassa, Copyright © 2012, Lior Ramati Licensed under the Eiffel Forum License 2. https://sopel.chat This module relies on pyenchant, on Fedora and Red Hat based system, it can be found in the package python-enchant """ from __future__ import unicode_literals, absolute_import, print_function, division try: import enchant except ImportError: enchant = None from sopel.module import commands, example @commands('spellcheck', 'spell') @example('.spellcheck stuff') def spellcheck(bot, trigger): """ Says whether the given word is spelled correctly, and gives suggestions if it's not. """ if not enchant: bot.say("Missing pyenchant module.") if not trigger.group(2): return word = trigger.group(2).rstrip() if " " in word: bot.say("One word at a time, please") return dictionary = enchant.Dict("en_US") dictionary_uk = enchant.Dict("en_GB") # I don't want to make anyone angry, so I check both American and British English. if dictionary_uk.check(word): if dictionary.check(word): bot.say(word + " is spelled correctly") else: bot.say(word + " is spelled correctly (British)") elif dictionary.check(word): bot.say(word + " is spelled correctly (American)") else: msg = word + " is not spelled correctly. Maybe you want one of these spellings:" sugWords = [] for suggested_word in dictionary.suggest(word): sugWords.append(suggested_word) for suggested_word in dictionary_uk.suggest(word): sugWords.append(suggested_word) for suggested_word in sorted(set(sugWords)): # removes duplicates msg = msg + " '" + suggested_word + "'," bot.say(msg) sopel-6.6.9/sopel/modules/tell.py000066400000000000000000000127441347452002400167730ustar00rootroot00000000000000# coding=utf-8 """ tell.py - Sopel Tell and Ask Module Copyright 2008, Sean B. Palmer, inamidst.com Licensed under the Eiffel Forum License 2. https://sopel.chat """ from __future__ import unicode_literals, absolute_import, print_function, division import os import time import threading import sys from sopel.tools import Identifier, iterkeys from sopel.tools.time import get_timezone, format_time from sopel.module import commands, nickname_commands, rule, priority, example maximum = 4 def loadReminders(fn, lock): lock.acquire() try: result = {} f = open(fn) for line in f: line = line.strip() if sys.version_info.major < 3: line = line.decode('utf-8') if line: try: tellee, teller, verb, timenow, msg = line.split('\t', 4) except ValueError: continue # @@ hmm result.setdefault(tellee, []).append((teller, verb, timenow, msg)) f.close() finally: lock.release() return result def dumpReminders(fn, data, lock): lock.acquire() try: f = open(fn, 'w') for tellee in iterkeys(data): for remindon in data[tellee]: line = '\t'.join((tellee,) + remindon) try: to_write = line + '\n' if sys.version_info.major < 3: to_write = to_write.encode('utf-8') f.write(to_write) except IOError: break try: f.close() except IOError: pass finally: lock.release() return True def setup(self): fn = self.nick + '-' + self.config.core.host + '.tell.db' self.tell_filename = os.path.join(self.config.core.homedir, fn) if not os.path.exists(self.tell_filename): try: f = open(self.tell_filename, 'w') except (OSError, IOError): # Remove IOError when dropping py2 support pass else: f.write('') f.close() self.memory['tell_lock'] = threading.Lock() self.memory['reminders'] = loadReminders(self.tell_filename, self.memory['tell_lock']) @commands('tell', 'ask') @nickname_commands('tell', 'ask') @example('$nickname, tell Embolalia he broke something again.') def f_remind(bot, trigger): """Give someone a message the next time they're seen""" teller = trigger.nick verb = trigger.group(1) if not trigger.group(3): bot.reply("%s whom?" % verb) return tellee = trigger.group(3).rstrip('.,:;') msg = trigger.group(2).lstrip(tellee).lstrip() if not msg: bot.reply("%s %s what?" % (verb, tellee)) return tellee = Identifier(tellee) if not os.path.exists(bot.tell_filename): return if len(tellee) > 30: return bot.reply('That nickname is too long.') if tellee == bot.nick: return bot.reply("I'm here now, you can tell me whatever you want!") if not tellee in (Identifier(teller), bot.nick, 'me'): tz = get_timezone(bot.db, bot.config, None, tellee) timenow = format_time(bot.db, bot.config, tz, tellee) bot.memory['tell_lock'].acquire() try: if not tellee in bot.memory['reminders']: bot.memory['reminders'][tellee] = [(teller, verb, timenow, msg)] else: bot.memory['reminders'][tellee].append((teller, verb, timenow, msg)) finally: bot.memory['tell_lock'].release() response = "I'll pass that on when %s is around." % tellee bot.reply(response) elif Identifier(teller) == tellee: bot.say('You can %s yourself that.' % verb) else: bot.say("Hey, I'm not as stupid as Monty you know!") dumpReminders(bot.tell_filename, bot.memory['reminders'], bot.memory['tell_lock']) # @@ tell def getReminders(bot, channel, key, tellee): lines = [] template = "%s: %s <%s> %s %s %s" today = time.strftime('%d %b', time.gmtime()) bot.memory['tell_lock'].acquire() try: for (teller, verb, datetime, msg) in bot.memory['reminders'][key]: if datetime.startswith(today): datetime = datetime[len(today) + 1:] lines.append(template % (tellee, datetime, teller, verb, tellee, msg)) try: del bot.memory['reminders'][key] except KeyError: bot.msg(channel, 'Er...') finally: bot.memory['tell_lock'].release() return lines @rule('(.*)') @priority('low') def message(bot, trigger): tellee = trigger.nick channel = trigger.sender if not os.path.exists(bot.tell_filename): return reminders = [] remkeys = list(reversed(sorted(bot.memory['reminders'].keys()))) for remkey in remkeys: if not remkey.endswith('*') or remkey.endswith(':'): if tellee.lower() == remkey.lower(): reminders.extend(getReminders(bot, channel, remkey, tellee)) elif tellee.lower().startswith(remkey.lower().rstrip('*:')): reminders.extend(getReminders(bot, channel, remkey, tellee)) for line in reminders[:maximum]: bot.say(line) if reminders[maximum:]: bot.say('Further messages sent privately') for line in reminders[maximum:]: bot.msg(tellee, line) if len(bot.memory['reminders'].keys()) != remkeys: dumpReminders(bot.tell_filename, bot.memory['reminders'], bot.memory['tell_lock']) # @@ tell sopel-6.6.9/sopel/modules/tld.py000066400000000000000000000057701347452002400166170ustar00rootroot00000000000000# coding=utf-8 """ tld.py - Sopel TLD Module Copyright 2009-10, Michael Yanovich, yanovich.net Licensed under the Eiffel Forum License 2. https://sopel.chat """ from __future__ import unicode_literals, absolute_import, print_function, division from sopel.module import commands, example from sopel import web import requests import re import sys if sys.version_info.major >= 3: unicode = str uri = 'https://en.wikipedia.org/wiki/List_of_Internet_top-level_domains' r_tag = re.compile(r'<(?!!)[^>]+>') @commands('tld') @example('.tld ru') def gettld(bot, trigger): """Show information about the given Top Level Domain.""" page = requests.get(uri).text tld = trigger.group(2) if not tld: bot.reply("You must provide a top-level domain to search.") return # Stop if no tld argument is provided if tld[0] == '.': tld = tld[1:] search = r'(?i)\.{0}\n(\.{0}\n(.*)\n([A-Za-z0-9].*?)\n]*>(.*?)\n]*>(.*?)\n' search = search.format(tld) re_country = re.compile(search) matches = re_country.findall(page) if matches: matches = list(matches[0]) i = 0 while i < len(matches): matches[i] = r_tag.sub("", matches[i]) i += 1 desc = matches[2] if len(desc) > 400: desc = desc[:400] + "..." reply = "%s -- %s. IDN: %s, DNSSEC: %s" % (matches[1], desc, matches[3], matches[4]) else: search = r'.{0}\n(.*?)\n]*>(.*?)\n]*>(.*?)\n]*>(.*?)\n]*>(.*?)\n]*>(.*?)\n' search = search.format(unicode(tld)) re_country = re.compile(search) matches = re_country.findall(page) if matches: matches = matches[0] dict_val = dict() dict_val["country"], dict_val["expl"], dict_val["notes"], dict_val["idn"], dict_val["dnssec"], dict_val["sld"] = matches for key in dict_val: if dict_val[key] == " ": dict_val[key] = "N/A" dict_val[key] = r_tag.sub('', dict_val[key]) if len(dict_val["notes"]) > 400: dict_val["notes"] = dict_val["notes"][:400] + "..." reply = "%s (%s, %s). IDN: %s, DNSSEC: %s, SLD: %s" % (dict_val["country"], dict_val["expl"], dict_val["notes"], dict_val["idn"], dict_val["dnssec"], dict_val["sld"]) else: reply = "No matches found for TLD: {0}".format(unicode(tld)) # Final touches + output reply = web.decode(reply) bot.reply(reply) sopel-6.6.9/sopel/modules/translate.py000066400000000000000000000153761347452002400200340ustar00rootroot00000000000000# coding=utf-8 """ translate.py - Sopel Translation Module Copyright 2008, Sean B. Palmer, inamidst.com Copyright © 2013-2014, Elad Alfassa Licensed under the Eiffel Forum License 2. https://sopel.chat """ from __future__ import unicode_literals, absolute_import, print_function, division import json import random import sys import requests from sopel import web from sopel.module import rule, commands, priority, example mangle_lines = {} if sys.version_info.major >= 3: unicode = str def translate(text, in_lang='auto', out_lang='en', verify_ssl=True): raw = False if unicode(out_lang).endswith('-raw'): out_lang = out_lang[:-4] raw = True headers = { 'User-Agent': 'Mozilla/5.0' + '(X11; U; Linux i686)' + 'Gecko/20071127 Firefox/2.0.0.11' } query = { "client": "gtx", "sl": in_lang, "tl": out_lang, "dt": "t", "q": text, } url = "https://translate.googleapis.com/translate_a/single" result = requests.get(url, params=query, timeout=40, headers=headers, verify=verify_ssl).text if result == '[,,""]': return None, in_lang while ',,' in result: result = result.replace(',,', ',null,') result = result.replace('[,', '[null,') try: data = json.loads(result) except ValueError: return None, None if raw: return str(data), 'en-raw' try: language = data[2] # -2][0][0] except IndexError: language = '?' return ''.join(x[0] for x in data[0]), language @rule(r'$nickname[,:]\s+(?:([a-z]{2}) +)?(?:([a-z]{2}|en-raw) +)?["“](.+?)["”]\? *$') @example('$nickname: "mon chien"? or $nickname: fr "mon chien"?') @priority('low') def tr(bot, trigger): """Translates a phrase, with an optional language hint.""" in_lang, out_lang, phrase = trigger.groups() if (len(phrase) > 350) and (not trigger.admin): return bot.reply('Phrase must be under 350 characters.') if phrase.strip() == '': return bot.reply('You need to specify a string for me to translate!') in_lang = in_lang or 'auto' out_lang = out_lang or 'en' if in_lang != out_lang: msg, in_lang = translate(phrase, in_lang, out_lang, verify_ssl=bot.config.core.verify_ssl) if not in_lang: return bot.say("Translation failed, probably because of a rate-limit.") if sys.version_info.major < 3 and isinstance(msg, str): msg = msg.decode('utf-8') if msg: msg = web.decode(msg) # msg.replace(''', "'") msg = '"%s" (%s to %s, translate.google.com)' % (msg, in_lang, out_lang) else: msg = 'The %s to %s translation failed, are you sure you specified valid language abbreviations?' % (in_lang, out_lang) bot.reply(msg) else: bot.reply('Language guessing failed, so try suggesting one!') @commands('translate', 'tr') @example('.tr :en :fr my dog', '"mon chien" (en to fr, translate.google.com)') @example('.tr היי', '"Hey" (iw to en, translate.google.com)') @example('.tr mon chien', '"my dog" (fr to en, translate.google.com)') def tr2(bot, trigger): """Translates a phrase, with an optional language hint.""" command = trigger.group(2) if not command: return bot.reply('You did not give me anything to translate') def langcode(p): return p.startswith(':') and (2 < len(p) < 10) and p[1:].isalpha() args = ['auto', 'en'] for i in range(2): if ' ' not in command: break prefix, cmd = command.split(' ', 1) if langcode(prefix): args[i] = prefix[1:] command = cmd phrase = command if (len(phrase) > 350) and (not trigger.admin): return bot.reply('Phrase must be under 350 characters.') if phrase.strip() == '': return bot.reply('You need to specify a string for me to translate!') src, dest = args if src != dest: msg, src = translate(phrase, src, dest, verify_ssl=bot.config.core.verify_ssl) if not src: return bot.say("Translation failed, probably because of a rate-limit.") if sys.version_info.major < 3 and isinstance(msg, str): msg = msg.decode('utf-8') if msg: msg = web.decode(msg) # msg.replace(''', "'") msg = '"%s" (%s to %s, translate.google.com)' % (msg, src, dest) else: msg = 'The %s to %s translation failed, are you sure you specified valid language abbreviations?' % (src, dest) bot.reply(msg) else: bot.reply('Language guessing failed, so try suggesting one!') def get_random_lang(long_list, short_list): random_index = random.randint(0, len(long_list) - 1) random_lang = long_list[random_index] if random_lang not in short_list: short_list.append(random_lang) else: return get_random_lang(long_list, short_list) return short_list @commands('mangle', 'mangle2') def mangle(bot, trigger): """Repeatedly translate the input until it makes absolutely no sense.""" verify_ssl = bot.config.core.verify_ssl global mangle_lines long_lang_list = ['fr', 'de', 'es', 'it', 'no', 'he', 'la', 'ja', 'cy', 'ar', 'yi', 'zh', 'nl', 'ru', 'fi', 'hi', 'af', 'jw', 'mr', 'ceb', 'cs', 'ga', 'sv', 'eo', 'el', 'ms', 'lv'] lang_list = [] for __ in range(0, 8): lang_list = get_random_lang(long_lang_list, lang_list) random.shuffle(lang_list) if trigger.group(2) is None: try: phrase = (mangle_lines[trigger.sender.lower()], '') except KeyError: bot.reply("What do you want me to mangle?") return else: phrase = (trigger.group(2).strip(), '') if phrase[0] == '': bot.reply("What do you want me to mangle?") return for lang in lang_list: backup = phrase try: phrase = translate(phrase[0], 'en', lang, verify_ssl=verify_ssl) except Exception: # TODO: Be specific phrase = False if not phrase: phrase = backup break try: phrase = translate(phrase[0], lang, 'en', verify_ssl=verify_ssl) except Exception: # TODO: Be specific phrase = backup continue if not phrase: phrase = backup break bot.reply(phrase[0]) @rule('(.*)') @priority('low') def collect_mangle_lines(bot, trigger): global mangle_lines mangle_lines[trigger.sender.lower()] = "%s said '%s'" % (trigger.nick, (trigger.group(0).strip())) if __name__ == "__main__": from sopel.test_tools import run_example_tests run_example_tests(__file__) sopel-6.6.9/sopel/modules/unicode_info.py000066400000000000000000000043151347452002400204670ustar00rootroot00000000000000# coding=utf-8 """Codepoints Module""" # Copyright 2013, Elsie Powell, embolalia.com # Copyright 2008, Sean B. Palmer, inamidst.com # Licensed under the Eiffel Forum License 2. from __future__ import unicode_literals, absolute_import, print_function, division import sys import unicodedata from sopel import module if sys.version_info.major >= 3: # Note on unicode and str (required for py2 compatibility) # the `hex` function returns a `str`, both in py2 and py3 # however, a `str` is a unicode string in py3, but a bytestring in py2 # in order to prevent that, we encode the return from `hex` as `unicode` # and since this class does not exist anymore on py3, we create an alias # for `str` in py3 unichr = chr unicode = str def get_codepoint_name(char): """Retrieve the codepoint and name if possible from a character""" # Get the hex value for the code point, and drop the 0x from the front point = unicode(hex(ord(char)))[2:] # Make the hex 4 characters long with preceding 0s, and all upper case point = point.rjust(4, '0').upper() # get codepoint's name name = None try: name = unicodedata.name(char) except ValueError: pass return point, name @module.commands('u') @module.example('.u ‽', 'U+203D INTERROBANG (‽)') @module.example('.u 203D', 'U+203D INTERROBANG (‽)') def codepoint(bot, trigger): arg = trigger.group(2) if not arg: bot.reply('What code point do you want me to look up?') return module.NOLIMIT stripped = arg.strip() if len(stripped) > 0: arg = stripped if len(arg) > 1: if arg.startswith('U+'): arg = arg[2:] try: arg = unichr(int(arg, 16)) except (ValueError, TypeError): bot.reply("That's not a valid code point.") return module.NOLIMIT point, name = get_codepoint_name(arg) if name is None: name = '(No name found)' template = 'U+%s %s (\xe2\x97\x8c%s)' if not unicodedata.combining(arg): template = 'U+%s %s (%s)' bot.say(template % (point, name, arg)) if __name__ == "__main__": from sopel.test_tools import run_example_tests run_example_tests(__file__) sopel-6.6.9/sopel/modules/units.py000066400000000000000000000136501347452002400171720ustar00rootroot00000000000000# coding=utf-8 """ units.py - Unit conversion module for Sopel Copyright © 2013, Elad Alfassa, Copyright © 2013, Dimitri Molenaars, Licensed under the Eiffel Forum License 2. """ from __future__ import unicode_literals, absolute_import, print_function, division from sopel.module import commands, example, NOLIMIT import re find_temp = re.compile(r'(-?[0-9]*\.?[0-9]*)[ °]*(K|C|F)', re.IGNORECASE) find_length = re.compile(r'([0-9]*\.?[0-9]*)[ ]*(mile[s]?|mi|inch|in|foot|feet|ft|yard[s]?|yd|(?:milli|centi|kilo|)meter[s]?|[mkc]?m|ly|light-year[s]?|au|astronomical unit[s]?|parsec[s]?|pc)', re.IGNORECASE) find_mass = re.compile(r'([0-9]*\.?[0-9]*)[ ]*(lb|lbm|pound[s]?|ounce|oz|(?:kilo|)gram(?:me|)[s]?|[k]?g)', re.IGNORECASE) def f_to_c(temp): return (float(temp) - 32) * 5 / 9 def c_to_k(temp): return temp + 273.15 def c_to_f(temp): return (9.0 / 5.0 * temp + 32) def k_to_c(temp): return temp - 273.15 @commands('temp') @example('.temp 100F', '37.78°C = 100.00°F = 310.93K') @example('.temp 100C', '100.00°C = 212.00°F = 373.15K') @example('.temp 100K', '-173.15°C = -279.67°F = 100.00K') def temperature(bot, trigger): """ Convert temperatures """ try: source = find_temp.match(trigger.group(2)).groups() except (AttributeError, TypeError): bot.reply("That's not a valid temperature.") return NOLIMIT unit = source[1].upper() numeric = float(source[0]) celsius = 0 if unit == 'C': celsius = numeric elif unit == 'F': celsius = f_to_c(numeric) elif unit == 'K': celsius = k_to_c(numeric) kelvin = c_to_k(celsius) fahrenheit = c_to_f(celsius) if kelvin >= 0: bot.reply("{:.2f}°C = {:.2f}°F = {:.2f}K".format(celsius, fahrenheit, kelvin)) else: bot.reply("Physically impossible temperature.") @commands('length', 'distance') @example('.distance 3m', '3.00m = 9 feet, 10.11 inches') @example('.distance 3km', '3.00km = 1.86 miles') @example('.distance 3 miles', '4.83km = 3.00 miles') @example('.distance 3 inch', '7.62cm = 3.00 inches') @example('.distance 3 feet', '91.44cm = 3 feet, 0.00 inches') @example('.distance 3 yards', '2.74m = 9 feet, 0.00 inches') @example('.distance 155cm', '1.55m = 5 feet, 1.02 inches') @example('.length 3 ly', '28382191417742.40km = 17635876112814.77 miles') @example('.length 3 au', '448793612.10km = 278867421.71 miles') @example('.length 3 parsec', '92570329129020.20km = 57520535754731.61 miles') def distance(bot, trigger): """ Convert distances """ try: source = find_length.match(trigger.group(2)).groups() except (AttributeError, TypeError): bot.reply("That's not a valid length unit.") return NOLIMIT unit = source[1].lower() numeric = float(source[0]) meter = 0 if unit in ("meters", "meter", "m"): meter = numeric elif unit in ("millimeters", "millimeter", "mm"): meter = numeric / 1000 elif unit in ("kilometers", "kilometer", "km"): meter = numeric * 1000 elif unit in ("miles", "mile", "mi"): meter = numeric / 0.00062137 elif unit in ("inch", "in"): meter = numeric / 39.370 elif unit in ("centimeters", "centimeter", "cm"): meter = numeric / 100 elif unit in ("feet", "foot", "ft"): meter = numeric / 3.2808 elif unit in ("yards", "yard", "yd"): meter = numeric / (3.2808 / 3) elif unit in ("light-year", "light-years", "ly"): meter = numeric * 9460730472580800 elif unit in ("astronomical unit", "astronomical units", "au"): meter = numeric * 149597870700 elif unit in ("parsec", "parsecs", "pc"): meter = numeric * 30856776376340068 if meter >= 1000: metric_part = '{:.2f}km'.format(meter / 1000) elif meter < 0.01: metric_part = '{:.2f}mm'.format(meter * 1000) elif meter < 1: metric_part = '{:.2f}cm'.format(meter * 100) else: metric_part = '{:.2f}m'.format(meter) # Shit like this makes me hate being an American. inch = meter * 39.37 foot = int(inch) // 12 inch = inch - (foot * 12) yard = foot // 3 mile = meter * 0.000621371192 if yard > 500: stupid_part = '{:.2f} miles'.format(mile) else: parts = [] if yard >= 100: parts.append('{} yards'.format(yard)) foot -= (yard * 3) if foot == 1: parts.append('1 foot') elif foot != 0: parts.append('{:.0f} feet'.format(foot)) parts.append('{:.2f} inches'.format(inch)) stupid_part = ', '.join(parts) bot.reply('{} = {}'.format(metric_part, stupid_part)) @commands('weight', 'mass') def mass(bot, trigger): """ Convert mass """ try: source = find_mass.match(trigger.group(2)).groups() except (AttributeError, TypeError): bot.reply("That's not a valid mass unit.") return NOLIMIT unit = source[1].lower() numeric = float(source[0]) metric = 0 if unit in ("gram", "grams", "gramme", "grammes", "g"): metric = numeric elif unit in ("kilogram", "kilograms", "kilogramme", "kilogrammes", "kg"): metric = numeric * 1000 elif unit in ("lb", "lbm", "pound", "pounds"): metric = numeric * 453.59237 elif unit in ("oz", "ounce"): metric = numeric * 28.35 if metric >= 1000: metric_part = '{:.2f}kg'.format(metric / 1000) else: metric_part = '{:.2f}g'.format(metric) ounce = metric * .035274 pound = int(ounce) // 16 ounce = ounce - (pound * 16) if pound > 1: stupid_part = '{} pounds'.format(pound) if ounce > 0.01: stupid_part += ' {:.2f} ounces'.format(ounce) else: stupid_part = '{:.2f} oz'.format(ounce) bot.reply('{} = {}'.format(metric_part, stupid_part)) if __name__ == "__main__": from sopel.test_tools import run_example_tests run_example_tests(__file__) sopel-6.6.9/sopel/modules/uptime.py000066400000000000000000000014211347452002400173240ustar00rootroot00000000000000# coding=utf-8 """ uptime.py - Uptime module Copyright 2014, Fabian Neundorf Licensed under the Eiffel Forum License 2. https://sopel.chat """ from __future__ import unicode_literals, absolute_import, print_function, division from sopel.module import commands import datetime def setup(bot): if "uptime" not in bot.memory: bot.memory["uptime"] = datetime.datetime.utcnow() @commands('uptime') def uptime(bot, trigger): """.uptime - Returns the uptime of Sopel.""" delta = datetime.timedelta(seconds=round((datetime.datetime.utcnow() - bot.memory["uptime"]) .total_seconds())) bot.say("I've been sitting here for {} and I keep " "going!".format(delta)) sopel-6.6.9/sopel/modules/url.py000066400000000000000000000266421347452002400166370ustar00rootroot00000000000000# coding=utf-8 """URL title module""" # Copyright 2010-2011, Michael Yanovich, yanovich.net, Kenneth Sham # Copyright 2012-2013 Elsie Powell # Copyright 2013 Lior Ramati (firerogue517@gmail.com) # Copyright © 2014 Elad Alfassa # Licensed under the Eiffel Forum License 2. from __future__ import unicode_literals, absolute_import, print_function, division import re from sopel import web, tools, __version__ from sopel.module import commands, rule, example from sopel.config.types import ValidatedAttribute, ListAttribute, StaticSection import requests USER_AGENT = 'Sopel/{} (https://sopel.chat)'.format(__version__) default_headers = {'User-Agent': USER_AGENT} find_urls = None # These are used to clean up the title tag before actually parsing it. Not the # world's best way to do this, but it'll do for now. title_tag_data = re.compile('<(/?)title( [^>]+)?>', re.IGNORECASE) quoted_title = re.compile('[\'"][\'"]', re.IGNORECASE) # This is another regex that presumably does something important. re_dcc = re.compile(r'(?i)dcc\ssend') # This sets the maximum number of bytes that should be read in order to find # the title. We don't want it too high, or a link to a big file/stream will # just keep downloading until there's no more memory. 640k ought to be enough # for anybody. max_bytes = 655360 class UrlSection(StaticSection): # TODO some validation rules maybe? exclude = ListAttribute('exclude') exclusion_char = ValidatedAttribute('exclusion_char', default='!') shorten_url_length = ValidatedAttribute( 'shorten_url_length', int, default=0) def configure(config): """ | name | example | purpose | | ---- | ------- | ------- | | exclude | https?://git\\\\.io/.* | A list of regular expressions for URLs for which the title should not be shown. | | exclusion\\_char | ! | A character (or string) which, when immediately preceding a URL, will stop the URL's title from being shown. | | shorten\\_url\\_length | 72 | If greater than 0, the title fetcher will include a TinyURL version of links longer than this many characters. | """ config.define_section('url', UrlSection) config.url.configure_setting( 'exclude', 'Enter regular expressions for each URL you would like to exclude.' ) config.url.configure_setting( 'exclusion_char', 'Enter a character which can be prefixed to suppress URL titling' ) config.url.configure_setting( 'shorten_url_length', 'Enter how many characters a URL should be before the bot puts a' ' shorter version of the URL in the title as a TinyURL link' ' (0 to disable)' ) def setup(bot): global find_urls bot.config.define_section('url', UrlSection) if bot.config.url.exclude: regexes = [re.compile(s) for s in bot.config.url.exclude] else: regexes = [] # We're keeping these in their own list, rather than putting then in the # callbacks list because 1, it's easier to deal with modules that are still # using this list, and not the newer callbacks list and 2, having a lambda # just to pass is kinda ugly. if not bot.memory.contains('url_exclude'): bot.memory['url_exclude'] = regexes else: exclude = bot.memory['url_exclude'] if regexes: exclude.extend(regexes) bot.memory['url_exclude'] = exclude # Ensure that url_callbacks and last_seen_url are in memory if not bot.memory.contains('url_callbacks'): bot.memory['url_callbacks'] = tools.SopelMemory() if not bot.memory.contains('last_seen_url'): bot.memory['last_seen_url'] = tools.SopelMemory() def find_func(text, clean=False): def trim_url(url): # clean trailing sentence- or clause-ending punctuation while url[-1] in '.,?!\'":;': url = url[:-1] # clean unmatched parentheses/braces/brackets for (opener, closer) in [('(', ')'), ('[', ']'), ('{', '}'), ('<', '>')]: if (url[-1] == closer) and (url.count(opener) < url.count(closer)): url = url[:-1] return url re_url = r'(?u)((?<!%s)(?:http|https|ftp)(?::\/\/\S+))'\ % (bot.config.url.exclusion_char) r = re.compile(re_url, re.IGNORECASE) urls = re.findall(r, text) if clean: urls = [trim_url(url) for url in urls] return urls find_urls = find_func @commands('title') @example('.title http://google.com', '[ Google ] - google.com') def title_command(bot, trigger): """ Show the title or URL information for the given URL, or the last URL seen in this channel. """ if not trigger.group(2): if trigger.sender not in bot.memory['last_seen_url']: return matched = check_callbacks(bot, trigger, bot.memory['last_seen_url'][trigger.sender], True) if matched: return else: urls = [bot.memory['last_seen_url'][trigger.sender]] else: urls = find_urls(trigger) results = process_urls(bot, trigger, urls) for title, domain, tinyurl in results[:4]: message = '[ %s ] - %s' % (title, domain) if tinyurl: message += ' ( %s )' % tinyurl bot.reply(message) # Nice to have different failure messages for one-and-only requested URL # failed vs. one-of-many failed. if len(urls) == 1 and not results: bot.reply('Sorry, fetching that title failed. Make sure the site is working.') elif len(urls) > len(results): bot.reply('I couldn\'t get all of the titles, but I fetched what I could!') @rule(r'(?u).*(https?://\S+).*') def title_auto(bot, trigger): """ Automatically show titles for URLs. For shortened URLs/redirects, find where the URL redirects to and show the title for that (or call a function from another module to give more information). """ if re.match(bot.config.core.prefix + 'title', trigger): return # Avoid fetching known malicious links if 'safety_cache' in bot.memory and trigger in bot.memory['safety_cache']: if bot.memory['safety_cache'][trigger]['positives'] > 1: return urls = find_urls(trigger, clean=True) if len(urls) == 0: return results = process_urls(bot, trigger, urls) bot.memory['last_seen_url'][trigger.sender] = urls[-1] for title, domain, tinyurl in results[:4]: message = '[ %s ] - %s' % (title, domain) if tinyurl: message += ' ( %s )' % tinyurl # Guard against responding to other instances of this bot. if message != trigger: bot.say(message) def process_urls(bot, trigger, urls): """ For each URL in the list, ensure that it isn't handled by another module. If not, find where it redirects to, if anywhere. If that redirected URL should be handled by another module, dispatch the callback for it. Return a list of (title, hostname) tuples for each URL which is not handled by another module. """ results = [] shorten_url_length = bot.config.url.shorten_url_length for url in urls: if not url.startswith(bot.config.url.exclusion_char): # Magic stuff to account for international domain names try: url = web.iri_to_uri(url) except Exception: # TODO: Be specific pass # First, check that the URL we got doesn't match matched = check_callbacks(bot, trigger, url, False) if matched: continue # If the URL is over bot.config.url.shorten_url_length, # shorten the URL tinyurl = None if (shorten_url_length > 0) and (len(url) > shorten_url_length): # Check bot memory to see if the shortened URL is already in # memory if not bot.memory.contains('shortened_urls'): # Initialize shortened_urls as a dict if it doesn't exist. bot.memory['shortened_urls'] = tools.SopelMemory() if bot.memory['shortened_urls'].contains(url): tinyurl = bot.memory['shortened_urls'][url] else: tinyurl = get_tinyurl(url) bot.memory['shortened_urls'][url] = tinyurl # Finally, actually show the URL title = find_title(url, verify=bot.config.core.verify_ssl) if title: results.append((title, get_hostname(url), tinyurl)) return results def check_callbacks(bot, trigger, url, run=True): """ Check the given URL against the callbacks list. If it matches, and ``run`` is given as ``True``, run the callback function, otherwise pass. Returns ``True`` if the url matched anything in the callbacks list. """ # Check if it matches the exclusion list first matched = any(regex.search(url) for regex in bot.memory['url_exclude']) # Then, check if there's anything in the callback list for regex, function in tools.iteritems(bot.memory['url_callbacks']): match = regex.search(url) if match: # Always run ones from @url; they don't run on their own. if run or hasattr(function, 'url_regex'): function(bot, trigger, match) matched = True return matched def find_title(url, verify=True): """Return the title for the given URL.""" try: response = requests.get(url, stream=True, verify=verify, headers=default_headers) content = b'' for byte in response.iter_content(chunk_size=512): content += byte if b'' in content or len(content) > max_bytes: break content = content.decode('utf-8', errors='ignore') # Need to close the connection because we have not read all # the data response.close() except requests.exceptions.ConnectionError: return None # Some cleanup that I don't really grok, but was in the original, so # we'll keep it (with the compiled regexes made global) for now. content = title_tag_data.sub(r'<\1title>', content) content = quoted_title.sub('', content) start = content.rfind('') end = content.rfind('') if start == -1 or end == -1: return title = web.decode(content[start + 7:end]) title = title.strip()[:200] title = ' '.join(title.split()) # cleanly remove multiple spaces # More cryptic regex substitutions. This one looks to be myano's invention. title = re_dcc.sub('', title) return title or None def get_hostname(url): idx = 7 if url.startswith('https://'): idx = 8 elif url.startswith('ftp://'): idx = 6 hostname = url[idx:] slash = hostname.find('/') if slash != -1: hostname = hostname[:slash] return hostname def get_tinyurl(url): """ Returns a shortened tinyURL link of the URL. """ tinyurl = "https://tinyurl.com/api-create.php?url=%s" % url try: res = requests.get(tinyurl) res.raise_for_status() except requests.exceptions.RequestException: return None # Replace text output with https instead of http to make the # result an HTTPS link. return res.text.replace("http://", "https://") if __name__ == "__main__": from sopel.test_tools import run_example_tests run_example_tests(__file__) sopel-6.6.9/sopel/modules/version.py000066400000000000000000000042011347452002400175050ustar00rootroot00000000000000# coding=utf-8 """ version.py - Sopel Version Module Copyright 2009, Silas Baronda Copyright 2014, Dimitri Molenaars Licensed under the Eiffel Forum License 2. https://sopel.chat """ from __future__ import unicode_literals, absolute_import, print_function, division from datetime import datetime from sopel import __version__ as release from sopel.module import commands, intent, rate import re from os import path log_line = re.compile(r'\S+ (\S+) (.*? <.*?>) (\d+) (\S+)\tcommit[^:]*: (.+)') def git_info(): repo = path.join(path.dirname(path.dirname(path.dirname(__file__))), '.git') head = path.join(repo, 'HEAD') if path.isfile(head): with open(head) as h: head_loc = h.readline()[5:-1] # strip ref: and \n head_file = path.join(repo, head_loc) if path.isfile(head_file): with open(head_file) as h: sha = h.readline() if sha: return sha @commands('version') def version(bot, trigger): """Display the latest commit version, if Sopel is running in a git repo.""" sha = git_info() if not sha: msg = 'Sopel v. ' + release if release[-4:] == '-git': msg += ' at unknown commit.' bot.reply(msg) return bot.reply("Sopel v. {} at commit: {}".format(release, sha)) @intent('VERSION') @rate(20) def ctcp_version(bot, trigger): bot.write(('NOTICE', trigger.nick), '\x01VERSION Sopel IRC Bot version %s\x01' % release) @intent('SOURCE') @rate(20) def ctcp_source(bot, trigger): bot.write(('NOTICE', trigger.nick), '\x01SOURCE https://github.com/sopel-irc/sopel/\x01') @intent('PING') @rate(10) def ctcp_ping(bot, trigger): text = trigger.group() text = text.replace("PING ", "") text = text.replace("\x01", "") bot.write(('NOTICE', trigger.nick), '\x01PING {0}\x01'.format(text)) @intent('TIME') @rate(20) def ctcp_time(bot, trigger): dt = datetime.now() current_time = dt.strftime("%A, %d. %B %Y %I:%M%p") bot.write(('NOTICE', trigger.nick), '\x01TIME {0}\x01'.format(current_time)) sopel-6.6.9/sopel/modules/wikipedia.py000066400000000000000000000117341347452002400177770ustar00rootroot00000000000000# coding=utf-8 # Copyright 2013 Elsie Powell - embolalia.com # Licensed under the Eiffel Forum License 2. from __future__ import unicode_literals, absolute_import, print_function, division from sopel import tools from sopel.config.types import StaticSection, ValidatedAttribute from sopel.module import NOLIMIT, commands, example, rule from requests import get import re import sys if sys.version_info.major < 3: from urllib import quote as _quote from urlparse import unquote as _unquote quote = lambda s: _quote(s.encode('utf-8')).decode('utf-8') unquote = lambda s: _unquote(s.encode('utf-8')).decode('utf-8') else: from urllib.parse import quote, unquote REDIRECT = re.compile(r'^REDIRECT (.*)') class WikipediaSection(StaticSection): default_lang = ValidatedAttribute('default_lang', default='en') """The default language to find articles from.""" lang_per_channel = ValidatedAttribute('lang_per_channel') def setup(bot): bot.config.define_section('wikipedia', WikipediaSection) regex = re.compile('([a-z]+).(wikipedia.org/wiki/)([^ ]+)') if not bot.memory.contains('url_callbacks'): bot.memory['url_callbacks'] = tools.SopelMemory() bot.memory['url_callbacks'][regex] = mw_info def configure(config): """ | name | example | purpose | | ---- | ------- | ------- | | default\\_lang | en | The default language to find articles from (same as Wikipedia language subdomain) | | lang\\_per\\_channel | #YourPants:en,#TusPantalones:es | List of #channel:langcode pairs to define Wikipedia language per channel | """ config.define_section('wikipedia', WikipediaSection) config.wikipedia.configure_setting( 'default_lang', "Enter the default language to find articles from." ) def mw_search(server, query, num): """ Searches the specified MediaWiki server for the given query, and returns the specified number of results. """ search_url = ('https://%s/w/api.php?format=json&action=query' '&list=search&srlimit=%d&srprop=timestamp&srwhat=text' '&srsearch=') % (server, num) search_url += query query = get(search_url).json() if 'query' in query: query = query['query']['search'] return [r['title'] for r in query] else: return None def say_snippet(bot, trigger, server, query, show_url=True): page_name = query.replace('_', ' ') query = quote(query.replace(' ', '_')) try: snippet = mw_snippet(server, query) except KeyError: if show_url: bot.say("[WIKIPEDIA] Error fetching snippet for \"{}\".".format(page_name)) return msg = '[WIKIPEDIA] {} | "{}"'.format(page_name, snippet) msg_url = msg + ' | https://{}/wiki/{}'.format(server, query) if msg_url == trigger: # prevents triggering on another instance of Sopel return if show_url: msg = msg_url bot.say(msg) def mw_snippet(server, query): """ Retrives a snippet of the specified length from the given page on the given server. """ snippet_url = ('https://' + server + '/w/api.php?format=json' '&action=query&prop=extracts&exintro&explaintext' '&exchars=300&redirects&titles=') snippet_url += query snippet = get(snippet_url).json() snippet = snippet['query']['pages'] # For some reason, the API gives the page *number* as the key, so we just # grab the first page number in the results. snippet = snippet[list(snippet.keys())[0]] return snippet['extract'] @rule(r'.*\/([a-z]+\.wikipedia\.org)\/wiki\/((?!File\:)[^ ]+).*') def mw_info(bot, trigger, found_match=None): """ Retrives a snippet of the specified length from the given page on the given server. """ match = found_match or trigger say_snippet(bot, trigger, match.group(1), unquote(match.group(2)), show_url=False) @commands('w', 'wiki', 'wik') @example('.w San Francisco') def wikipedia(bot, trigger): lang = bot.config.wikipedia.default_lang # change lang if channel has custom language set if (trigger.sender and not trigger.sender.is_nick() and bot.config.wikipedia.lang_per_channel): customlang = re.search('(' + trigger.sender + r'):(\w+)', bot.config.wikipedia.lang_per_channel) if customlang is not None: lang = customlang.group(2) if trigger.group(2) is None: bot.reply("What do you want me to look up?") return NOLIMIT query = trigger.group(2) args = re.search(r'^-([a-z]{2,12})\s(.*)', query) if args is not None: lang = args.group(1) query = args.group(2) if not query: bot.reply('What do you want me to look up?') return NOLIMIT server = lang + '.wikipedia.org' query = mw_search(server, query, 1) if not query: bot.reply("I can't find any results for that.") return NOLIMIT else: query = query[0] say_snippet(bot, trigger, server, query) sopel-6.6.9/sopel/modules/wiktionary.py000066400000000000000000000071071347452002400202300ustar00rootroot00000000000000# coding=utf-8 """ wiktionary.py - Sopel Wiktionary Module Copyright 2009, Sean B. Palmer, inamidst.com Licensed under the Eiffel Forum License 2. https://sopel.chat """ from __future__ import unicode_literals, absolute_import, print_function, division import re import requests from sopel import web from sopel.module import commands, example uri = 'https://en.wiktionary.org/w/index.php?title=%s&printable=yes' r_sup = re.compile(r']+>.+') # Superscripts that are references only, not ordinal indicators, etc... r_tag = re.compile(r'<[^>]+>') r_ul = re.compile(r'(?ims)
      .*?
    ') def text(html): text = r_sup.sub('', html) # Remove superscripts that are references from definition text = r_tag.sub('', text).strip() text = text.replace('\n', ' ') text = text.replace('\r', '') text = text.replace('(intransitive', '(intr.') text = text.replace('(transitive', '(trans.') text = web.decode(text) return text def wikt(word): bytes = requests.get(uri % web.quote(word)).text bytes = r_ul.sub('', bytes) mode = None etymology = None definitions = {} for line in bytes.splitlines(): if 'id="Etymology"' in line: mode = 'etymology' elif 'id="Noun"' in line: mode = 'noun' elif 'id="Verb"' in line: mode = 'verb' elif 'id="Adjective"' in line: mode = 'adjective' elif 'id="Adverb"' in line: mode = 'adverb' elif 'id="Interjection"' in line: mode = 'interjection' elif 'id="Particle"' in line: mode = 'particle' elif 'id="Preposition"' in line: mode = 'preposition' elif 'id="Prefix"' in line: mode = 'prefix' elif 'id="Suffix"' in line: mode = 'suffix' # 'id="' can occur in definition lines
  • when tag is used for references; # make sure those are not excluded (see e.g., abecedarian). elif ('id="' in line) and ('
  • ' not in line): mode = None elif (mode == 'etmyology') and ('

    ' in line): etymology = text(line) elif (mode is not None) and ('

  • ' in line): definitions.setdefault(mode, []).append(text(line)) if ' 300: result = result[:295] + '[...]' bot.say(result) sopel-6.6.9/sopel/modules/xkcd.py000066400000000000000000000077171347452002400167700ustar00rootroot00000000000000# coding=utf-8 # Copyright 2010, Michael Yanovich (yanovich.net), and Morgan Goose # Copyright 2012, Lior Ramati # Copyright 2013, Elsie Powell (embolalia.com) # Licensed under the Eiffel Forum License 2. from __future__ import unicode_literals, absolute_import, print_function, division import random import re import requests from sopel.modules.search import bing_search from sopel.module import commands, url ignored_sites = [ # For google searching 'almamater.xkcd.com', 'blog.xkcd.com', 'blag.xkcd.com', 'forums.xkcd.com', 'fora.xkcd.com', 'forums3.xkcd.com', 'store.xkcd.com', 'wiki.xkcd.com', 'what-if.xkcd.com', ] sites_query = ' site:xkcd.com -site:' + ' -site:'.join(ignored_sites) def get_info(number=None, verify_ssl=True): if number: url = 'https://xkcd.com/{}/info.0.json'.format(number) else: url = 'https://xkcd.com/info.0.json' data = requests.get(url, verify=verify_ssl).json() data['url'] = 'https://xkcd.com/' + str(data['num']) return data def google(query): url = bing_search(query + sites_query) if not url: return None match = re.match(r'(?:https?://)?xkcd.com/(\d+)/?', url) if match: return match.group(1) @commands('xkcd') def xkcd(bot, trigger): """ .xkcd - Finds an xkcd comic strip. Takes one of 3 inputs: If no input is provided it will return a random comic If numeric input is provided it will return that comic, or the nth-latest comic if the number is non-positive If non-numeric input is provided it will return the first google result for those keywords on the xkcd.com site """ verify_ssl = bot.config.core.verify_ssl # get latest comic for rand function and numeric input latest = get_info(verify_ssl=verify_ssl) max_int = latest['num'] # if no input is given (pre - lior's edits code) if not trigger.group(2): # get rand comic random.seed() requested = get_info(random.randint(1, max_int + 1), verify_ssl=verify_ssl) else: query = trigger.group(2).strip() numbered = re.match(r"^(#|\+|-)?(\d+)$", query) if numbered: query = int(numbered.group(2)) if numbered.group(1) == "-": query = -query return numbered_result(bot, query, latest) else: # Non-number: google. if (query.lower() == "latest" or query.lower() == "newest"): requested = latest else: number = google(query) if not number: bot.say('Could not find any comics for that query.') return requested = get_info(number, verify_ssl=verify_ssl) say_result(bot, requested) def numbered_result(bot, query, latest, verify_ssl=True): max_int = latest['num'] if query > max_int: bot.say(("Sorry, comic #{} hasn't been posted yet. " "The last comic was #{}").format(query, max_int)) return elif query <= -max_int: bot.say(("Sorry, but there were only {} comics " "released yet so far").format(max_int)) return elif abs(query) == 0: requested = latest elif query == 404 or max_int + query == 404: bot.say("404 - Not Found") # don't error on that one return elif query > 0: requested = get_info(query, verify_ssl=verify_ssl) else: # Negative: go back that many from current requested = get_info(max_int + query, verify_ssl=verify_ssl) say_result(bot, requested) def say_result(bot, result): message = '{} | {} | Alt-text: {}'.format(result['url'], result['title'], result['alt']) bot.say(message) @url(r'xkcd.com/(\d+)') def get_url(bot, trigger, match): verify_ssl = bot.config.core.verify_ssl latest = get_info(verify_ssl=verify_ssl) numbered_result(bot, int(match.group(1)), latest) sopel-6.6.9/sopel/run_script.py000077500000000000000000000174101347452002400165510ustar00rootroot00000000000000#!/usr/bin/env python2.7 # coding=utf-8 """ Sopel - An IRC Bot Copyright 2008, Sean B. Palmer, inamidst.com Copyright © 2012-2014, Elad Alfassa Licensed under the Eiffel Forum License 2. https://sopel.chat """ from __future__ import unicode_literals, absolute_import, print_function, division import sys from sopel.tools import stderr if sys.version_info < (2, 7): stderr('Error: Requires Python 2.7 or later. Try python2.7 sopel') sys.exit(1) if sys.version_info.major == 3 and sys.version_info.minor < 3: stderr('Error: When running on Python 3, Python 3.3 is required.') sys.exit(1) import os import argparse import signal from sopel.__init__ import run, __version__ from sopel.config import Config, _create_config, ConfigurationError, _wizard import sopel.tools as tools homedir = os.path.join(os.path.expanduser('~'), '.sopel') def enumerate_configs(extension='.cfg'): configfiles = [] if os.path.isdir(homedir): sopel_dotdirfiles = os.listdir(homedir) # Preferred for item in sopel_dotdirfiles: if item.endswith(extension): configfiles.append(item) return configfiles def find_config(name, extension='.cfg'): if os.path.isfile(name): return name configs = enumerate_configs(extension) if name in configs or name + extension in configs: if name + extension in configs: name = name + extension return os.path.join(homedir, name) def main(argv=None): global homedir # Step One: Parse The Command Line try: parser = argparse.ArgumentParser(description='Sopel IRC Bot', usage='%(prog)s [options]') parser.add_argument('-c', '--config', metavar='filename', help='use a specific configuration file') parser.add_argument("-d", '--fork', action="store_true", dest="daemonize", help="Daemonize sopel") parser.add_argument("-q", '--quit', action="store_true", dest="quit", help="Gracefully quit Sopel") parser.add_argument("-k", '--kill', action="store_true", dest="kill", help="Kill Sopel") parser.add_argument("-l", '--list', action="store_true", dest="list_configs", help="List all config files found") parser.add_argument("-m", '--migrate', action="store_true", dest="migrate_configs", help="Migrate config files to the new format") parser.add_argument('--quiet', action="store_true", dest="quiet", help="Suppress all output") parser.add_argument('-w', '--configure-all', action='store_true', dest='wizard', help='Run the configuration wizard.') parser.add_argument('--configure-modules', action='store_true', dest='mod_wizard', help=( 'Run the configuration wizard, but only for the ' 'module configuration options.')) parser.add_argument('-v', '--version', action="store_true", dest="version", help="Show version number and exit") if argv: opts = parser.parse_args(argv) else: opts = parser.parse_args() # Step Two: "Do not run as root" checks. try: # Linux/Mac if os.getuid() == 0 or os.geteuid() == 0: stderr('Error: Do not run Sopel with root privileges.') sys.exit(1) except AttributeError: # Windows if os.environ.get("USERNAME") == "Administrator": stderr('Error: Do not run Sopel as Administrator.') sys.exit(1) if opts.version: py_ver = '%s.%s.%s' % (sys.version_info.major, sys.version_info.minor, sys.version_info.micro) print('Sopel %s (running on python %s)' % (__version__, py_ver)) print('https://sopel.chat/') return elif opts.wizard: _wizard('all', opts.config) return elif opts.mod_wizard: _wizard('mod', opts.config) return if opts.list_configs: configs = enumerate_configs() print('Config files in ~/.sopel:') if len(configs) is 0: print('\tNone found') else: for config in configs: print('\t%s' % config) print('-------------------------') return config_name = opts.config or 'default' configpath = find_config(config_name) if not os.path.isfile(configpath): print("Welcome to Sopel!\nI can't seem to find the configuration file, so let's generate it!\n") if not configpath.endswith('.cfg'): configpath = configpath + '.cfg' _create_config(configpath) configpath = find_config(config_name) try: config_module = Config(configpath) except ConfigurationError as e: stderr(e) sys.exit(2) if config_module.core.not_configured: stderr('Bot is not configured, can\'t start') # exit with code 2 to prevent auto restart on fail by systemd sys.exit(2) logfile = os.path.os.path.join(config_module.core.logdir, 'stdio.log') config_module._is_daemonized = opts.daemonize sys.stderr = tools.OutputRedirect(logfile, True, opts.quiet) sys.stdout = tools.OutputRedirect(logfile, False, opts.quiet) # Handle --quit, --kill and saving the PID to file pid_dir = config_module.core.pid_dir if opts.config is None: pid_file_path = os.path.join(pid_dir, 'sopel.pid') else: basename = os.path.basename(opts.config) if basename.endswith('.cfg'): basename = basename[:-4] pid_file_path = os.path.join(pid_dir, 'sopel-%s.pid' % basename) if os.path.isfile(pid_file_path): with open(pid_file_path, 'r') as pid_file: try: old_pid = int(pid_file.read()) except ValueError: old_pid = None if old_pid is not None and tools.check_pid(old_pid): if not opts.quit and not opts.kill: stderr('There\'s already a Sopel instance running with this config file') stderr('Try using the --quit or the --kill options') sys.exit(1) elif opts.kill: stderr('Killing the sopel') os.kill(old_pid, signal.SIGKILL) sys.exit(0) elif opts.quit: stderr('Signaling Sopel to stop gracefully') if hasattr(signal, 'SIGUSR1'): os.kill(old_pid, signal.SIGUSR1) else: os.kill(old_pid, signal.SIGTERM) sys.exit(0) elif opts.kill or opts.quit: stderr('Sopel is not running!') sys.exit(1) elif opts.quit or opts.kill: stderr('Sopel is not running!') sys.exit(1) if opts.daemonize: child_pid = os.fork() if child_pid is not 0: sys.exit() with open(pid_file_path, 'w') as pid_file: pid_file.write(str(os.getpid())) # Step Five: Initialise And Run sopel run(config_module, pid_file_path) except KeyboardInterrupt: print("\n\nInterrupted") os._exit(1) if __name__ == '__main__': main() sopel-6.6.9/sopel/test_tools.py000066400000000000000000000152111347452002400165520ustar00rootroot00000000000000# coding=utf-8 """This module has classes and functions that can help in writing tests. test_tools.py - Sopel misc tools Copyright 2013, Ari Koivula, Licensed under the Eiffel Forum License 2. https://sopel.chat """ from __future__ import unicode_literals, absolute_import, print_function, division import os import re import sys import tempfile try: import ConfigParser except ImportError: import configparser as ConfigParser import sopel.config import sopel.config.core_section import sopel.tools import sopel.tools.target import sopel.trigger class MockConfig(sopel.config.Config): def __init__(self): self.filename = tempfile.mkstemp()[1] #self._homedir = tempfile.mkdtemp() #self.filename = os.path.join(self._homedir, 'test.cfg') self.parser = ConfigParser.RawConfigParser(allow_no_value=True) self.parser.add_section('core') self.parser.set('core', 'owner', 'Embolalia') self.define_section('core', sopel.config.core_section.CoreSection) self.get = self.parser.get def define_section(self, name, cls_): if not self.parser.has_section(name): self.parser.add_section(name) setattr(self, name, cls_(self, name)) class MockSopel(object): def __init__(self, nick, admin=False, owner=False): self.nick = nick self.user = "sopel" channel = sopel.tools.Identifier("#Sopel") self.channels = sopel.tools.SopelMemory() self.channels[channel] = sopel.tools.target.Channel(channel) self.users = sopel.tools.SopelMemory() self.privileges = sopel.tools.SopelMemory() self.memory = sopel.tools.SopelMemory() self.ops = {} self.halfplus = {} self.voices = {} self.config = MockConfig() self._init_config() if admin: self.config.core.admins = [self.nick] if owner: self.config.core.owner = self.nick def _init_config(self): cfg = self.config cfg.parser.set('core', 'admins', '') cfg.parser.set('core', 'owner', '') home_dir = os.path.join(os.path.expanduser('~'), '.sopel') if not os.path.exists(home_dir): os.mkdir(home_dir) cfg.parser.set('core', 'homedir', home_dir) class MockSopelWrapper(object): def __init__(self, bot, pretrigger): self.bot = bot self.pretrigger = pretrigger self.output = [] def _store(self, string, recipent=None, **kwargs): self.output.append(string.strip()) say = reply = action = _store def __getattr__(self, attr): return getattr(self.bot, attr) def get_example_test(tested_func, msg, results, privmsg, admin, owner, repeat, use_regexp, ignore=[]): """Get a function that calls tested_func with fake wrapper and trigger. Args: tested_func - A sopel callable that accepts SopelWrapper and Trigger. msg - Message that is supposed to trigger the command. results - Expected output from the callable. privmsg - If true, make the message appear to have sent in a private message to the bot. If false, make it appear to have come from a channel. admin - If true, make the message appear to have come from an admin. owner - If true, make the message appear to have come from an owner. repeat - How many times to repeat the test. Useful for tests that return random stuff. use_regexp = Bool. If true, results is in regexp format. ignore - List of strings to ignore. """ def test(): bot = MockSopel("NickName", admin=admin, owner=owner) match = None if hasattr(tested_func, "commands"): for command in tested_func.commands: regexp = sopel.tools.get_command_regexp(".", command) match = regexp.match(msg) if match: break assert match, "Example did not match any command." sender = bot.nick if privmsg else "#channel" hostmask = "%s!%s@%s " % (bot.nick, "UserName", "example.com") # TODO enable message tags full_message = ':{} PRIVMSG {} :{}'.format(hostmask, sender, msg) pretrigger = sopel.trigger.PreTrigger(bot.nick, full_message) trigger = sopel.trigger.Trigger(bot.config, pretrigger, match) module = sys.modules[tested_func.__module__] if hasattr(module, 'setup'): module.setup(bot) def isnt_ignored(value): """Return True if value doesn't match any re in ignore list.""" for ignored_line in ignore: if re.match(ignored_line, value): return False return True for _i in range(repeat): wrapper = MockSopelWrapper(bot, trigger) tested_func(wrapper, trigger) wrapper.output = list(filter(isnt_ignored, wrapper.output)) assert len(wrapper.output) == len(results) for result, output in zip(results, wrapper.output): if type(output) is bytes: output = output.decode('utf-8') if use_regexp: if not re.match(result, output): assert result == output else: assert result == output return test def get_disable_setup(): import pytest import py @pytest.fixture(autouse=True) def disable_setup(request, monkeypatch): setup = getattr(request.module, "setup", None) isfixture = hasattr(setup, "_pytestfixturefunction") if setup is not None and not isfixture and py.builtin.callable(setup): monkeypatch.setattr(setup, "_pytestfixturefunction", pytest.fixture(), raising=False) return disable_setup def insert_into_module(func, module_name, base_name, prefix): """Add a function into a module.""" func.__module__ = module_name module = sys.modules[module_name] # Make sure the func method does not overwrite anything. for i in range(1000): func.__name__ = str("%s_%s_%s" % (prefix, base_name, i)) if not hasattr(module, func.__name__): break setattr(module, func.__name__, func) def run_example_tests(filename, tb='native', multithread=False, verbose=False): # These are only required when running tests, so import them here rather # than at the module level. import pytest from multiprocessing import cpu_count args = [filename, "-s"] args.extend(['--tb', tb]) if verbose: args.extend(['-v']) if multithread and cpu_count() > 1: args.extend(["-n", str(cpu_count())]) pytest.main(args) sopel-6.6.9/sopel/tools/000077500000000000000000000000001347452002400151415ustar00rootroot00000000000000sopel-6.6.9/sopel/tools/__init__.py000066400000000000000000000343051347452002400172570ustar00rootroot00000000000000# coding=utf-8 """Useful miscellaneous tools and shortcuts for Sopel modules *Availability: 3+* """ # tools.py - Sopel misc tools # Copyright 2008, Sean B. Palmer, inamidst.com # Copyright © 2012, Elad Alfassa # Copyright 2012, Elsie Powell, embolalia.com # Licensed under the Eiffel Forum License 2. # https://sopel.chat from __future__ import unicode_literals, absolute_import, print_function, division import sys import os import re import threading import codecs import traceback from collections import defaultdict from sopel.tools._events import events # NOQA if sys.version_info.major >= 3: raw_input = input unicode = str iteritems = dict.items itervalues = dict.values iterkeys = dict.keys else: iteritems = dict.iteritems itervalues = dict.itervalues iterkeys = dict.iterkeys _channel_prefixes = ('#', '&', '+', '!') # Can be implementation-dependent _regex_type = type(re.compile('')) def get_input(prompt): """Get decoded input from the terminal (equivalent to python 3's ``input``). """ if sys.version_info.major >= 3: return input(prompt) else: return raw_input(prompt).decode('utf8') def get_raising_file_and_line(tb=None): """Return the file and line number of the statement that raised the tb. Returns: (filename, lineno) tuple """ if not tb: tb = sys.exc_info()[2] filename, lineno, _context, _line = traceback.extract_tb(tb)[-1] return filename, lineno def compile_rule(nick, pattern, alias_nicks): """ Return a compiled rule regex, replacing placeholders for ``$nick`` and ``$nickname`` with the values defined in the bot's config at startup. """ # Not sure why this happens on reloads, but it shouldn't cause problems… if isinstance(pattern, _regex_type): return pattern if alias_nicks: nicks = list(alias_nicks) # alias_nicks.copy() doesn't work in py2 nicks.append(nick) nicks = map(re.escape, nicks) nick = '(?:%s)' % '|'.join(nicks) else: nick = re.escape(nick) pattern = pattern.replace('$nickname', nick) pattern = pattern.replace('$nick', r'{}[,:]\s+'.format(nick)) flags = re.IGNORECASE if '\n' in pattern: flags |= re.VERBOSE return re.compile(pattern, flags) def get_command_regexp(prefix, command): """Return a compiled regexp object that implements the command.""" # Escape all whitespace with a single backslash. This ensures that regexp # in the prefix is treated as it was before the actual regexp was changed # to use the verbose syntax. prefix = re.sub(r"(\s)", r"\\\1", prefix) pattern = get_command_pattern(prefix, command) return re.compile(pattern, re.IGNORECASE | re.VERBOSE) def get_command_pattern(prefix, command): """Return the uncompiled regex pattern for standard commands.""" # This regexp matches equivalently and produces the same # groups 1 and 2 as the old regexp: r'^%s(%s)(?: +(.*))?$' # The only differences should be handling all whitespace # like spaces and the addition of groups 3-6. return r""" (?:{prefix})({command}) # Command as group 1. (?:\s+ # Whitespace to end command. ( # Rest of the line as group 2. (?:(\S+))? # Parameters 1-4 as groups 3-6. (?:\s+(\S+))? (?:\s+(\S+))? (?:\s+(\S+))? .* # Accept anything after the parameters. # Leave it up to the module to parse # the line. ))? # Group 2 must be None, if there are no # parameters. $ # EoL, so there are no partial matches. """.format(prefix=prefix, command=command) def get_nickname_command_regexp(nick, command, alias_nicks): """Return a compiled regexp object that implements the nickname command.""" if isinstance(alias_nicks, unicode): alias_nicks = [alias_nicks] elif not isinstance(alias_nicks, list): raise ValueError('A list or string is required.') return compile_rule(nick, get_nickname_command_pattern(command), alias_nicks) def get_nickname_command_pattern(command): """Return the uncompiled regex pattern for nickname commands.""" return r""" ^ $nickname[:,]? # Nickname. \s+({command}) # Command as group 1. (?:\s+ # Whitespace to end command. ( # Rest of the line as group 2. (?:(\S+))? # Parameters 1-4 as groups 3-6. (?:\s+(\S+))? (?:\s+(\S+))? (?:\s+(\S+))? .* # Accept anything after the parameters. Leave it up to # the module to parse the line. ))? # Group 1 must be None, if there are no parameters. $ # EoL, so there are no partial matches. """.format(command=command) def get_sendable_message(text, max_length=400): """Get a sendable ``text`` message, with its excess when needed. :param str txt: unicode string of text to send :param int max_length: maximum length of the message to be sendable :return: a tuple of two values, the sendable text and its excess text We're arbitrarily saying that the max is 400 bytes of text when messages will be split. Otherwise, we'd have to account for the bot's hostmask, which is hard. The `max_length` is the max length of text in **bytes**, but we take care of unicode 2-bytes characters, by working on the unicode string, then making sure the bytes version is smaller than the max length. """ unicode_max_length = max_length excess = '' while len(text.encode('utf-8')) > max_length: last_space = text.rfind(' ', 0, unicode_max_length) if last_space == -1: # No last space, just split where it is possible excess = text[unicode_max_length:] + excess text = text[:unicode_max_length] # Decrease max length for the unicode string unicode_max_length = unicode_max_length - 1 else: # Split at the last best space found excess = text[last_space:] text = text[:last_space] return text, excess.lstrip() def deprecated(old): def new(*args, **kwargs): print('Function %s is deprecated.' % old.__name__, file=sys.stderr) trace = traceback.extract_stack() for line in traceback.format_list(trace[:-1]): stderr(line[:-1]) return old(*args, **kwargs) new.__doc__ = old.__doc__ new.__name__ = old.__name__ return new # from # http://parand.com/say/index.php/2007/07/13/simple-multi-dimensional-dictionaries-in-python/ # A simple class to make mutli dimensional dict easy to use class Ddict(dict): """Class for multi-dimensional ``dict``. A simple helper class to ease the creation of multi-dimensional ``dict``\\s. """ def __init__(self, default=None): self.default = default def __getitem__(self, key): if key not in self: self[key] = self.default() return dict.__getitem__(self, key) class Identifier(unicode): """A `unicode` subclass which acts appropriately for IRC identifiers. When used as normal `unicode` objects, case will be preserved. However, when comparing two Identifier objects, or comparing a Identifier object with a `unicode` object, the comparison will be case insensitive. This case insensitivity includes the case convention conventions regarding ``[]``, ``{}``, ``|``, ``\\``, ``^`` and ``~`` described in RFC 2812. """ def __new__(cls, identifier): # According to RFC2812, identifiers have to be in the ASCII range. # However, I think it's best to let the IRCd determine that, and we'll # just assume unicode. It won't hurt anything, and is more internally # consistent. And who knows, maybe there's another use case for this # weird case convention. s = unicode.__new__(cls, identifier) s._lowered = Identifier._lower(identifier) return s def lower(self): """Return the identifier converted to lower-case per RFC 2812.""" return self._lowered @staticmethod def _lower(identifier): """Returns `identifier` in lower case per RFC 2812.""" if isinstance(identifier, Identifier): return identifier._lowered # The tilde replacement isn't needed for identifiers, but is for # channels, which may be useful at some point in the future. low = identifier.lower().replace('{', '[').replace('}', ']') low = low.replace('|', '\\').replace('^', '~') return low def __repr__(self): return "%s(%r)" % ( self.__class__.__name__, self.__str__() ) def __hash__(self): return self._lowered.__hash__() def __lt__(self, other): if isinstance(other, unicode): other = Identifier._lower(other) return unicode.__lt__(self._lowered, other) def __le__(self, other): if isinstance(other, unicode): other = Identifier._lower(other) return unicode.__le__(self._lowered, other) def __gt__(self, other): if isinstance(other, unicode): other = Identifier._lower(other) return unicode.__gt__(self._lowered, other) def __ge__(self, other): if isinstance(other, unicode): other = Identifier._lower(other) return unicode.__ge__(self._lowered, other) def __eq__(self, other): if isinstance(other, unicode): other = Identifier._lower(other) return unicode.__eq__(self._lowered, other) def __ne__(self, other): return not (self == other) def is_nick(self): """Returns True if the Identifier is a nickname (as opposed to channel) """ return self and not self.startswith(_channel_prefixes) class OutputRedirect(object): """Redirect te output to the terminal and a log file. A simplified object used to write to both the terminal and a log file. """ def __init__(self, logpath, stderr=False, quiet=False): """Create an object which will to to a file and the terminal. Create an object which will log to the file at ``logpath`` as well as the terminal. If ``stderr`` is given and true, it will write to stderr rather than stdout. If ``quiet`` is given and True, data will be written to the log file only, but not the terminal. """ self.logpath = logpath self.stderr = stderr self.quiet = quiet def write(self, string): """Write the given ``string`` to the logfile and terminal.""" if not self.quiet: try: if self.stderr: sys.__stderr__.write(string) else: sys.__stdout__.write(string) except Exception: # TODO: Be specific pass with codecs.open(self.logpath, 'ab', encoding="utf8", errors='xmlcharrefreplace') as logfile: try: logfile.write(string) except UnicodeDecodeError: # we got an invalid string, safely encode it to utf-8 logfile.write(unicode(string, 'utf8', errors="replace")) def flush(self): if self.stderr: sys.__stderr__.flush() else: sys.__stdout__.flush() # These seems to trace back to when we thought we needed a try/except on prints, # because it looked like that was why we were having problems. We'll drop it in # 4.0^H^H^H5.0^H^H^H6.0^H^H^Hsome version when someone can be bothered. @deprecated def stdout(string): print(string) def stderr(string): """Print the given ``string`` to stderr. This is equivalent to ``print >> sys.stderr, string`` """ print(string, file=sys.stderr) def check_pid(pid): """Check if a process is running with the given ``PID``. *Availability: Only on POSIX systems* Return ``True`` if there is a process running with the given ``PID``. """ try: os.kill(pid, 0) except OSError: return False else: return True def get_hostmask_regex(mask): """Return a compiled `re.RegexObject` for an IRC hostmask""" mask = re.escape(mask) mask = mask.replace(r'\*', '.*') return re.compile(mask + '$', re.I) class SopelMemory(dict): """A simple thread-safe dict implementation. *Availability: 4.0; available as ``Willie.WillieMemory`` in 3.1.0 - 3.2.0* In order to prevent exceptions when iterating over the values and changing them at the same time from different threads, we use a blocking lock on ``__setitem__`` and ``contains``. """ def __init__(self, *args): dict.__init__(self, *args) self.lock = threading.Lock() def __setitem__(self, key, value): self.lock.acquire() result = dict.__setitem__(self, key, value) self.lock.release() return result def __contains__(self, key): """Check if a key is in the dict. It locks it for writes when doing so. """ self.lock.acquire() result = dict.__contains__(self, key) self.lock.release() return result def contains(self, key): """Backwards compatability with 3.x, use `in` operator instead.""" return self.__contains__(key) class SopelMemoryWithDefault(defaultdict): """Same as SopelMemory, but subclasses from collections.defaultdict.""" def __init__(self, *args): defaultdict.__init__(self, *args) self.lock = threading.Lock() def __setitem__(self, key, value): self.lock.acquire() result = defaultdict.__setitem__(self, key, value) self.lock.release() return result def __contains__(self, key): """Check if a key is in the dict. It locks it for writes when doing so. """ self.lock.acquire() result = defaultdict.__contains__(self, key) self.lock.release() return result def contains(self, key): """Backwards compatability with 3.x, use `in` operator instead.""" return self.__contains__(key) sopel-6.6.9/sopel/tools/_events.py000066400000000000000000000130661347452002400171640ustar00rootroot00000000000000# coding=utf-8 from __future__ import unicode_literals, absolute_import, print_function, division class events(object): """An enumeration of all the standardized and notable IRC numeric events This allows you to do, for example, ``@module.event(events.RPL_WELCOME)`` rather than ``@module.event('001')`` """ # ###################################################### Non-RFC / Non-IRCv3 # Only add things here if they're actually in common use across multiple # ircds. RPL_ISUPPORT = '005' RPL_WHOSPCRPL = '354' # ################################################################### IRC v3 # ## 3.1 # CAP ERR_INVALIDCAPCMD = '410' # SASL RPL_LOGGEDIN = '900' RPL_LOGGEDOUT = '901' ERR_NICKLOCKED = '902' RPL_SASLSUCCESS = '903' ERR_SASLFAIL = '904' ERR_SASLTOOLONG = '905' ERR_SASLABORTED = '906' ERR_SASLALREADY = '907' RPL_SASLMECHS = '908' # TLS RPL_STARTTLS = '670' ERR_STARTTLS = '691' # ## 3.2 # Metadata RPL_WHOISKEYVALUE = '760' RPL_KEYVALUE = '761' RPL_METADATAEND = '762' ERR_METADATALIMIT = '764' ERR_TARGETINVALID = '765' ERR_NOMATCHINGKEY = '766' ERR_KEYINVALID = '767' ERR_KEYNOTSET = '768' ERR_KEYNOPERMISSION = '769' # Monitor RPL_MONONLINE = '730' RPL_MONOFFLINE = '731' RPL_MONLIST = '732' RPL_ENDOFMONLIST = '733' ERR_MONLISTFULL = '734' # ################################################################# RFC 1459 # ## 6.1 Error Replies. ERR_NOSUCHNICK = '401' ERR_NOSUCHSERVER = '402' ERR_NOSUCHCHANNEL = '403' ERR_CANNOTSENDTOCHAN = '404' ERR_TOOMANYCHANNELS = '405' ERR_WASNOSUCHNICK = '406' ERR_TOOMANYTARGETS = '407' ERR_NOORIGIN = '409' ERR_NORECIPIENT = '411' ERR_NOTEXTTOSEND = '412' ERR_NOTOPLEVEL = '413' ERR_WILDTOPLEVEL = '414' ERR_UNKNOWNCOMMAND = '421' ERR_NOMOTD = '422' ERR_NOADMININFO = '423' ERR_FILEERROR = '424' ERR_NONICKNAMEGIVEN = '431' ERR_ERRONEUSNICKNAME = '432' ERR_NICKNAMEINUSE = '433' ERR_NICKCOLLISION = '436' ERR_USERNOTINCHANNEL = '441' ERR_NOTONCHANNEL = '442' ERR_USERONCHANNEL = '443' ERR_NOLOGIN = '444' ERR_SUMMONDISABLED = '445' ERR_USERSDISABLED = '446' ERR_NOTREGISTERED = '451' ERR_NEEDMOREPARAMS = '461' ERR_ALREADYREGISTRED = '462' ERR_NOPERMFORHOST = '463' ERR_PASSWDMISMATCH = '464' ERR_YOUREBANNEDCREEP = '465' ERR_KEYSET = '467' ERR_CHANNELISFULL = '471' ERR_UNKNOWNMODE = '472' ERR_INVITEONLYCHAN = '473' ERR_BANNEDFROMCHAN = '474' ERR_BADCHANNELKEY = '475' ERR_NOPRIVILEGES = '481' ERR_CHANOPRIVSNEEDED = '482' ERR_CANTKILLSERVER = '483' ERR_NOOPERHOST = '491' ERR_UMODEUNKNOWNFLAG = '501' ERR_USERSDONTMATCH = '502' # ## 6.2 Command responses. RPL_NONE = '300' RPL_USERHOST = '302' RPL_ISON = '303' RPL_AWAY = '301' RPL_UNAWAY = '305' RPL_NOWAWAY = '306' RPL_WHOISUSER = '311' RPL_WHOISSERVER = '312' RPL_WHOISOPERATOR = '313' RPL_WHOISIDLE = '317' RPL_ENDOFWHOIS = '318' RPL_WHOISCHANNELS = '319' RPL_WHOWASUSER = '314' RPL_ENDOFWHOWAS = '369' RPL_LISTSTART = '321' RPL_LIST = '322' RPL_LISTEND = '323' RPL_CHANNELMODEIS = '324' RPL_NOTOPIC = '331' RPL_TOPIC = '332' RPL_INVITING = '341' RPL_SUMMONING = '342' RPL_VERSION = '351' RPL_WHOREPLY = '352' RPL_ENDOFWHO = '315' RPL_NAMREPLY = '353' RPL_ENDOFNAMES = '366' RPL_LINKS = '364' RPL_ENDOFLINKS = '365' RPL_BANLIST = '367' RPL_ENDOFBANLIST = '368' RPL_INFO = '371' RPL_ENDOFINFO = '374' RPL_MOTDSTART = '375' RPL_MOTD = '372' RPL_ENDOFMOTD = '376' RPL_YOUREOPER = '381' RPL_REHASHING = '382' RPL_TIME = '391' RPL_USERSSTART = '392' RPL_USERS = '393' RPL_ENDOFUSERS = '394' RPL_NOUSERS = '395' RPL_TRACELINK = '200' RPL_TRACECONNECTING = '201' RPL_TRACEHANDSHAKE = '202' RPL_TRACEUNKNOWN = '203' RPL_TRACEOPERATOR = '204' RPL_TRACEUSER = '205' RPL_TRACESERVER = '206' RPL_TRACENEWTYPE = '208' RPL_TRACELOG = '261' RPL_STATSLINKINFO = '211' RPL_STATSCOMMANDS = '212' RPL_STATSCLINE = '213' RPL_STATSNLINE = '214' RPL_STATSILINE = '215' RPL_STATSKLINE = '216' RPL_STATSYLINE = '218' RPL_ENDOFSTATS = '219' RPL_STATSLLINE = '241' RPL_STATSUPTIME = '242' RPL_STATSOLINE = '243' RPL_STATSHLINE = '244' RPL_UMODEIS = '221' RPL_LUSERCLIENT = '251' RPL_LUSEROP = '252' RPL_LUSERUNKNOWN = '253' RPL_LUSERCHANNELS = '254' RPL_LUSERME = '255' RPL_ADMINME = '256' RPL_ADMINLOC1 = '257' RPL_ADMINLOC2 = '258' RPL_ADMINEMAIL = '259' # ################################################################# RFC 2812 # ## 5.1 Command responses RPL_WELCOME = '001' RPL_YOURHOST = '002' RPL_CREATED = '003' RPL_MYINFO = '004' RPL_BOUNCE = '005' RPL_UNIQOPIS = '325' RPL_INVITELIST = '346' RPL_ENDOFINVITELIST = '347' RPL_EXCEPTLIST = '348' RPL_ENDOFEXCEPTLIST = '349' RPL_YOURESERVICE = '383' RPL_TRACESERVICE = '207' RPL_TRACECLASS = '209' RPL_TRACERECONNECT = '210' RPL_TRACEEND = '262' RPL_SERVLIST = '234' RPL_SERVLISTEND = '235' RPL_TRYAGAIN = '263' # ## 5.2 Error Replies ERR_NOSUCHSERVICE = '408' ERR_BADMASK = '415' ERR_UNAVAILRESOURCE = '437' ERR_YOUWILLBEBANNED = '466' ERR_BADCHANMASK = '476' ERR_NOCHANMODES = '477' ERR_BANLISTFULL = '478' ERR_RESTRICTED = '484' ERR_UNIQOPPRIVSNEEDED = '485' sopel-6.6.9/sopel/tools/calculation.py000066400000000000000000000160451347452002400200170ustar00rootroot00000000000000# coding=utf-8 """Tools to help safely do calculations from user input""" from __future__ import unicode_literals, absolute_import, print_function, division import time import numbers import operator import ast __all__ = ['eval_equation'] class ExpressionEvaluator: """A generic class for evaluating limited forms of Python expressions. Instances can overwrite binary_ops and unary_ops attributes with dicts of the form {ast.Node, function}. When the ast.Node being used as key is found, it will be evaluated using the given function. """ class Error(Exception): pass def __init__(self, bin_ops=None, unary_ops=None): self.binary_ops = bin_ops or {} self.unary_ops = unary_ops or {} def __call__(self, expression_str, timeout=5.0): """Evaluate a python expression and return the result. Raises: SyntaxError: If the given expression_str is not a valid python statement. ExpressionEvaluator.Error: If the instance of ExpressionEvaluator does not have a handler for the ast.Node. """ ast_expression = ast.parse(expression_str, mode='eval') return self._eval_node(ast_expression.body, time.time() + timeout) def _eval_node(self, node, timeout): """Recursively evaluate the given ast.Node. Uses self.binary_ops and self.unary_ops for the implementation. A subclass could overwrite this to handle more nodes, calling it only for nodes it does not implement it self. Raises: ExpressionEvaluator.Error: If it can't handle the ast.Node. """ if isinstance(node, ast.Num): return node.n elif (isinstance(node, ast.BinOp) and type(node.op) in self.binary_ops): left = self._eval_node(node.left, timeout) right = self._eval_node(node.right, timeout) if time.time() > timeout: raise ExpressionEvaluator.Error( "Time for evaluating expression ran out.") return self.binary_ops[type(node.op)](left, right) elif (isinstance(node, ast.UnaryOp) and type(node.op) in self.unary_ops): operand = self._eval_node(node.operand, timeout) if time.time() > timeout: raise ExpressionEvaluator.Error( "Time for evaluating expression ran out.") return self.unary_ops[type(node.op)](operand) raise ExpressionEvaluator.Error( "Ast.Node '%s' not implemented." % (type(node).__name__,)) def guarded_mul(left, right): """Decorate a function to raise an error for values > limit.""" # Only handle ints because floats will overflow anyway. if not isinstance(left, numbers.Integral): pass elif not isinstance(right, numbers.Integral): pass elif left in (0, 1) or right in (0, 1): # Ignore trivial cases. pass elif left.bit_length() + right.bit_length() > 664386: # 664386 is the number of bits (10**100000)**2 has, which is instant on # my laptop, while (10**1000000)**2 has a noticeable delay. It could # certainly be improved. raise ValueError( "Value is too large to be handled in limited time and memory.") return operator.mul(left, right) def pow_complexity(num, exp): """Estimate the worst case time pow(num, exp) takes to calculate. This function is based on experimetal data from the time it takes to calculate "num**exp" on laptop with i7-2670QM processor on a 32 bit CPython 2.7.6 interpreter on Windows. It tries to implement this surface: x=exp, y=num 1e5 2e5 3e5 4e5 5e5 6e5 7e5 8e5 9e5 e1 0.03 0.09 0.16 0.25 0.35 0.46 0.60 0.73 0.88 e2 0.08 0.24 0.46 0.73 1.03 1.40 1.80 2.21 2.63 e3 0.15 0.46 0.87 1.39 1.99 2.63 3.35 4.18 5.15 e4 0.24 0.73 1.39 2.20 3.11 4.18 5.39 6.59 7.88 e5 0.34 1.03 2.00 3.12 4.48 5.97 7.56 9.37 11.34 e6 0.46 1.39 2.62 4.16 5.97 7.86 10.09 12.56 15.39 e7 0.60 1.79 3.34 5.39 7.60 10.16 13.00 16.23 19.44 e8 0.73 2.20 4.18 6.60 9.37 12.60 16.26 19.83 23.70 e9 0.87 2.62 5.15 7.93 11.34 15.44 19.40 23.66 28.58 For powers of 2 it tries to implement this surface: 1e7 2e7 3e7 4e7 5e7 6e7 7e7 8e7 9e7 1 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 2 0.21 0.44 0.71 0.92 1.20 1.49 1.66 1.95 2.23 4 0.43 0.91 1.49 1.96 2.50 3.13 3.54 4.10 4.77 8 0.70 1.50 2.24 3.16 3.83 4.66 5.58 6.56 7.67 The function number were selected by starting with the theoretical complexity of exp * log2(num)**2 and fiddling with the exponents untill it more or less matched with the table. Because this function is based on a limited set of data it might not give accurate results outside these boundaries. The results derived from large num and exp were quite accurate for small num and very large exp though, except when num was a power of 2. """ if num in (0, 1) or exp in (0, 1): return 0 elif (num & (num - 1)) == 0: # For powers of 2 the scaling is a bit different. return exp ** 1.092 * num.bit_length() ** 1.65 / 623212911.121 else: return exp ** 1.590 * num.bit_length() ** 1.73 / 36864057619.3 def guarded_pow(left, right): # Only handle ints because floats will overflow anyway. if not isinstance(left, numbers.Integral): pass elif not isinstance(right, numbers.Integral): pass elif pow_complexity(left, right) < 0.5: # Value 0.5 is arbitary and based on a estimated runtime of 0.5s # on a fairly decent laptop processor. pass else: raise ValueError("Pow expression too complex to calculate.") return operator.pow(left, right) class EquationEvaluator(ExpressionEvaluator): __bin_ops = { ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: guarded_mul, ast.Div: operator.truediv, ast.Pow: guarded_pow, ast.Mod: operator.mod, ast.FloorDiv: operator.floordiv, ast.BitXor: guarded_pow } __unary_ops = { ast.USub: operator.neg, ast.UAdd: operator.pos, } def __init__(self): ExpressionEvaluator.__init__( self, bin_ops=self.__bin_ops, unary_ops=self.__unary_ops ) def __call__(self, expression_str): result = ExpressionEvaluator.__call__(self, expression_str) # This wrapper is here so additional sanity checks could be done # on the result of the eval, but currently none are done. return result eval_equation = EquationEvaluator() """Evaluates a Python equation expression and returns the result. Supports addition (+), subtraction (-), multiplication (*), division (/), power (**) and modulo (%). """ sopel-6.6.9/sopel/tools/jobs.py000066400000000000000000000167421347452002400164620ustar00rootroot00000000000000# coding=utf-8 from __future__ import unicode_literals, absolute_import, print_function, division import copy import datetime import sys import threading import time if sys.version_info.major >= 3: unicode = str basestring = str py3 = True else: py3 = False try: import Queue except ImportError: import queue as Queue class released(object): """A context manager that releases a lock temporarily.""" def __init__(self, lock): self.lock = lock def __enter__(self): self.lock.release() def __exit__(self, _type, _value, _traceback): self.lock.acquire() class PriorityQueue(Queue.PriorityQueue): """A priority queue with a peek method.""" def peek(self): """Return a copy of the first element without removing it.""" self.not_empty.acquire() try: while not self._qsize(): self.not_empty.wait() # Return a copy to avoid corrupting the heap. This is important # for thread safety if the object is mutable. return copy.deepcopy(self.queue[0]) finally: self.not_empty.release() class JobScheduler(threading.Thread): """Calls jobs assigned to it in steady intervals. JobScheduler is a thread that keeps track of Jobs and calls them every X seconds, where X is a property of the Job. It maintains jobs in a priority queue, where the next job to be called is always the first item. Thread safety is maintained with a mutex that is released during long operations, so methods add_job and clear_jobs can be safely called from the main thread. """ min_reaction_time = 30.0 # seconds """How often should scheduler checks for changes in the job list.""" def __init__(self, bot): """Requires bot as argument for logging.""" threading.Thread.__init__(self) self.bot = bot self._jobs = PriorityQueue() # While PriorityQueue it self is thread safe, this mutex is needed # to stop old jobs being put into new queue after clearing the # queue. self._mutex = threading.Lock() # self.cleared is used for more fine grained locking. self._cleared = False def add_job(self, job): """Add a Job to the current job queue.""" self._jobs.put(job) def clear_jobs(self): """Clear current Job queue and start fresh.""" if self._jobs.empty(): # Guards against getting stuck waiting for self._mutex when # thread is waiting for self._jobs to not be empty. return with self._mutex: self._cleared = True self._jobs = PriorityQueue() def run(self): """Run forever.""" while True: try: self._do_next_job() except Exception: # TODO: Be specific # Modules exceptions are caught earlier, so this is a bit # more serious. Options are to either stop the main thread # or continue this thread and hope that it won't happen # again. self.bot.error() # Sleep a bit to guard against busy-looping and filling # the log with useless error messages. time.sleep(10.0) # seconds def _do_next_job(self): """Wait until there is a job and do it.""" with self._mutex: # Wait until the next job should be executed. # This has to be a loop, because signals stop time.sleep(). while True: job = self._jobs.peek() difference = job.next_time - time.time() duration = min(difference, self.min_reaction_time) if duration <= 0: break with released(self._mutex): time.sleep(duration) self._cleared = False job = self._jobs.get() with released(self._mutex): if job.func.thread: t = threading.Thread( target=self._call, args=(job.func,) ) t.start() else: self._call(job.func) job.next() # If jobs were cleared during the call, don't put an old job # into the new job queue. if not self._cleared: self._jobs.put(job) def _call(self, func): """Wrapper for collecting errors from modules.""" # Sopel.bot.call is way too specialized to be used instead. try: func(self.bot) except Exception: # TODO: Be specific self.bot.error() class Job(object): """Hold information about when a function should be called next. Job is a simple structure that hold information about when a function should be called next. They can be put in a priority queue, in which case the Job that should be executed next is returned. Calling the method next modifies the Job object for the next time it should be executed. Current time is used to decide when the job should be executed next so it should only be called right after the function was called. """ max_catchup = 5 """ This governs how much the scheduling of jobs is allowed to get behind before they are simply thrown out to avoid calling the same function too many times at once. """ def __init__(self, interval, func): """Initialize Job. Args: interval: number of seconds between calls to func func: function to be called """ self.next_time = time.time() + interval self.interval = interval self.func = func def next(self): """Update self.next_time with the assumption func was just called. Returns: A modified job object. """ last_time = self.next_time current_time = time.time() delta = last_time + self.interval - current_time if last_time > current_time + self.interval: # Clock appears to have moved backwards. Reset # the timer to avoid waiting for the clock to # catch up to whatever time it was previously. self.next_time = current_time + self.interval elif delta < 0 and abs(delta) > self.interval * self.max_catchup: # Execution of jobs is too far behind. Give up on # trying to catch up and reset the time, so that # will only be repeated a maximum of # self.max_catchup times. self.next_time = current_time - \ self.interval * self.max_catchup else: self.next_time = last_time + self.interval return self def __cmp__(self, other): """Compare Job objects according to attribute next_time.""" return self.next_time - other.next_time if py3: def __lt__(self, other): return self.next_time < other.next_time def __gt__(self, other): return self.next_time > other.next_time def __str__(self): """Return a string representation of the Job object. Example result: )> """ iso_time = str(datetime.fromtimestamp(self.next_time)) return "" % \ (iso_time, self.interval, self.func) def __iter__(self): """This is an iterator. Never stops though.""" return self sopel-6.6.9/sopel/tools/target.py000066400000000000000000000060201347452002400167770ustar00rootroot00000000000000# coding=utf-8 from __future__ import unicode_literals, absolute_import, print_function, division import functools from sopel.tools import Identifier @functools.total_ordering class User(object): """A representation of a user Sopel is aware of.""" def __init__(self, nick, user, host): assert isinstance(nick, Identifier) self.nick = nick """The user's nickname.""" self.user = user """The user's local username.""" self.host = host """The user's hostname.""" self.channels = {} """The channels the user is in. This maps channel name :class:`~sopel.tools.Identifier`\\s to :class:`Channel` objects. """ self.account = None """The IRC services account of the user. This relies on IRCv3 account tracking being enabled. """ self.away = None """Whether the user is marked as away.""" hostmask = property(lambda self: '{}!{}@{}'.format(self.nick, self.user, self.host)) """The user's full hostmask.""" def __eq__(self, other): if not isinstance(other, User): return NotImplemented return self.nick == other.nick def __lt__(self, other): if not isinstance(other, User): return NotImplemented return self.nick < other.nick @functools.total_ordering class Channel(object): """A representation of a channel Sopel is in.""" def __init__(self, name): assert isinstance(name, Identifier) self.name = name """The name of the channel.""" self.users = {} """The users in the channel. This maps nickname :class:`~sopel.tools.Identifier`\\s to :class:`User` objects. """ self.privileges = {} """The permissions of the users in the channel. This maps nickname :class:`~sopel.tools.Identifier`\\s to bitwise integer values. This can be compared to appropriate constants from :mod:`sopel.module`. """ self.topic = '' """The topic of the channel.""" def clear_user(self, nick): user = self.users.pop(nick, None) self.privileges.pop(nick, None) if user is not None: user.channels.pop(self.name, None) def add_user(self, user, privs=0): assert isinstance(user, User) self.users[user.nick] = user self.privileges[user.nick] = privs user.channels[self.name] = self def rename_user(self, old, new): if old in self.users: self.users[new] = self.users.pop(old) self.users[new].nick = new if old in self.privileges: self.privileges[new] = self.privileges.pop(old) def __eq__(self, other): if not isinstance(other, Channel): return NotImplemented return self.name == other.name def __lt__(self, other): if not isinstance(other, Channel): return NotImplemented return self.name < other.name sopel-6.6.9/sopel/tools/time.py000066400000000000000000000122461347452002400164560ustar00rootroot00000000000000# coding=utf-8 """Tools for getting and displaying the time.""" from __future__ import unicode_literals, absolute_import, print_function, division import datetime try: import pytz except ImportError: pytz = False def validate_timezone(zone): """Return an IETF timezone from the given IETF zone or common abbreviation. If the length of the zone is 4 or less, it will be upper-cased before being looked up; otherwise it will be title-cased. This is the expected case-insensitivity behavior in the majority of cases. For example, ``'edt'`` and ``'america/new_york'`` will both return ``'America/New_York'``. If the zone is not valid, ``ValueError`` will be raised. If ``pytz`` is not available, and the given zone is anything other than ``'UTC'``, ``ValueError`` will be raised. """ if zone is None: return None if not pytz: if zone.upper() != 'UTC': raise ValueError('Only UTC available, since pytz is not installed.') else: return zone zone = '/'.join(reversed(zone.split(', '))).replace(' ', '_') if len(zone) <= 4: zone = zone.upper() else: zone = zone.title() if zone in pytz.all_timezones: return zone else: raise ValueError("Invalid time zone.") def validate_format(tformat): """Returns the format, if valid, else None""" try: time = datetime.datetime.utcnow() time.strftime(tformat) except Exception: # TODO: Be specific raise ValueError('Invalid time format') return tformat def get_timezone(db=None, config=None, zone=None, nick=None, channel=None): """Find, and return, the approriate timezone Time zone is pulled in the following priority: 1. `zone`, if it is valid 2. The timezone for the channel or nick `zone` in `db` if one is set and valid. 3. The timezone for the nick `nick` in `db`, if one is set and valid. 4. The timezone for the channel `channel` in `db`, if one is set and valid. 5. The default timezone in `config`, if one is set and valid. If `db` is not given, or given but not set up, steps 2 and 3 will be skipped. If `config` is not given, step 4 will be skipped. If no step yeilds a valid timezone, `None` is returned. Valid timezones are those present in the IANA Time Zone Database. Prior to checking timezones, two translations are made to make the zone names more human-friendly. First, the string is split on `', '`, the pieces reversed, and then joined with `'/'`. Next, remaining spaces are replaced with `'_'`. Finally, strings longer than 4 characters are made title-case, and those 4 characters and shorter are made upper-case. This means "new york, america" becomes "America/New_York", and "utc" becomes "UTC". This function relies on `pytz` being available. If it is not available, `None` will always be returned. """ def _check(zone): try: return validate_timezone(zone) except ValueError: return None if not pytz: return None tz = None if zone: tz = _check(zone) if not tz: tz = _check( db.get_nick_or_channel_value(zone, 'timezone')) if not tz and nick: tz = _check(db.get_nick_value(nick, 'timezone')) if not tz and channel: tz = _check(db.get_channel_value(channel, 'timezone')) if not tz and config and config.core.default_timezone: tz = _check(config.core.default_timezone) return tz def format_time(db=None, config=None, zone=None, nick=None, channel=None, time=None): """Return a formatted string of the given time in the given zone. `time`, if given, should be a naive `datetime.datetime` object and will be treated as being in the UTC timezone. If it is not given, the current time will be used. If `zone` is given and `pytz` is available, `zone` must be present in the IANA Time Zone Database; `get_timezone` can be helpful for this. If `zone` is not given or `pytz` is not available, UTC will be assumed. The format for the string is chosen in the following order: 1. The format for the nick `nick` in `db`, if one is set and valid. 2. The format for the channel `channel` in `db`, if one is set and valid. 3. The default format in `config`, if one is set and valid. 4. ISO-8601 If `db` is not given or is not set up, steps 1 and 2 are skipped. If config is not given, step 3 will be skipped.""" tformat = None if db: if nick: tformat = db.get_nick_value(nick, 'time_format') if not tformat and channel: tformat = db.get_channel_value(channel, 'time_format') if not tformat and config and config.core.default_time_format: tformat = config.core.default_time_format if not tformat: tformat = '%Y-%m-%d - %T%Z' if not time: time = datetime.datetime.utcnow() if not pytz or not zone: return time.strftime(tformat) else: if not time.tzinfo: utc = pytz.timezone('UTC') time = utc.localize(time) zone = pytz.timezone(zone) return time.astimezone(zone).strftime(tformat) sopel-6.6.9/sopel/trigger.py000066400000000000000000000202351347452002400160200ustar00rootroot00000000000000# coding=utf-8 from __future__ import unicode_literals, absolute_import, print_function, division import re import sys import datetime import sopel.tools if sys.version_info.major >= 3: unicode = str basestring = str class PreTrigger(object): """A parsed message from the server, which has not been matched against any rules.""" component_regex = re.compile(r'([^!]*)!?([^@]*)@?(.*)') intent_regex = re.compile('\x01(\\S+) ?(.*)\x01') def __init__(self, own_nick, line): """own_nick is the bot's nick, needed to correctly parse sender. line is the full line from the server.""" line = line.strip('\r') self.line = line # Break off IRCv3 message tags, if present self.tags = {} if line.startswith('@'): tagstring, line = line.split(' ', 1) for tag in tagstring[1:].split(';'): tag = tag.split('=', 1) if len(tag) > 1: self.tags[tag[0]] = tag[1] else: self.tags[tag[0]] = None self.time = datetime.datetime.utcnow() if 'time' in self.tags: try: self.time = datetime.datetime.strptime(self.tags['time'], '%Y-%m-%dT%H:%M:%S.%fZ') except ValueError: pass # Server isn't conforming to spec, ignore the server-time # Grabs hostmask from line. # Example: line = ':Sopel!foo@bar PRIVMSG #sopel :foobar!' # print(hostmask) # Sopel!foo@bar # All lines start with ":" except PING. if line.startswith(':'): self.hostmask, line = line[1:].split(' ', 1) else: self.hostmask = None # Parses the line into a list of arguments. # Some events like MODE don't have a secondary string argument, i.e. no ' :' inside the line. # Example 1: line = ':nick!ident@domain PRIVMSG #sopel :foo bar!' # print(text) # 'foo bar!' # print(argstr) # ':nick!ident@domain PRIVMSG #sopel' # print(args) # [':nick!ident@domain', 'PRIVMSG', '#sopel', 'foo bar!'] # Example 2: line = 'irc.freenode.net MODE Sopel +i' # print(text) # '+i' # print(args) # ['irc.freenode.net', 'MODE', 'Sopel', '+i'] if ' :' in line: argstr, text = line.split(' :', 1) self.args = argstr.split(' ') self.args.append(text) else: self.args = line.split(' ') self.text = self.args[-1] self.event = self.args[0] self.args = self.args[1:] components = PreTrigger.component_regex.match(self.hostmask or '').groups() self.nick, self.user, self.host = components self.nick = sopel.tools.Identifier(self.nick) # If we have arguments, the first one is the sender # Unless it's a QUIT event if self.args and self.event != 'QUIT': target = sopel.tools.Identifier(self.args[0]) else: target = None # Unless we're messaging the bot directly, in which case that second # arg will be our bot's name. if target and target.lower() == own_nick.lower(): target = self.nick self.sender = target # Parse CTCP into a form consistent with IRCv3 intents if self.event == 'PRIVMSG' or self.event == 'NOTICE': intent_match = PreTrigger.intent_regex.match(self.args[-1]) if intent_match: intent, message = intent_match.groups() self.tags['intent'] = intent self.args[-1] = message or '' # Populate account from extended-join messages if self.event == 'JOIN' and len(self.args) == 3: # Account is the second arg `...JOIN #Sopel account :realname` self.tags['account'] = self.args[1] class Trigger(unicode): """A line from the server, which has matched a callable's rules. Note that CTCP messages (`PRIVMSG`es and `NOTICE`es which start and end with `'\\x01'`) will have the `'\\x01'` bytes stripped, and the command (e.g. `ACTION`) placed mapped to the `'intent'` key in `Trigger.tags`. """ sender = property(lambda self: self._pretrigger.sender) """The channel from which the message was sent. In a private message, this is the nick that sent the message.""" time = property(lambda self: self._pretrigger.time) """A datetime object at which the message was received by the IRC server. If the server does not support server-time, then `time` will be the time that the message was received by Sopel""" raw = property(lambda self: self._pretrigger.line) """The entire message, as sent from the server. This includes the CTCP \\x01 bytes and command, if they were included.""" is_privmsg = property(lambda self: self._is_privmsg) """True if the trigger is from a user, False if it's from a channel.""" hostmask = property(lambda self: self._pretrigger.hostmask) """Hostmask of the person who sent the message as !@""" user = property(lambda self: self._pretrigger.user) """Local username of the person who sent the message""" nick = property(lambda self: self._pretrigger.nick) """The :class:`sopel.tools.Identifier` of the person who sent the message. """ host = property(lambda self: self._pretrigger.host) """The hostname of the person who sent the message""" event = property(lambda self: self._pretrigger.event) """The IRC event (e.g. ``PRIVMSG`` or ``MODE``) which triggered the message.""" match = property(lambda self: self._match) """The regular expression :class:`re.MatchObject` for the triggering line. """ group = property(lambda self: self._match.group) """The ``group`` function of the ``match`` attribute. See Python :mod:`re` documentation for details.""" groups = property(lambda self: self._match.groups) """The ``groups`` function of the ``match`` attribute. See Python :mod:`re` documentation for details.""" groupdict = property(lambda self: self._match.groupdict) """The ``groupdict`` function of the ``match`` attribute. See Python :mod:`re` documentation for details.""" args = property(lambda self: self._pretrigger.args) """ A tuple containing each of the arguments to an event. These are the strings passed between the event name and the colon. For example, setting ``mode -m`` on the channel ``#example``, args would be ``('#example', '-m')`` """ tags = property(lambda self: self._pretrigger.tags) """A map of the IRCv3 message tags on the message.""" admin = property(lambda self: self._admin) """True if the nick which triggered the command is one of the bot's admins. """ owner = property(lambda self: self._owner) """True if the nick which triggered the command is the bot's owner.""" account = property(lambda self: self.tags.get('account') or self._account) """The account name of the user sending the message. This is only available if either the account-tag or the account-notify and extended-join capabilites are available. If this isn't the case, or the user sending the message isn't logged in, this will be None. """ def __new__(cls, config, message, match, account=None): self = unicode.__new__(cls, message.args[-1] if message.args else '') self._account = account self._pretrigger = message self._match = match self._is_privmsg = message.sender and message.sender.is_nick() def match_host_or_nick(pattern): pattern = sopel.tools.get_hostmask_regex(pattern) return bool( pattern.match(self.nick) or pattern.match('@'.join((self.nick, self.host))) ) if config.core.owner_account: self._owner = config.core.owner_account == self.account else: self._owner = match_host_or_nick(config.core.owner) self._admin = ( self._owner or self.account in config.core.admin_accounts or any(match_host_or_nick(item) for item in config.core.admins) ) return self sopel-6.6.9/sopel/web.py000066400000000000000000000162451347452002400151400ustar00rootroot00000000000000# coding=utf-8 """ *Availability: 3+, depreacted in 6.2.0* The web class contains essential web-related functions for interaction with web applications or websites in your modules. It supports HTTP GET, HTTP POST and HTTP HEAD. """ # Copyright © 2008, Sean B. Palmer, inamidst.com # Copyright © 2009, Michael Yanovich # Copyright © 2012, Dimitri Molenaars, Tyrope.nl. # Copyright © 2012-2013, Elad Alfassa, # Licensed under the Eiffel Forum License 2. from __future__ import unicode_literals, absolute_import, print_function, division import re import sys import urllib import requests from sopel import __version__ from sopel.tools import deprecated if sys.version_info.major < 3: import httplib from htmlentitydefs import name2codepoint from urlparse import urlparse from urlparse import urlunparse else: import http.client as httplib from html.entities import name2codepoint from urllib.parse import urlparse from urllib.parse import urlunparse unichr = chr unicode = str try: import ssl if not hasattr(ssl, 'match_hostname'): # Attempt to import ssl_match_hostname from python-backports import backports.ssl_match_hostname ssl.match_hostname = backports.ssl_match_hostname.match_hostname ssl.CertificateError = backports.ssl_match_hostname.CertificateError has_ssl = True except ImportError: has_ssl = False USER_AGENT = 'Sopel/{} (https://sopel.chat)'.format(__version__) default_headers = {'User-Agent': USER_AGENT} ca_certs = None # Will be overriden when config loads. This is for an edge case. class MockHttpResponse(httplib.HTTPResponse): "Mock HTTPResponse with data that comes from requests." def __init__(self, response): self.headers = response.headers self.status = response.status_code self.reason = response.reason self.close = response.close self.read = response.raw.read self.url = response.url def geturl(self): return self.url # HTTP GET @deprecated def get(uri, timeout=20, headers=None, return_headers=False, limit_bytes=None, verify_ssl=True, dont_decode=False): # pragma: no cover """Execute an HTTP GET query on `uri`, and return the result. Deprecated. `timeout` is an optional argument, which represents how much time we should wait before throwing a timeout exception. It defaults to 20, but can be set to higher values if you are communicating with a slow web application. `headers` is a dict of HTTP headers to send with the request. If `return_headers` is True, return a tuple of (bytes, headers) `limit_bytes` is ignored. """ if not uri.startswith('http'): uri = "http://" + uri if headers is None: headers = default_headers else: tmp = default_headers.copy() tmp.update(headers) headers = tmp u = requests.get(uri, timeout=timeout, headers=headers, verify=verify_ssl) bytes = u.content u.close() headers = u.headers if not dont_decode: bytes = u.text if not return_headers: return bytes else: headers['_http_status'] = u.status_code return (bytes, headers) # Get HTTP headers @deprecated def head(uri, timeout=20, headers=None, verify_ssl=True): # pragma: no cover """Execute an HTTP GET query on `uri`, and return the headers. Deprecated. `timeout` is an optional argument, which represents how much time we should wait before throwing a timeout exception. It defaults to 20, but can be set to higher values if you are communicating with a slow web application. """ if not uri.startswith('http'): uri = "http://" + uri if headers is None: headers = default_headers else: tmp = default_headers.copy() tmp.update(headers) headers = tmp u = requests.get(uri, timeout=timeout, headers=headers, verify=verify_ssl) info = u.headers u.close() return info # HTTP POST @deprecated def post(uri, query, limit_bytes=None, timeout=20, verify_ssl=True, return_headers=False): # pragma: no cover """Execute an HTTP POST query. Deprecated. `uri` is the target URI, and `query` is the POST data. If `return_headers` is true, returns a tuple of (bytes, headers). `limit_bytes` is ignored. """ if not uri.startswith('http'): uri = "http://" + uri u = requests.post(uri, timeout=timeout, verify=verify_ssl, data=query) bytes = u.raw.read(limit_bytes) headers = u.headers u.close() if not return_headers: return bytes else: headers['_http_status'] = u.status_code return (bytes, headers) r_entity = re.compile(r'&([^;\s]+);') def entity(match): value = match.group(1).lower() if value.startswith('#x'): return unichr(int(value[2:], 16)) elif value.startswith('#'): return unichr(int(value[1:])) elif value in name2codepoint: return unichr(name2codepoint[value]) return '[' + value + ']' def decode(html): return r_entity.sub(entity, html) # For internal use in web.py, (modules can use this if they need a urllib # object they can execute read() on) Both handles redirects and makes sure # input URI is UTF-8 @deprecated def get_urllib_object(uri, timeout, headers=None, verify_ssl=True, data=None): # pragma: no cover """Return an HTTPResponse object for `uri` and `timeout` and `headers`. Deprecated """ if headers is None: headers = default_headers else: tmp = default_headers.copy() tmp.update(headers) headers = tmp if data is not None: response = requests.post(uri, timeout=timeout, verify=verify_ssl, data=data, headers=headers) else: response = requests.get(uri, timeout=timeout, verify=verify_ssl, headers=headers) return MockHttpResponse(response) # Identical to urllib2.quote def quote(string, safe='/'): """Like urllib2.quote but handles unicode properly.""" if sys.version_info.major < 3: if isinstance(string, unicode): string = string.encode('utf8') string = urllib.quote(string, safe.encode('utf8')) else: string = urllib.parse.quote(str(string), safe) return string def quote_query(string): """Quotes the query parameters.""" parsed = urlparse(string) string = string.replace(parsed.query, quote(parsed.query, "/=&"), 1) return string # Functions for international domain name magic def urlencode_non_ascii(b): regex = '[\x80-\xFF]' if sys.version_info.major > 2: regex = b'[\x80-\xFF]' return re.sub(regex, lambda c: '%%%02x' % ord(c.group(0)), b) def iri_to_uri(iri): parts = urlparse(iri) parts_seq = (part.encode('idna') if parti == 1 else urlencode_non_ascii(part.encode('utf-8')) for parti, part in enumerate(parts)) if sys.version_info.major > 2: parts_seq = list(parts_seq) parsed = urlunparse(parts_seq) if sys.version_info.major > 2: return parsed.decode() else: return parsed if sys.version_info.major < 3: urlencode = urllib.urlencode else: urlencode = urllib.parse.urlencode sopel-6.6.9/test/000077500000000000000000000000001347452002400136365ustar00rootroot00000000000000sopel-6.6.9/test/test_commands.py000066400000000000000000000046101347452002400170510ustar00rootroot00000000000000# coding=utf-8 """Tests for command handling""" from __future__ import unicode_literals, absolute_import, print_function, division import pytest import re from sopel.tools import get_command_regexp, get_nickname_command_regexp @pytest.fixture def nick(): return 'Sopel' @pytest.fixture def alias_nicks(): return ['Soap', 'Pie'] @pytest.fixture def prefix(): return '.' @pytest.fixture def prefix_regex(): re.escape(prefix()) @pytest.fixture def command(): return 'testcmd' @pytest.fixture def groups(command): return { 3: "three", 4: "four", 5: "five", 6: "six", } @pytest.fixture def command_line(prefix, command, groups): return "{}{} {}".format(prefix, command, ' '.join(groups.values())) @pytest.fixture def nickname_command_line(nick, command, groups): return "{}: {} {}".format(nick, command, ' '.join(groups.values())) def test_command_groups(prefix, command, groups, command_line): regex = get_command_regexp(prefix, command) match = re.match(regex, command_line) assert match.group(0) == command_line assert match.group(1) == command assert match.group(2) == ' '.join(groups.values()) assert match.group(3) == groups[3] assert match.group(4) == groups[4] assert match.group(5) == groups[5] assert match.group(6) == groups[6] def test_nickname_command_groups(command, nick, groups, nickname_command_line): regex = get_nickname_command_regexp(nick, command, []) match = re.match(regex, nickname_command_line) assert match.group(0) == nickname_command_line assert match.group(1) == command assert match.group(2) == ' '.join(groups.values()) assert match.group(3) == groups[3] assert match.group(4) == groups[4] assert match.group(5) == groups[5] assert match.group(6) == groups[6] def test_nickname_command_aliased(command, nick, alias_nicks, groups, nickname_command_line): aliased_command_line = nickname_command_line.replace(nick, alias_nicks[0]) regex = get_nickname_command_regexp(nick, command, alias_nicks) match = re.match(regex, aliased_command_line) assert match.group(0) == aliased_command_line assert match.group(1) == command assert match.group(2) == ' '.join(groups.values()) assert match.group(3) == groups[3] assert match.group(4) == groups[4] assert match.group(5) == groups[5] assert match.group(6) == groups[6] sopel-6.6.9/test/test_config.py000066400000000000000000000076621347452002400165270ustar00rootroot00000000000000# coding=utf-8 from __future__ import unicode_literals, division, print_function, absolute_import import os import tempfile import unittest from sopel import config from sopel.config import types class FakeConfigSection(types.StaticSection): valattr = types.ValidatedAttribute('valattr') listattr = types.ListAttribute('listattr') choiceattr = types.ChoiceAttribute('choiceattr', ['spam', 'egg', 'bacon']) af_fileattr = types.FilenameAttribute('af_fileattr', relative=False, directory=False) ad_fileattr = types.FilenameAttribute('ad_fileattr', relative=False, directory=True) rf_fileattr = types.FilenameAttribute('rf_fileattr', relative=True, directory=False) rd_fileattr = types.FilenameAttribute('rd_fileattr', relative=True, directory=True) class ConfigFunctionalTest(unittest.TestCase): @classmethod def read_config(cls): configo = config.Config(cls.filename) configo.define_section('fake', FakeConfigSection) return configo @classmethod def setUpClass(cls): cls.filename = tempfile.mkstemp()[1] with open(cls.filename, 'w') as fileo: fileo.write( "[core]\n" "owner=dgw\n" "homedir={}".format(os.path.expanduser('~/.sopel')) ) cls.config = cls.read_config() cls.testfile = open(os.path.expanduser('~/.sopel/test.tmp'), 'w+').name cls.testdir = os.path.expanduser('~/.sopel/test.d/') os.mkdir(cls.testdir) @classmethod def tearDownClass(cls): os.remove(cls.filename) os.remove(cls.testfile) os.rmdir(cls.testdir) def test_validated_string_when_none(self): self.config.fake.valattr = None self.assertEqual(self.config.fake.valattr, None) def test_listattribute_when_empty(self): self.config.fake.listattr = [] self.assertEqual(self.config.fake.listattr, []) def test_listattribute_with_one_value(self): self.config.fake.listattr = ['foo'] self.assertEqual(self.config.fake.listattr, ['foo']) def test_listattribute_with_multiple_values(self): self.config.fake.listattr = ['egg', 'sausage', 'bacon'] self.assertEqual(self.config.fake.listattr, ['egg', 'sausage', 'bacon']) def test_listattribute_with_value_containing_comma(self): self.config.fake.listattr = ['spam, egg, sausage', 'bacon'] self.assertEqual(self.config.fake.listattr, ['spam', 'egg', 'sausage', 'bacon']) def test_choiceattribute_when_none(self): self.config.fake.choiceattr = None self.assertEqual(self.config.fake.choiceattr, None) def test_choiceattribute_when_not_in_set(self): with self.assertRaises(ValueError): self.config.fake.choiceattr = 'sausage' def test_choiceattribute_when_valid(self): self.config.fake.choiceattr = 'bacon' self.assertEqual(self.config.fake.choiceattr, 'bacon') def test_fileattribute_valid_absolute_file_path(self): self.config.fake.af_fileattr = self.testfile self.assertEqual(self.config.fake.af_fileattr, self.testfile) def test_fileattribute_valid_absolute_dir_path(self): testdir = self.testdir self.config.fake.ad_fileattr = testdir self.assertEqual(self.config.fake.ad_fileattr, testdir) def test_fileattribute_given_relative_when_absolute(self): with self.assertRaises(ValueError): self.config.fake.af_fileattr = '../testconfig.tmp' def test_fileattribute_given_absolute_when_relative(self): self.config.fake.rf_fileattr = self.testfile self.assertEqual(self.config.fake.rf_fileattr, self.testfile) def test_fileattribute_given_dir_when_file(self): with self.assertRaises(ValueError): self.config.fake.af_fileattr = self.testdir def test_fileattribute_given_file_when_dir(self): with self.assertRaises(ValueError): self.config.fake.ad_fileattr = self.testfile sopel-6.6.9/test/test_coretasks.py000066400000000000000000000107231347452002400172500ustar00rootroot00000000000000# coding=utf-8 """coretasks.py tests""" from __future__ import unicode_literals, absolute_import, print_function, division import pytest from sopel import coretasks from sopel.module import VOICE, HALFOP, OP, ADMIN, OWNER from sopel.tools import Identifier from sopel.test_tools import MockSopel, MockSopelWrapper from sopel.trigger import PreTrigger, Trigger @pytest.fixture def sopel(): bot = MockSopel("Sopel") return bot def test_bot_mixed_modes(sopel): """ Ensure mixed modes like +vha are tracked correctly. Sopel 6.6.6 and older would assign all modes to all users. #1575 """ # RPL_NAMREPLY to create Users and (zeroed) privs for user in set("Unothing Uvoice Uhalfop Uop Uadmin Uowner".split(" ")): pretrigger = PreTrigger( "Foo", ":test.example.com 353 Foo = #test :Foo %s" % user ) trigger = Trigger(sopel.config, pretrigger, None) coretasks.handle_names(MockSopelWrapper(sopel, trigger), trigger) pretrigger = PreTrigger("Foo", "MODE #test +qvhao Uowner Uvoice Uhalfop Uadmin Uop") trigger = Trigger(sopel.config, pretrigger, None) coretasks.track_modes(MockSopelWrapper(sopel, trigger), trigger) assert sopel.channels["#test"].privileges[Identifier("Unothing")] == 0 assert sopel.channels["#test"].privileges[Identifier("Uvoice")] == VOICE assert sopel.channels["#test"].privileges[Identifier("Uhalfop")] == HALFOP assert sopel.channels["#test"].privileges[Identifier("Uop")] == OP assert sopel.channels["#test"].privileges[Identifier("Uadmin")] == ADMIN assert sopel.channels["#test"].privileges[Identifier("Uowner")] == OWNER def test_bot_mixed_mode_removal(sopel): """ Ensure mixed mode types like -h+a are handled Sopel 6.6.6 and older did not handle this correctly. #1575 """ # RPL_NAMREPLY to create Users and (zeroed) privs for user in set("Uvoice Uop".split(" ")): pretrigger = PreTrigger( "Foo", ":test.example.com 353 Foo = #test :Foo %s" % user ) trigger = Trigger(sopel.config, pretrigger, None) coretasks.handle_names(MockSopelWrapper(sopel, trigger), trigger) pretrigger = PreTrigger("Foo", "MODE #test +qao Uvoice Uvoice Uvoice") trigger = Trigger(sopel.config, pretrigger, None) coretasks.track_modes(MockSopelWrapper(sopel, trigger), trigger) pretrigger = PreTrigger( "Foo", "MODE #test -o+o-qa+v Uvoice Uop Uvoice Uvoice Uvoice" ) trigger = Trigger(sopel.config, pretrigger, None) coretasks.track_modes(MockSopelWrapper(sopel, trigger), trigger) assert sopel.channels["#test"].privileges[Identifier("Uvoice")] == VOICE assert sopel.channels["#test"].privileges[Identifier("Uop")] == OP def test_bot_mixed_mode_types(sopel): """ Ensure mixed argument- and non-argument- modes are handled Sopel 6.6.6 and older did not behave well. #1575 """ # RPL_NAMREPLY to create Users and (zeroed) privs for user in set("Uvoice Uop Uadmin Uvoice2 Uop2 Uadmin2".split(" ")): pretrigger = PreTrigger( "Foo", ":test.example.com 353 Foo = #test :Foo %s" % user ) trigger = Trigger(sopel.config, pretrigger, None) coretasks.handle_names(MockSopelWrapper(sopel, trigger), trigger) # Non-attribute-requiring non-permission mode pretrigger = PreTrigger("Foo", "MODE #test +amov Uadmin Uop Uvoice") trigger = Trigger(sopel.config, pretrigger, None) coretasks.track_modes(MockSopelWrapper(sopel, trigger), trigger) assert sopel.channels["#test"].privileges[Identifier("Uvoice")] == VOICE assert sopel.channels["#test"].privileges[Identifier("Uop")] == OP assert sopel.channels["#test"].privileges[Identifier("Uadmin")] == ADMIN # Attribute-requiring non-permission modes # This results in a _send_who, which isn't supported in MockSopel or this # test, so we just make sure it results in an exception instead of privesc. pretrigger = PreTrigger("Foo", "MODE #test +abov Uadmin2 x!y@z Uop2 Uvoice2") trigger = Trigger(sopel.config, pretrigger, None) try: coretasks.track_modes(MockSopelWrapper(sopel, trigger), trigger) except AttributeError as e: if e.args[0] == "'MockSopel' object has no attribute 'enabled_capabilities'": return assert sopel.channels["#test"].privileges[Identifier("Uvoice2")] == VOICE assert sopel.channels["#test"].privileges[Identifier("Uop2")] == OP assert sopel.channels["#test"].privileges[Identifier("Uadmin2")] == ADMIN sopel-6.6.9/test/test_db.py000066400000000000000000000171021347452002400156350ustar00rootroot00000000000000# coding=utf-8 """Tests for the new database functionality. TODO: Most of these tests assume functionality tested in other tests. This is enough to get everything working (and is better than nothing), but best practice would probably be not to do that.""" from __future__ import unicode_literals, absolute_import, print_function, division import json import os import sqlite3 import sys import tempfile import pytest from sopel.db import SopelDB from sopel.test_tools import MockConfig from sopel.tools import Identifier db_filename = tempfile.mkstemp()[1] if sys.version_info.major >= 3: unicode = str basestring = str iteritems = dict.items itervalues = dict.values iterkeys = dict.keys else: iteritems = dict.iteritems itervalues = dict.itervalues iterkeys = dict.iterkeys @pytest.fixture def db(): config = MockConfig() config.core.db_filename = db_filename db = SopelDB(config) # TODO add tests to ensure db creation works properly, too. return db def teardown_function(function): os.remove(db_filename) def test_get_nick_id(db): conn = sqlite3.connect(db_filename) tests = [ [None, 'embolalia', Identifier('Embolalia')], # Ensures case conversion is handled properly [None, '[][]', Identifier('[]{}')], # Unicode, just in case [None, 'embölaliå', Identifier('EmbölaliÅ')], ] for test in tests: test[0] = db.get_nick_id(test[2]) nick_id, slug, nick = test with conn: cursor = conn.cursor() registered = cursor.execute( 'SELECT nick_id, slug, canonical FROM nicknames WHERE canonical IS ?', [nick] ).fetchall() assert len(registered) == 1 assert registered[0][1] == slug and registered[0][2] == nick # Check that each nick ended up with a different id assert len(set(test[0] for test in tests)) == len(tests) # Check that the retrieval actually is idempotent for test in tests: nick_id = test[0] new_id = db.get_nick_id(test[2]) assert nick_id == new_id # Even if the case is different for test in tests: nick_id = test[0] new_id = db.get_nick_id(Identifier(test[2].upper())) assert nick_id == new_id def test_alias_nick(db): nick = 'Embolalia' aliases = ['EmbölaliÅ', 'Embo`work', 'Embo'] nick_id = db.get_nick_id(nick) for alias in aliases: db.alias_nick(nick, alias) for alias in aliases: assert db.get_nick_id(alias) == nick_id db.alias_nick('both', 'arenew') # Shouldn't fail. with pytest.raises(ValueError): db.alias_nick('Eve', nick) with pytest.raises(ValueError): db.alias_nick(nick, nick) def test_set_nick_value(db): conn = sqlite3.connect(db_filename) cursor = conn.cursor() nick = 'Embolalia' nick_id = db.get_nick_id(nick) data = { 'key': 'value', 'number_key': 1234, 'unicode': 'EmbölaliÅ', } def check(): for key, value in iteritems(data): db.set_nick_value(nick, key, value) for key, value in iteritems(data): found_value = cursor.execute( 'SELECT value FROM nick_values WHERE nick_id = ? AND key = ?', [nick_id, key] ).fetchone()[0] assert json.loads(unicode(found_value)) == value check() # Test updates data['number_key'] = 'not a number anymore!' data['unicode'] = 'This is different toö!' check() def test_get_nick_value(db): conn = sqlite3.connect(db_filename) cursor = conn.cursor() nick = 'Embolalia' nick_id = db.get_nick_id(nick) data = { 'key': 'value', 'number_key': 1234, 'unicode': 'EmbölaliÅ', } for key, value in iteritems(data): cursor.execute('INSERT INTO nick_values VALUES (?, ?, ?)', [nick_id, key, json.dumps(value, ensure_ascii=False)]) conn.commit() for key, value in iteritems(data): found_value = db.get_nick_value(nick, key) assert found_value == value def test_unalias_nick(db): conn = sqlite3.connect(db_filename) nick = 'Embolalia' nick_id = 42 conn.execute('INSERT INTO nicknames VALUES (?, ?, ?)', [nick_id, Identifier(nick).lower(), nick]) aliases = ['EmbölaliÅ', 'Embo`work', 'Embo'] for alias in aliases: conn.execute('INSERT INTO nicknames VALUES (?, ?, ?)', [nick_id, Identifier(alias).lower(), alias]) conn.commit() for alias in aliases: db.unalias_nick(alias) for alias in aliases: found = conn.execute( 'SELECT * FROM nicknames WHERE nick_id = ?', [nick_id]).fetchall() assert len(found) == 1 def test_delete_nick_group(db): conn = sqlite3.connect(db_filename) aliases = ['Embolalia', 'Embo'] nick_id = 42 for alias in aliases: conn.execute('INSERT INTO nicknames VALUES (?, ?, ?)', [nick_id, Identifier(alias).lower(), alias]) conn.commit() db.set_nick_value(aliases[0], 'foo', 'bar') db.set_nick_value(aliases[1], 'spam', 'eggs') db.delete_nick_group(aliases[0]) # Nothing else has created values, so we know the tables are empty nicks = conn.execute('SELECT * FROM nicknames').fetchall() assert len(nicks) == 0 data = conn.execute('SELECT * FROM nick_values').fetchone() assert data is None def test_merge_nick_groups(db): conn = sqlite3.connect(db_filename) aliases = ['Embolalia', 'Embo'] for nick_id, alias in enumerate(aliases): conn.execute('INSERT INTO nicknames VALUES (?, ?, ?)', [nick_id, Identifier(alias).lower(), alias]) conn.commit() finals = (('foo', 'bar'), ('bar', 'blue'), ('spam', 'eggs')) db.set_nick_value(aliases[0], finals[0][0], finals[0][1]) db.set_nick_value(aliases[0], finals[1][0], finals[1][1]) db.set_nick_value(aliases[1], 'foo', 'baz') db.set_nick_value(aliases[1], finals[2][0], finals[2][1]) db.merge_nick_groups(aliases[0], aliases[1]) nick_ids = conn.execute('SELECT nick_id FROM nicknames') nick_id = nick_ids.fetchone()[0] alias_id = nick_ids.fetchone()[0] assert nick_id == alias_id for key, value in finals: found = conn.execute( 'SELECT value FROM nick_values WHERE nick_id = ? AND key = ?', [nick_id, key]).fetchone()[0] assert json.loads(unicode(found)) == value def test_set_channel_value(db): conn = sqlite3.connect(db_filename) db.set_channel_value('#asdf', 'qwer', 'zxcv') result = conn.execute( 'SELECT value FROM channel_values WHERE channel = ? and key = ?', ['#asdf', 'qwer']).fetchone()[0] assert result == '"zxcv"' def test_get_channel_value(db): conn = sqlite3.connect(db_filename) conn.execute("INSERT INTO channel_values VALUES ('#asdf', 'qwer', '\"zxcv\"')") conn.commit() result = db.get_channel_value('#asdf', 'qwer') assert result == 'zxcv' def test_get_nick_or_channel_value(db): db.set_nick_value('asdf', 'qwer', 'poiu') db.set_channel_value('#asdf', 'qwer', '/.,m') assert db.get_nick_or_channel_value('asdf', 'qwer') == 'poiu' assert db.get_nick_or_channel_value('#asdf', 'qwer') == '/.,m' def test_get_preferred_value(db): db.set_nick_value('asdf', 'qwer', 'poiu') db.set_channel_value('#asdf', 'qwer', '/.,m') db.set_channel_value('#asdf', 'lkjh', '1234') names = ['asdf', '#asdf'] assert db.get_preferred_value(names, 'qwer') == 'poiu' assert db.get_preferred_value(names, 'lkjh') == '1234' sopel-6.6.9/test/test_formatting.py000066400000000000000000000030631347452002400174230ustar00rootroot00000000000000# coding=utf-8 """Tests for message formatting""" from __future__ import unicode_literals, absolute_import, print_function, division import pytest from sopel.formatting import colors, color, hex_color, bold, italic, underline, strikethrough, monospace, reverse def test_color(): text = 'Hello World' assert color(text) == text assert color(text, colors.PINK) == '\x0313' + text + '\x03' assert color(text, colors.PINK, colors.TEAL) == '\x0313,10' + text + '\x03' pytest.raises(ValueError, color, text, 100) pytest.raises(ValueError, color, text, 'INVALID') def test_hex_color(): text = 'Hello World' assert hex_color(text) == text assert hex_color(text, '369') == '\x04336699' + text + '\x04' assert hex_color(text, '246', '987654') == '\x04224466,987654' + text + '\x04' pytest.raises(ValueError, hex_color, text, 0x224466) pytest.raises(ValueError, hex_color, text, '1234') pytest.raises(ValueError, hex_color, text, 'sixchr') def test_bold(): text = 'Hello World' assert bold(text) == '\x02' + text + '\x02' def test_italic(): text = 'Hello World' assert italic(text) == '\x1d' + text + '\x1d' def test_underline(): text = 'Hello World' assert underline(text) == '\x1f' + text + '\x1f' def test_strikethrough(): text = 'Hello World' assert strikethrough(text) == '\x1e' + text + '\x1e' def test_monospace(): text = 'Hello World' assert monospace(text) == '\x11' + text + '\x11' def test_reverse(): text = 'Hello World' assert reverse(text) == '\x16' + text + '\x16' sopel-6.6.9/test/test_irc.py000066400000000000000000000067561347452002400160420ustar00rootroot00000000000000# coding=utf-8 """Tests for message formatting""" from __future__ import unicode_literals, absolute_import, print_function, division import pytest import asynchat import os import shutil import socket import tempfile import asyncore from sopel import irc from sopel.tools import Identifier import sopel.config as conf HOST = '127.0.0.1' SERVER_QUIT = 'QUIT' class BasicServer(asyncore.dispatcher): def __init__(self, address, handler): asyncore.dispatcher.__init__(self) self.response_handler = handler self.create_socket(socket.AF_INET, socket.SOCK_STREAM) self.bind(address) self.address = self.socket.getsockname() self.listen(1) return def handle_accept(self): # Called when a client connects to our socket client_info = self.accept() BasicHandler(sock=client_info[0], handler=self.response_handler) self.handle_close() return def handle_close(self): self.close() class BasicHandler(asynchat.async_chat): ac_in_buffer_size = 512 ac_out_buffer_size = 512 def __init__(self, sock, handler): self.received_data = [] asynchat.async_chat.__init__(self, sock) self.handler_function = handler self.set_terminator(b'\n') return def collect_incoming_data(self, data): self.received_data.append(data.decode('utf-8')) def found_terminator(self): self._process_command() def _process_command(self): command = ''.join(self.received_data) response = self.handler_function(self, command) self.push(':fake.server {}\n'.format(response).encode()) self.received_data = [] def start_server(rpl_function=None): def rpl_func(msg): print(msg) return msg if rpl_function is None: rpl_function = rpl_func address = ('localhost', 0) # let the kernel give us a port server = BasicServer(address, rpl_function) return server @pytest.fixture def bot(request): cfg_dir = tempfile.mkdtemp() print(cfg_dir) filename = tempfile.mkstemp(dir=cfg_dir)[1] os.mkdir(os.path.join(cfg_dir, 'modules')) def fin(): print('teardown config file') shutil.rmtree(cfg_dir) request.addfinalizer(fin) def gen(data): with open(filename, 'w') as fileo: fileo.write(data) cfg = conf.Config(filename) irc_bot = irc.Bot(cfg) irc_bot.config = cfg return irc_bot return gen def test_bot_init(bot): test_bot = bot( '[core]\n' 'owner=Baz\n' 'nick=Foo\n' 'user=Bar\n' 'name=Sopel\n' ) assert test_bot.nick == Identifier('Foo') assert test_bot.user == 'Bar' assert test_bot.name == 'Sopel' def basic_irc_replies(server, msg): if msg.startswith('NICK'): return '001 Foo :Hello' elif msg.startswith('USER'): # Quit here because good enough server.close() elif msg.startswith('PING'): return 'PONG{}'.format(msg.replace('PING', '', 1)) elif msg.startswith('CAP'): return 'CAP * :' elif msg.startswith('QUIT'): server.close() else: return '421 {} :Unknown command'.format(msg) def test_bot_connect(bot): test_bot = bot( '[core]\n' 'owner=Baz\n' 'nick=Foo\n' 'user=Bar\n' 'name=Sopel\n' 'host=127.0.0.1\n' 'timeout=10\n' ) s = start_server(basic_irc_replies) # Do main run test_bot.run(HOST, s.address[1]) sopel-6.6.9/test/test_module.py000066400000000000000000000112441347452002400165360ustar00rootroot00000000000000# coding=utf-8 """Tests for message formatting""" from __future__ import unicode_literals, absolute_import, print_function, division import pytest from sopel.trigger import PreTrigger, Trigger from sopel.test_tools import MockSopel, MockSopelWrapper from sopel.tools import Identifier from sopel import module @pytest.fixture def sopel(): bot = MockSopel('Sopel') bot.config.core.owner = 'Bar' return bot @pytest.fixture def bot(sopel, pretrigger): bot = MockSopelWrapper(sopel, pretrigger) bot.channels[Identifier('#Sopel')].privileges[Identifier('Foo')] = module.VOICE return bot @pytest.fixture def pretrigger(): line = ':Foo!foo@example.com PRIVMSG #Sopel :Hello, world' return PreTrigger(Identifier('Foo'), line) @pytest.fixture def pretrigger_pm(): line = ':Foo!foo@example.com PRIVMSG Sopel :Hello, world' return PreTrigger(Identifier('Foo'), line) @pytest.fixture def trigger_owner(bot): line = ':Bar!bar@example.com PRIVMSG #Sopel :Hello, world' return Trigger(bot.config, PreTrigger(Identifier('Bar'), line), None) @pytest.fixture def trigger(bot, pretrigger): return Trigger(bot.config, pretrigger, None) @pytest.fixture def trigger_pm(bot, pretrigger_pm): return Trigger(bot.config, pretrigger_pm, None) def test_unblockable(): @module.unblockable def mock(bot, trigger, match): return True assert mock.unblockable is True def test_interval(): @module.interval(5) def mock(bot, trigger, match): return True assert mock.interval == [5] def test_rule(): @module.rule('.*') def mock(bot, trigger, match): return True assert mock.rule == ['.*'] def test_thread(): @module.thread(True) def mock(bot, trigger, match): return True assert mock.thread is True def test_commands(): @module.commands('sopel') def mock(bot, trigger, match): return True assert mock.commands == ['sopel'] def test_nickname_commands(): @module.nickname_commands('sopel') def mock(bot, trigger, match): return True assert mock.nickname_commands == ['sopel'] def test_priority(): @module.priority('high') def mock(bot, trigger, match): return True assert mock.priority == 'high' def test_event(): @module.event('301') def mock(bot, trigger, match): return True assert mock.event == ['301'] def test_intent(): @module.intent('ACTION') def mock(bot, trigger, match): return True assert mock.intents == ['ACTION'] def test_rate(): @module.rate(5) def mock(bot, trigger, match): return True assert mock.rate == 5 def test_require_privmsg(bot, trigger, trigger_pm): @module.require_privmsg('Try again in a PM') def mock(bot, trigger, match=None): return True assert mock(bot, trigger) is not True assert mock(bot, trigger_pm) is True @module.require_privmsg def mock_(bot, trigger, match=None): return True assert mock_(bot, trigger) is not True assert mock_(bot, trigger_pm) is True def test_require_chanmsg(bot, trigger, trigger_pm): @module.require_chanmsg('Try again in a channel') def mock(bot, trigger, match=None): return True assert mock(bot, trigger) is True assert mock(bot, trigger_pm) is not True @module.require_chanmsg def mock_(bot, trigger, match=None): return True assert mock(bot, trigger) is True assert mock(bot, trigger_pm) is not True def test_require_privilege(bot, trigger): @module.require_privilege(module.VOICE) def mock_v(bot, trigger, match=None): return True assert mock_v(bot, trigger) is True @module.require_privilege(module.OP, 'You must be at least opped!') def mock_o(bot, trigger, match=None): return True assert mock_o(bot, trigger) is not True def test_require_admin(bot, trigger, trigger_owner): @module.require_admin('You must be an admin') def mock(bot, trigger, match=None): return True assert mock(bot, trigger) is not True @module.require_admin def mock_(bot, trigger, match=None): return True assert mock_(bot, trigger_owner) is True def test_require_owner(bot, trigger, trigger_owner): @module.require_owner('You must be an owner') def mock(bot, trigger, match=None): return True assert mock(bot, trigger) is not True @module.require_owner def mock_(bot, trigger, match=None): return True assert mock_(bot, trigger_owner) is True def test_example(bot, trigger): @module.commands('mock') @module.example('.mock', 'True') def mock(bot, trigger, match=None): return True assert mock(bot, trigger) is True sopel-6.6.9/test/test_regression.py000066400000000000000000000030211347452002400174230ustar00rootroot00000000000000# coding=utf-8 """Regression tests""" from __future__ import unicode_literals, absolute_import, print_function, division import pytest from sopel import coretasks from sopel.tools import Identifier from sopel.test_tools import MockSopel, MockSopelWrapper from sopel.trigger import PreTrigger, Trigger @pytest.fixture def sopel(): bot = MockSopel("Sopel") return bot @pytest.fixture def sopel_bot(sopel): pretrigger = PreTrigger(Identifier("Foo"), "PING abc") bot = MockSopelWrapper(sopel, pretrigger) bot.privileges = dict() bot.users = dict() return bot def test_bot_legacy_permissions(sopel_bot): """ Make sure permissions match after being updated from both RPL_NAMREPLY and RPL_WHOREPLY, #1482 """ nick = Identifier("Admin") # RPL_NAMREPLY pretrigger = PreTrigger("Foo", ":test.example.com 353 Foo = #test :Foo ~@Admin") trigger = Trigger(sopel_bot.config, pretrigger, None) coretasks.handle_names(sopel_bot, trigger) assert ( sopel_bot.channels["#test"].privileges[nick] == sopel_bot.privileges["#test"][nick] ) # RPL_WHOREPLY pretrigger = PreTrigger( "Foo", ":test.example.com 352 Foo #test ~Admin adminhost test.example.com Admin Hr~ :0 Admin", ) trigger = Trigger(sopel_bot.config, pretrigger, None) coretasks.recv_who(sopel_bot, trigger) assert ( sopel_bot.channels["#test"].privileges[nick] == sopel_bot.privileges["#test"][nick] ) assert sopel_bot.users.get(nick) is not None sopel-6.6.9/test/test_tools.py000066400000000000000000000045411347452002400164130ustar00rootroot00000000000000# coding=utf-8 """Tests sopel.tools""" from __future__ import unicode_literals, absolute_import, print_function, division from sopel import tools def test_get_sendable_message_default(): initial = 'aaaa' text, excess = tools.get_sendable_message(initial) assert text == initial assert excess == '' def test_get_sendable_message_limit(): initial = 'a' * 400 text, excess = tools.get_sendable_message(initial) assert text == initial assert excess == '' def test_get_sendable_message_excess(): initial = 'a' * 401 text, excess = tools.get_sendable_message(initial) assert text == 'a' * 400 assert excess == 'a' def test_get_sendable_message_excess_space(): # aaa...aaa bbb...bbb initial = ' '.join(['a' * 200, 'b' * 200]) text, excess = tools.get_sendable_message(initial) assert text == 'a' * 200 assert excess == 'b' * 200 def test_get_sendable_message_excess_space_limit(): # aaa...aaa bbb...bbb initial = ' '.join(['a' * 400, 'b' * 200]) text, excess = tools.get_sendable_message(initial) assert text == 'a' * 400 assert excess == 'b' * 200 def test_get_sendable_message_excess_bigger(): # aaa...aaa bbb...bbb initial = ' '.join(['a' * 401, 'b' * 1000]) text, excess = tools.get_sendable_message(initial) assert text == 'a' * 400 assert excess == 'a ' + 'b' * 1000 def test_get_sendable_message_optional(): text, excess = tools.get_sendable_message('aaaa', 3) assert text == 'aaa' assert excess == 'a' text, excess = tools.get_sendable_message('aaa bbb', 3) assert text == 'aaa' assert excess == 'bbb' text, excess = tools.get_sendable_message('aa bb cc', 3) assert text == 'aa' assert excess == 'bb cc' def test_get_sendable_message_two_bytes(): text, excess = tools.get_sendable_message('αααα', 4) assert text == 'αα' assert excess == 'αα' text, excess = tools.get_sendable_message('αααα', 5) assert text == 'αα' assert excess == 'αα' text, excess = tools.get_sendable_message('α ααα', 4) assert text == 'α' assert excess == 'ααα' text, excess = tools.get_sendable_message('αα αα', 4) assert text == 'αα' assert excess == 'αα' text, excess = tools.get_sendable_message('ααα α', 4) assert text == 'αα' assert excess == 'α α' sopel-6.6.9/test/test_trigger.py000066400000000000000000000207041347452002400167150ustar00rootroot00000000000000# coding=utf-8 """Tests for message parsing""" from __future__ import unicode_literals, absolute_import, print_function, division import re import pytest import datetime from sopel.test_tools import MockConfig from sopel.trigger import PreTrigger, Trigger from sopel.tools import Identifier @pytest.fixture def nick(): return Identifier('Sopel') def test_basic_pretrigger(nick): line = ':Foo!foo@example.com PRIVMSG #Sopel :Hello, world' pretrigger = PreTrigger(nick, line) assert pretrigger.tags == {} assert pretrigger.hostmask == 'Foo!foo@example.com' assert pretrigger.line == line assert pretrigger.args == ['#Sopel', 'Hello, world'] assert pretrigger.event == 'PRIVMSG' assert pretrigger.nick == Identifier('Foo') assert pretrigger.user == 'foo' assert pretrigger.host == 'example.com' assert pretrigger.sender == '#Sopel' def test_pm_pretrigger(nick): line = ':Foo!foo@example.com PRIVMSG Sopel :Hello, world' pretrigger = PreTrigger(nick, line) assert pretrigger.tags == {} assert pretrigger.hostmask == 'Foo!foo@example.com' assert pretrigger.line == line assert pretrigger.args == ['Sopel', 'Hello, world'] assert pretrigger.event == 'PRIVMSG' assert pretrigger.nick == Identifier('Foo') assert pretrigger.user == 'foo' assert pretrigger.host == 'example.com' assert pretrigger.sender == Identifier('Foo') def test_quit_pretrigger(nick): line = ':Foo!foo@example.com QUIT :quit message text' pretrigger = PreTrigger(nick, line) assert pretrigger.tags == {} assert pretrigger.hostmask == 'Foo!foo@example.com' assert pretrigger.line == line assert pretrigger.args == ['quit message text'] assert pretrigger.event == 'QUIT' assert pretrigger.nick == Identifier('Foo') assert pretrigger.user == 'foo' assert pretrigger.host == 'example.com' assert pretrigger.sender is None def test_join_pretrigger(nick): line = ':Foo!foo@example.com JOIN #Sopel' pretrigger = PreTrigger(nick, line) assert pretrigger.tags == {} assert pretrigger.hostmask == 'Foo!foo@example.com' assert pretrigger.line == line assert pretrigger.args == ['#Sopel'] assert pretrigger.event == 'JOIN' assert pretrigger.nick == Identifier('Foo') assert pretrigger.user == 'foo' assert pretrigger.host == 'example.com' assert pretrigger.sender == Identifier('#Sopel') def test_tags_pretrigger(nick): line = '@foo=bar;baz;sopel.chat/special=value :Foo!foo@example.com PRIVMSG #Sopel :Hello, world' pretrigger = PreTrigger(nick, line) assert pretrigger.tags == {'baz': None, 'foo': 'bar', 'sopel.chat/special': 'value'} assert pretrigger.hostmask == 'Foo!foo@example.com' assert pretrigger.line == line assert pretrigger.args == ['#Sopel', 'Hello, world'] assert pretrigger.event == 'PRIVMSG' assert pretrigger.nick == Identifier('Foo') assert pretrigger.user == 'foo' assert pretrigger.host == 'example.com' assert pretrigger.sender == '#Sopel' def test_intents_pretrigger(nick): line = '@intent=ACTION :Foo!foo@example.com PRIVMSG #Sopel :Hello, world' pretrigger = PreTrigger(nick, line) assert pretrigger.tags == {'intent': 'ACTION'} assert pretrigger.hostmask == 'Foo!foo@example.com' assert pretrigger.line == line assert pretrigger.args == ['#Sopel', 'Hello, world'] assert pretrigger.event == 'PRIVMSG' assert pretrigger.nick == Identifier('Foo') assert pretrigger.user == 'foo' assert pretrigger.host == 'example.com' assert pretrigger.sender == '#Sopel' def test_unusual_pretrigger(nick): line = 'PING' pretrigger = PreTrigger(nick, line) assert pretrigger.tags == {} assert pretrigger.hostmask is None assert pretrigger.line == line assert pretrigger.args == [] assert pretrigger.event == 'PING' def test_ctcp_intent_pretrigger(nick): line = ':Foo!foo@example.com PRIVMSG Sopel :\x01VERSION\x01' pretrigger = PreTrigger(nick, line) assert pretrigger.tags == {'intent': 'VERSION'} assert pretrigger.hostmask == 'Foo!foo@example.com' assert pretrigger.line == line assert pretrigger.args == ['Sopel', ''] assert pretrigger.event == 'PRIVMSG' assert pretrigger.nick == Identifier('Foo') assert pretrigger.user == 'foo' assert pretrigger.host == 'example.com' assert pretrigger.sender == Identifier('Foo') def test_ctcp_data_pretrigger(nick): line = ':Foo!foo@example.com PRIVMSG Sopel :\x01PING 1123321\x01' pretrigger = PreTrigger(nick, line) assert pretrigger.tags == {'intent': 'PING'} assert pretrigger.hostmask == 'Foo!foo@example.com' assert pretrigger.line == line assert pretrigger.args == ['Sopel', '1123321'] assert pretrigger.event == 'PRIVMSG' assert pretrigger.nick == Identifier('Foo') assert pretrigger.user == 'foo' assert pretrigger.host == 'example.com' assert pretrigger.sender == Identifier('Foo') def test_ircv3_extended_join_pretrigger(nick): line = ':Foo!foo@example.com JOIN #Sopel bar :Real Name' pretrigger = PreTrigger(nick, line) assert pretrigger.tags == {'account': 'bar'} assert pretrigger.hostmask == 'Foo!foo@example.com' assert pretrigger.line == line assert pretrigger.args == ['#Sopel', 'bar', 'Real Name'] assert pretrigger.event == 'JOIN' assert pretrigger.nick == Identifier('Foo') assert pretrigger.user == 'foo' assert pretrigger.host == 'example.com' assert pretrigger.sender == Identifier('#Sopel') def test_ircv3_extended_join_trigger(nick): line = ':Foo!foo@example.com JOIN #Sopel bar :Real Name' pretrigger = PreTrigger(nick, line) config = MockConfig() config.core.owner_account = 'bar' fakematch = re.match('.*', line) trigger = Trigger(config, pretrigger, fakematch) assert trigger.sender == '#Sopel' assert trigger.raw == line assert trigger.is_privmsg is False assert trigger.hostmask == 'Foo!foo@example.com' assert trigger.user == 'foo' assert trigger.nick == Identifier('Foo') assert trigger.host == 'example.com' assert trigger.event == 'JOIN' assert trigger.match == fakematch assert trigger.group == fakematch.group assert trigger.groups == fakematch.groups assert trigger.args == ['#Sopel', 'bar', 'Real Name'] assert trigger.account == 'bar' assert trigger.tags == {'account': 'bar'} assert trigger.owner is True assert trigger.admin is True def test_ircv3_intents_trigger(nick): line = '@intent=ACTION :Foo!foo@example.com PRIVMSG #Sopel :Hello, world' pretrigger = PreTrigger(nick, line) config = MockConfig() config.core.owner = 'Foo' config.core.admins = ['Bar'] fakematch = re.match('.*', line) trigger = Trigger(config, pretrigger, fakematch) assert trigger.sender == '#Sopel' assert trigger.raw == line assert trigger.is_privmsg is False assert trigger.hostmask == 'Foo!foo@example.com' assert trigger.user == 'foo' assert trigger.nick == Identifier('Foo') assert trigger.host == 'example.com' assert trigger.event == 'PRIVMSG' assert trigger.match == fakematch assert trigger.group == fakematch.group assert trigger.groups == fakematch.groups assert trigger.groupdict == fakematch.groupdict assert trigger.args == ['#Sopel', 'Hello, world'] assert trigger.tags == {'intent': 'ACTION'} assert trigger.admin is True assert trigger.owner is True def test_ircv3_account_tag_trigger(nick): line = '@account=Foo :Nick_Is_Not_Foo!foo@example.com PRIVMSG #Sopel :Hello, world' pretrigger = PreTrigger(nick, line) config = MockConfig() config.core.owner_account = 'Foo' config.core.admins = ['Bar'] fakematch = re.match('.*', line) trigger = Trigger(config, pretrigger, fakematch) assert trigger.admin is True assert trigger.owner is True def test_ircv3_server_time_trigger(nick): line = '@time=2016-01-09T03:15:42.000Z :Foo!foo@example.com PRIVMSG #Sopel :Hello, world' pretrigger = PreTrigger(nick, line) config = MockConfig() config.core.owner = 'Foo' config.core.admins = ['Bar'] fakematch = re.match('.*', line) trigger = Trigger(config, pretrigger, fakematch) assert trigger.time == datetime.datetime(2016, 1, 9, 3, 15, 42, 0) # Spec-breaking string line = '@time=2016-01-09T04:20 :Foo!foo@example.com PRIVMSG #Sopel :Hello, world' pretrigger = PreTrigger(nick, line) assert pretrigger.time is not None