pax_global_header00006660000000000000000000000064130074355440014517gustar00rootroot0000000000000052 comment=f6315f7a4090700431a7ee2295e881e9c4bc9c1b sopel-6.5.0/000077500000000000000000000000001300743554400126515ustar00rootroot00000000000000sopel-6.5.0/.coveragerc000066400000000000000000000011271300743554400147730ustar00rootroot00000000000000# Sample conf file from http://nedbatchelder.com/code/coverage/config.html [run] 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__.: [html] directory = coverage_html_report sopel-6.5.0/.gitignore000066400000000000000000000003741300743554400146450ustar00rootroot00000000000000build/ dist/ env/ MANIFEST doc/build/* logs/* tests sopel.wiki/* sopel.egg-info/* *.db *.pyc *.pyo *.txt *~ *.rpm *.spec .*.sw* .pid-* # IntelliJ idea .idea *.iml # Eclipse/PyDev .settings .project .pydevproject .cache .coverage # macOS *.DS_Store sopel-6.5.0/.gitmodules000066400000000000000000000001551300743554400150270ustar00rootroot00000000000000[submodule "docs/wiki"] path = docs/wiki url = git://github.com/embolalia/willie.wiki.git branch = master sopel-6.5.0/.travis.yml000066400000000000000000000007631300743554400147700ustar00rootroot00000000000000language: python python: - "2.7" - "3.4" git: submodules: false 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 - pip install coveralls - pip install flake8 script: - ./checkstyle.sh || true - coverage run --source sopel -m py.test -v . --ignore=sopel.py - coverage report --show-missing after_success: coveralls sopel-6.5.0/CONTRIBUTING.md000066400000000000000000000070551300743554400151110ustar00rootroot00000000000000Submitting 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.5.0/COPYING000066400000000000000000000017761300743554400137170ustar00rootroot00000000000000 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.5.0/CREDITS000066400000000000000000000015211300743554400136700ustar00rootroot00000000000000This 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.5.0/MANIFEST.in000066400000000000000000000000321300743554400144020ustar00rootroot00000000000000include *requirements.txt sopel-6.5.0/NEWS000066400000000000000000000572411300743554400133610ustar00rootroot00000000000000Changes 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; a replacement can be found on PyPI as 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 priliveges 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 http://willie.dftba.net/willie_6.html 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 http://willie.dftba.net/willie_6.html 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 http://willie.dftba.net/willie_6.html 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 http://sopel.chat/willie_6.html Module changes (for users): * 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 http://sopel.chat/willie_6.html API changes (for developers): * 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, simmilar 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 http://willie.dftba.net/willie_6.html Core changes (for users): * 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 http://willie.dftba.net/willie_6.html Module changes (for users): * 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 (for users): * DB nick group merging now works properly * A few combinations of authentication configuration that were broken are fixed API changes (for developers): * 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 (for users): * 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 (for developers): * 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 (for users): * An exception that failed the chanlogs module from loading is fixed * Meetbot meeting subjects no longer fail with unicode Core changes (for users): * 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 (for developers): * 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 (for users): * 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 (for users): * help_prefix can now be given in [core] to change the command prefix used in help API changes (for developers): * 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 http://willie.dftba.net/willie_5.html for more information. Module changes (for users): * 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 (for users): * 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 (for developers): * 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 http://willie.dftba.net/willie_5.html for more information. Module changes (for users): * Due to API deprecation and instability, .g now uses DuckDuckGo instead of Google Core changes (for users): * Fix remaining regression in db update function Changes between 4.6.0 and 4.6.1 =============================== This release starts preparations for Willie 5. See http://willie.dftba.net/willie_5.html for more information. Module changes (for users): * Fix regression in table creations and erroneously changed column names Core changes (for users): * Fix regression in db update function API changes (for developers): * Correctly print out deprecation warnings Changes between 4.5.1 and 4.6.0 =============================== This release starts preparations for Willie 5. See http://willie.dftba.net/willie_5.html for more information. Module Changes (for users): * 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 (for users): * 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 (for developers): * Numerous functions were added to the db to ease the transition to Willie 5 * tools.Nick has been renamed 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 (for users): * 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 (for users): * 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 (for users): * Scheduled tasks now work properly under Python 3 * Willie no longer accepts partial matches for admin status API Changes (for developers): * 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 (for users): * 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 (for users): * Ping timeout handling is working again * bind_host configuration option is working again Changes between 4.3.0 and 4.4.0 =============================== Module Changes (for users): * .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 bugfixed Core changes (for users): * 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 (for developers): * 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 (for users): * A new channel logging module is added * Misc bugfixes, especially when running with Python 3 Core changes (for users): * Fixed a regression that caused numerous errors in willie.web * Misc bugfixes API Changes (for developers): * 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 (for users): * 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 (for users): * 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 (for developers): * 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 optinal notice parameter for bot.reply for easier sending of IRC NOTICE messages Changes between 4.0.1 and 4.1.0 =============================== Module Changes (for users): * 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 (for users): * 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 (for developers): * 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 (for users): * 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 (for users): * 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 no 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 (for users): * 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 (for developers): * 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.5.0/README.rst000066400000000000000000000072731300743554400143510ustar00rootroot00000000000000|version| |downloads| |license| |build| |issues| |forks| |stars| |ages| |works| |badges| 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 ===================== If you're on Arch, the easiest way to install is through your package manager. The package is named ``sopel`` in [community] repository. On other distros, and pretty much any operating system you can run Python on, you can install `pip `_, and do ``pip install sopel``. Failing all that, you can download the latest tarball from http://sopel.chat 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 extra modules are available in the `sopel-extras `_ repository, but of course you can also write new modules. A `tutorial `_ for creating new modules is available on the wiki. API documentation can be found online at http://sopel.chat/docs, or you can create a local version by running ``make html`` in the ``doc`` directory. Further documentation --------------------- In addition to the `official website `_, there is also a `wiki `_ which includes valuable information including a full listing of `commands `_. Questions? ---------- Join us in `#sopel `_ on Freenode. .. |status| image:: https://travis-ci.org/sopel-irc/sopel.svg :target: https://travis-ci.org/sopel-irc/sopel .. |coverage-status| image:: https://coveralls.io/repos/sopel-irc/sopel/badge.png :target: https://coveralls.io/r/sopel-irc/sopel .. |version| image:: https://img.shields.io/pypi/v/sopel.svg :target: https://pypi.python.org/pypi/sopel .. |downloads| image:: https://img.shields.io/pypi/dm/sopel.svg :target: https://pypi.python.org/pypi/sopel .. |license| image:: https://img.shields.io/pypi/l/sopel.svg :target: https://github.com/sopel-irc/sopel/blob/master/COPYING .. |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 .. |forks| image:: https://img.shields.io/github/forks/sopel-irc/sopel.svg :target: https://github.com/sopel-irc/sopel/network .. |stars| image:: https://img.shields.io/github/stars/sopel-irc/sopel.svg :target: https://github.com/sopel-irc/sopel/stargazers .. |ages| image:: https://img.shields.io/badge/ages-12%2B-green.svg .. |works| image:: https://img.shields.io/badge/works-usually-yellow.svg .. |badges| image:: https://img.shields.io/badge/badges-10-green.svg sopel-6.5.0/checkstyle.sh000077500000000000000000000037441300743554400153560ustar00rootroot00000000000000#!/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 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.5.0/ci_build.sh000077500000000000000000000010301300743554400147540ustar00rootroot00000000000000#!/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 willie test clean $SUDO pip3 install -r dev-requirements.txt python3 pytest_run.py willie test ./checkstyle.sh sopel-6.5.0/conftest.py000066400000000000000000000002021300743554400150420ustar00rootroot00000000000000# This file lists files which should be ignored by pytest collect_ignore = ["setup.py", "willie.py", "willie/modules/ipython.py"] sopel-6.5.0/contrib/000077500000000000000000000000001300743554400143115ustar00rootroot00000000000000sopel-6.5.0/contrib/README000066400000000000000000000013441300743554400151730ustar00rootroot00000000000000This 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.5.0/contrib/release.sh000077500000000000000000000016441300743554400162750ustar00rootroot00000000000000#!/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.5.0/contrib/rpm/000077500000000000000000000000001300743554400151075ustar00rootroot00000000000000sopel-6.5.0/contrib/rpm/makerpm.py000077500000000000000000000023231300743554400171200ustar00rootroot00000000000000#!/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.5.0/contrib/rpm/sopel.spec.in000066400000000000000000000045131300743554400175150ustar00rootroot00000000000000%{!?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: http://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.5.0/contrib/sopel.cfg000066400000000000000000000011321300743554400161110ustar00rootroot00000000000000#Default sopel configuration file for Fedora #For information related to possible configuration values see # https://github.com/sopel-irc/sopel/wiki/Core-configuration-settings # https://github.com/sopel-irc/sopel/wiki/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.5.0/contrib/sopel.conf000066400000000000000000000001021300743554400162730ustar00rootroot00000000000000# Sopel temporary directory setup d /run/sopel 0755 sopel sopel - sopel-6.5.0/contrib/sopel.service000066400000000000000000000005101300743554400170110ustar00rootroot00000000000000[Unit] Description=Sopel IRC bot Documentation=http://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.5.0/contrib/suppress-warnings.py000066400000000000000000000002201300743554400203670ustar00rootroot00000000000000# coding=utf-8 # suppress-warnings.py # Suppress iPython's DeprecationWarnings on Sopel start import warnings warnings.filterwarnings('ignore') sopel-6.5.0/contrib/update_db.py000077500000000000000000000040651300743554400166220ustar00rootroot00000000000000#!/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.5.0/dev-requirements.txt000066400000000000000000000000171300743554400167070ustar00rootroot00000000000000pytest ipython sopel-6.5.0/docs/000077500000000000000000000000001300743554400136015ustar00rootroot00000000000000sopel-6.5.0/docs/Makefile000066400000000000000000000130111300743554400152350ustar00rootroot00000000000000# 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.5.0/docs/make.bat000066400000000000000000000120231300743554400152040ustar00rootroot00000000000000@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.5.0/docs/source/000077500000000000000000000000001300743554400151015ustar00rootroot00000000000000sopel-6.5.0/docs/source/api.rst000066400000000000000000000016551300743554400164130ustar00rootroot00000000000000Additional 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.5.0/docs/source/bot.rst000066400000000000000000000005211300743554400164150ustar00rootroot00000000000000The 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.5.0/docs/source/conf.py000066400000000000000000000175671300743554400164200ustar00rootroot00000000000000# -*- 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 IRC Bot' copyright = u'2012-2015, Elsie Powell, et al.' # 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 = {} # 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 = None # 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 = True # 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'Elsie Powell, et al.', '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'Elsie Powell, et al.'], 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'Elsie Powell, et al.', 'SopelIRCBot', 'One line description of project.', '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.5.0/docs/source/config.rst000066400000000000000000000005261300743554400171030ustar00rootroot00000000000000Configuration 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.5.0/docs/source/db.rst000066400000000000000000000001151300743554400162150ustar00rootroot00000000000000The bot's database ================== .. automodule:: sopel.db :members: sopel-6.5.0/docs/source/index.rst000066400000000000000000000012071300743554400167420ustar00rootroot00000000000000.. 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 `_ * `Wiki `_ .. 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.5.0/docs/source/plugin.rst000066400000000000000000000060721300743554400171360ustar00rootroot00000000000000Plugin 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.5.0/docs/source/trigger.rst000066400000000000000000000001061300743554400172730ustar00rootroot00000000000000Triggers ======== .. autoclass:: sopel.trigger.Trigger :members: sopel-6.5.0/docs/wiki/000077500000000000000000000000001300743554400145445ustar00rootroot00000000000000sopel-6.5.0/docs/willie.man000066400000000000000000000064161300743554400155720ustar00rootroot00000000000000.\" 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 usefull (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.5.0/pytest.ini000066400000000000000000000001561300743554400147040ustar00rootroot00000000000000[pytest] # willie/modules/ files contain tests python_files=*.py addopts = --tb=short norecursedirs = contrib sopel-6.5.0/pytest_run.py000077500000000000000000000010321300743554400154360ustar00rootroot00000000000000#!/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 willie version. pytest_run.py Copyright 2013, Ari Koivula, Licensed under the Eiffel Forum License 2. http://sopel.chat """ from __future__ import unicode_literals if __name__ == "__main__": import sys import pytest returncode = pytest.main() sys.exit(returncode) sopel-6.5.0/requirements.txt000066400000000000000000000000761300743554400161400ustar00rootroot00000000000000xmltodict pytz praw pyenchant pygeoip requests>=2.0.0,<2.11.0 sopel-6.5.0/setup.py000077500000000000000000000036761300743554400144020ustar00rootroot00000000000000#!/usr/bin/env python # coding=utf-8 from __future__ import unicode_literals, print_function 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='http://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, entry_points={'console_scripts': ['sopel = sopel.run_script:main']}, ) sopel-6.5.0/sopel.py000077500000000000000000000003051300743554400143460ustar00rootroot00000000000000#!/usr/bin/env python3 # coding=utf-8 from __future__ import absolute_import # Different from setuptools script, because we want the one in this dir. from sopel import run_script run_script.main() sopel-6.5.0/sopel/000077500000000000000000000000001300743554400137735ustar00rootroot00000000000000sopel-6.5.0/sopel/__init__.py000066400000000000000000000067311300743554400161130ustar00rootroot00000000000000# 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.5.0' 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: stderr('Got quit signal, shutting down.') p.quit('Closing') while True: 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) sopel.logger.setup_logging(p) p.run(config.core.host, int(config.core.port)) except KeyboardInterrupt: break except Exception: trace = traceback.format_exc() try: stderr(trace) except: 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.5.0/sopel/bot.py000066400000000000000000000651021300743554400151350ustar00rootroot00000000000000# 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.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): self.shutdown_methods = shutdowns for callbl in callables: for rule in callbl.rule: self._callables[callbl.priority][rule].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. """ # We're arbitrarily saying that the max is 400 bytes of text when # messages will be split. Otherwise, we'd have to acocunt for the bot's # hostmask, which is hard. max_text_length = 400 # Encode to bytes, for propper length calculation if isinstance(text, unicode): encoded_text = text.encode('utf-8') else: encoded_text = text excess = '' if max_messages > 1 and len(encoded_text) > max_text_length: last_space = encoded_text.rfind(' '.encode('utf-8'), 0, max_text_length) if last_space == -1: excess = encoded_text[max_text_length:] encoded_text = encoded_text[:max_text_length] else: excess = encoded_text[last_space + 1:] encoded_text = encoded_text[:last_space] # We'll then send the excess at the end # Back to unicode again, so we don't screw things up later. text = encoded_text.decode('utf-8') 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 = 0.8 + penalty 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: 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') and trigger.tags.get('intent') not in func.intents): 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 ) ) 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.5.0/sopel/config/000077500000000000000000000000001300743554400152405ustar00rootroot00000000000000sopel-6.5.0/sopel/config/__init__.py000066400000000000000000000232771300743554400173640ustar00rootroot00000000000000# 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') 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: print("Encountered an error while writing the config file." + " This shouldn't happen. Check permissions.") raise sys.exit(1) print("Config file written sucessfully!") sopel-6.5.0/sopel/config/core_section.py000066400000000000000000000165421300743554400202760ustar00rootroot00000000000000# 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(): certs = '/etc/pki/tls/cert.pem' if not os.path.isfile(certs): certs = '/etc/ssl/certs/ca-certificates.crt' if not os.path.isfile(certs): return None return certs 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.""" auth_method = ChoiceAttribute('auth_method', choices=[ 'nickserv', 'authserv', 'Q', 'sasl', 'server']) """The method to use to authenticate with the server. Can be ``nickserv``, ``authserv``, ``Q``, ``sasl``, or ``server``.""" 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') """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=True) """Whether a log of raw lines as sent and recieved 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: http://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.5.0/sopel/config/types.py000066400000000000000000000313751300743554400167670ustar00rootroot00000000000000# 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 try: import configparser except ImportError: import ConfigParser as configparser 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 NotImplemented("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 NotImplemented("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 = 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 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 OSError: raise ValueError("Value must be an existant 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.5.0/sopel/coretasks.py000066400000000000000000000604011300743554400163440ustar00rootroot00000000000000# 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 )) @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('(#\S*)', trigger.raw) if not channels: return channel = Identifier(channels.group(1)) if channel not in bot.privileges: bot.privileges[channel] = dict() # This could probably be made flexible in the future, but I don't think # it'd be worth it. 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 @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: *( ( "-" / "+" ) * * ) channel = Identifier(trigger.args[0]) line = trigger.args[1:] # 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. if channel.is_nick(): return mapping = {'v': sopel.module.VOICE, 'h': sopel.module.HALFOP, 'o': sopel.module.OP, 'a': sopel.module.ADMIN, 'q': sopel.module.OWNER} modes = [] for arg in line: if len(arg) == 0: continue if arg[0] in '+-': # There was a comment claiming IRC allows e.g. MODE +aB-c foo, but # I don't see it in any RFCs. Leaving in the extra parsing for now. sign = '' modes = [] for char in arg: if char == '+' or char == '-': sign = char else: modes.append(sign + char) else: arg = Identifier(arg) for mode in modes: priv = bot.privileges[channel].get(arg, 0) value = mapping.get(mode[1]) if value is not None: if mode[0] == '+': priv = priv | value else: priv = priv & ~value bot.privileges[channel][arg] = 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)) @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)) # Spec says we do a base 64 encode on the SASL stuff bot.write(('AUTHENTICATE', base64.b64encode(sasl_token.encode('utf-8')))) @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. https://github.com/sopel-irc/sopel/wiki/Making-Sopel-ignore-people """ 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 _record_who(bot, channel, user, host, nick, account, away) def _record_who(bot, channel, user, host, nick, account=None, away=None): nick = Identifier(nick) channel = Identifier(channel) if nick not in bot.users: bot.users[nick] = User(nick, user, host) user = bot.users[nick] if account == '0': user.account = None else: user.account = account user.away = away if channel not in bot.channels: bot.channels[channel] = Channel(channel) bot.channels[channel].add_user(user) @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, = trigger.args[1:6] _record_who(bot, channel, user, host, nick) @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.5.0/sopel/db.py000066400000000000000000000223531300743554400147370ustar00rootroot00000000000000# 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: 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.""" # Do nothing if the db already exists. try: self.execute('SELECT * FROM nick_ids;') self.execute('SELECT * FROM nicknames;') self.execute('SELECT * FROM nick_values;') self.execute('SELECT * FROM channel_values;') except: pass else: return self.execute( 'CREATE TABLE nick_ids (nick_id INTEGER PRIMARY KEY AUTOINCREMENT)' ) self.execute( 'CREATE TABLE nicknames ' '(nick_id INTEGER REFERENCES nick_ids, ' 'slug STRING PRIMARY KEY, canonical string)' ) self.execute( 'CREATE TABLE nick_values ' '(nick_id INTEGER REFERENCES nick_ids(nick_id), ' 'key STRING, value STRING, ' 'PRIMARY KEY (nick_id, key))' ) self.execute( 'CREATE TABLE 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): 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.5.0/sopel/formatting.py000066400000000000000000000053151300743554400165230ustar00rootroot00000000000000# 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 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_UNDERLINE = '\x1f' """The control code to start or end underlining""" CONTROL_BOLD = '\x02' """The control code to start or end bold 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 `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 bold(text): """Return the text, with bold IRC formatting.""" return ''.join([CONTROL_BOLD, text, CONTROL_BOLD]) def underline(text): """Return the text, with underline IRC formatting.""" return ''.join([CONTROL_UNDERLINE, text, CONTROL_UNDERLINE]) sopel-6.5.0/sopel/irc.py000066400000000000000000000372511300743554400151320ustar00rootroot00000000000000# 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: self.writing_lock.acquire() # Blocking lock, can't send two things # at a time # 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. if text is not None: temp = (' '.join(args) + ' :' + text)[:510] + '\r\n' else: temp = ' '.join(args)[:510] + '\r\n' 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) 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.""" 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. self.close() def handle_connect(self): 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) try: ssl.match_hostname(self.ssl.getpeercert(), self.config.core.host) except ssl.CertificateError: stderr("Invalid certficate, hostname mismatch!") 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')) 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) 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 _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: # 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 recieved 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.5.0/sopel/loader.py000066400000000000000000000170351300743554400156210ustar00rootroot00000000000000# 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 itervalues, get_command_regexp if sys.version_info.major >= 3: basestring = (str, bytes) # Can be implementation-dependent _regex_type = type(re.compile('')) 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: 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 compile_rule(nick, pattern): # Not sure why this happens on reloads, but it shouldn't cause problems… if isinstance(pattern, _regex_type): return pattern 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 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 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) for rule in func.rule] if hasattr(func, 'commands'): func.rule = getattr(func, 'rule', []) for command in func.commands: regexp = get_command_regexp(prefix, command) 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 = help_prefix + example[len(help_prefix):] if doc or example: for command in func.commands: func._docs[command] = (doc, example) 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', 'rule', 'intent', '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.5.0/sopel/logger.py000066400000000000000000000034021300743554400156230ustar00rootroot00000000000000# 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: self.handleError(record) class ChannelOutputFormatter(logging.Formatter): def __init__(self): super(ChannelOutputFormatter, self).__init__( fmt='[%(filename)s] %(msg)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.5.0/sopel/module.py000066400000000000000000000375651300743554400156520ustar00rootroot00000000000000# 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 supresses 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 rety a failed command immediately. .. versionadded:: 4.0 """ VOICE = 1 HALFOP = 2 OP = 4 ADMIN = 8 OWNER = 16 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, seperated 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, seperated 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 have never have any additional parameters, as the command would match the rest of the line. """ def add_attribute(function): if not hasattr(function, "rule"): function.rule = [] rule = 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='|'.join(command_list)) function.rule.append(rule) 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.privileges[trigger.sender] 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. Add an example attribute into a function and generate a test. """ # TODO dat doc doe >_< def __init__(self, msg, result=None, privmsg=False, admin=False, owner=False, repeat=1, re=False, ignore=None): """Accepts arguments for the decorator. Args: msg - The example message to give to the function as input. result - Resulting output from calling the function with msg. 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 - Bool. Make the message appear to have come from an admin. owner - Bool. Make the message appear to have come from an owner. repeat - How many times to repeat the test. Usefull for tests that return random stuff. re - Bool. If true, result is interpreted as a regular expression. ignore - a list of outputs to ignore. """ # 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 = [] if 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' ) record = { "example": self.msg, "result": self.result, "privmsg": self.privmsg, "admin": self.admin, } func.example.append(record) return func sopel-6.5.0/sopel/modules/000077500000000000000000000000001300743554400154435ustar00rootroot00000000000000sopel-6.5.0/sopel/modules/__init__.py000066400000000000000000000001421300743554400175510ustar00rootroot00000000000000# coding=utf-8 from __future__ import unicode_literals, absolute_import, print_function, division sopel-6.5.0/sopel/modules/admin.py000066400000000000000000000156371300743554400171210ustar00rootroot00000000000000# 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. http://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): 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 sopels 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. arg1 = trigger.group(3).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 # Display current value if no value is given. value = trigger.group(4) if not value: 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 # Otherwise, set the value to one given as argument 2. 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 sopels config object to the configuration file.""" bot.config.save() sopel-6.5.0/sopel/modules/adminchannel.py000066400000000000000000000174361300743554400204510ustar00rootroot00000000000000# 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.privileges[trigger.sender][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.privileges[trigger.sender][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.privileges[trigger.sender][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.privileges[trigger.sender][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.privileges[trigger.sender][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.privileges[trigger.sender][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.privileges[trigger.sender][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.5.0/sopel/modules/announce.py000066400000000000000000000012721300743554400176250ustar00rootroot00000000000000# 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.5.0/sopel/modules/bugzilla.py000066400000000000000000000055751300743554400176420ustar00rootroot00000000000000# 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 xmltodict from sopel import web, 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): 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)' '(/show_bug.cgi\?\S*?)' '(id=\d+)') % domains) bot.memory['url_callbacks'][regex] = show_bug def shutdown(bot): del bot.memory['url_callbacks'][regex] @rule(r'.*https?://(\S+?)' '(/show_bug.cgi\?\S*?)' '(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 = web.get(url, dont_decode=True) 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.5.0/sopel/modules/calc.py000066400000000000000000000034611300743554400167230ustar00rootroot00000000000000# coding=utf-8 """ calc.py - Sopel Calculator Module Copyright 2008, Sean B. Palmer, inamidst.com Licensed under the Eiffel Forum License 2. http://sopel.chat """ from __future__ import unicode_literals, absolute_import, print_function, division from sopel import web from sopel.module import commands, example from sopel.tools.calculation import eval_equation import sys if sys.version_info.major >= 3: unichr = chr BASE_TUMBOLIA_URI = 'https://tumbolia-two.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 = web.get(uri + web.quote(query)) 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.5.0/sopel/modules/clock.py000066400000000000000000000215251300743554400171150ustar00rootroot00000000000000# 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 http://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): config.define_section('clock', TimeSection) config.clock.configure_setting( 'tz', 'Preferred time zone (http://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 http://sopel.chat/tz """ 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 " "http://sopel.chat/tz") return if tz not in pytz.all_timezones: bot.reply("I don't know that time zone. Try one from " "http://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 http://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 http://strftime.net 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: 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 time zone for the channel. """ if bot.privileges[trigger.sender][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 " "http://sopel.chat/tz") return if tz not in pytz.all_timezones: bot.reply("I don't know that time zone. Try one from " "http://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 http://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 preferred channel timezone, or the current channel timezone if no channel 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 http://strftime.net or your favorite search engine to learn more. """ if bot.privileges[trigger.sender][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: 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.5.0/sopel/modules/countdown.py000066400000000000000000000025341300743554400200410ustar00rootroot00000000000000# coding=utf-8 """ countdown.py - Sopel Countdown Module Copyright 2011, Michael Yanovich, yanovich.net Licensed under the Eiffel Forum License 2. http://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: 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.5.0/sopel/modules/currency.py000066400000000000000000000070471300743554400176570ustar00rootroot00000000000000# 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 json import xmltodict import re from sopel import web 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 = 'http://www.bankofcanada.ca/stats/assets/rates_rss/noon/en_{}.xml' 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': rates = json.loads(web.get('https://api.bitcoinaverage.com/ticker/all')) return 1 / rates['CAD']['24h_avg'], 'Bitcoin—24hr average' data, headers = web.get(base_url.format(code), dont_decode=True, return_headers=True) if headers['_http_status'] == 404: return False, False namespaces = { 'http://www.cbwiki.net/wiki/index.php/Specification_1.1': 'cb', 'http://purl.org/rss/1.0/': None, 'http://www.w3.org/1999/02/22-rdf-syntax-ns#': 'rdf' } xml = xmltodict.parse(data, process_namespaces=True, namespaces=namespaces).get('rdf:RDF') namestring = xml.get('channel').get('title').get('#text') name = namestring[len('Bank of Canada noon rate: '):] name = re.sub(r'\s*\(noon\)\s*', '', name) rate = xml.get('item').get('cb:statistics').get('cb:exchangeRate').get('cb:value').get('#text') return float(rate), 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: bot.reply("Sorry, I didn't understand the input.") 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: bot.reply("Something went wrong while I was getting the exchange rate.") return NOLIMIT result = amount / of_rate * to_rate bot.say("{} {} ({}) = {} {} ({})".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: bot.reply("Sorry, I didn't understand the input.") return NOLIMIT display(bot, amount, 'BTC', to) sopel-6.5.0/sopel/modules/dice.py000066400000000000000000000205571300743554400167320ustar00rootroot00000000000000# 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. http://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", 'You roll 2d4: \(\d\+\d\) = \d', re=True) @sopel.module.example(".roll 100d1", '[^:]*: \(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))) # Showing the actual error will hopefully give a better hint of what is # wrong with the syntax than a generic error message. try: result = eval_equation(eval_str) except Exception as e: bot.reply("SyntaxError, eval(%s), %s" % (eval_str, e)) 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 # 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.5.0/sopel/modules/etymology.py000066400000000000000000000052241300743554400200500ustar00rootroot00000000000000# coding=utf-8 """ etymology.py - Sopel Etymology Module Copyright 2007-9, Sean B. Palmer, inamidst.com Licensed under the Eiffel Forum License 2. http://sopel.chat """ from __future__ import unicode_literals, absolute_import, print_function, division import re from sopel import web from sopel.module import commands, example, NOLIMIT etyuri = 'http://etymonline.com/?term=%s' etysearch = 'http://etymonline.com/?search=%s' r_definition = re.compile(r'(?ims)]*>.*?') r_tag = re.compile(r'<(?!!)[^>]+>') r_whitespace = re.compile(r'[\t\r\n ]+') abbrs = [ 'cf', 'lit', 'etc', 'Ger', 'Du', 'Skt', 'Rus', 'Eng', 'Amer.Eng', 'Sp', 'Fr', 'N', 'E', 'S', 'W', 'L', 'Gen', 'J.C', 'dial', 'Gk', '19c', '18c', '17c', '16c', 'St', 'Capt', 'obs', 'Jan', 'Feb', 'Mar', 'Apr', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', 'c', 'tr', 'e', 'g' ] t_sentence = r'^.*?(?') s = s.replace('<', '<') s = s.replace('&', '&') return s def text(html): html = r_tag.sub('', html) html = r_whitespace.sub(' ', html) return unescape(html).strip() 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]) word = {'axe': 'ax/axe'}.get(word, word) bytes = web.get(etyuri % word) definitions = r_definition.findall(bytes) if not definitions: return None defn = text(definitions[0]) m = r_sentence.match(defn) if not m: return None sentence = m.group(0) maxlength = 275 if len(sentence) > maxlength: sentence = sentence[:maxlength] words = sentence[:-5].split(' ') words.pop() sentence = ' '.join(words) + ' [...]' sentence = '"' + sentence.replace('"', "'") + '"' return sentence + ' - ' + (etyuri % 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 % word) bot.msg(trigger.sender, msg) return NOLIMIT except (AttributeError, TypeError): result = None if result is not None: bot.msg(trigger.sender, result) else: uri = etysearch % word msg = 'Can\'t find the etymology for "%s". Try %s' % (word, uri) bot.msg(trigger.sender, msg) return NOLIMIT sopel-6.5.0/sopel/modules/find.py000066400000000000000000000116431300743554400167420ustar00rootroot00000000000000# 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.5.0/sopel/modules/find_updates.py000066400000000000000000000034451300743554400204700ustar00rootroot00000000000000# 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 = 'http://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.5.0/sopel/modules/help.py000066400000000000000000000101461300743554400167470ustar00rootroot00000000000000# coding=utf-8 """ help.py - Sopel Help Module Copyright 2008, Sean B. Palmer, inamidst.com Copyright © 2013, Elad Alfassa, Licensed under the Eiffel Forum License 2. http://sopel.chat """ from __future__ import unicode_literals, absolute_import, print_function, division import textwrap import collections import json import requests from sopel.logger import get_logger from sopel.module import commands, rule, example, priority logger = get_logger(__name__) @rule('$nick' '(?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-gist' in bot.memory and bot.memory['command-gist'][0] == len(bot.command_groups): url = bot.memory['command-gist'][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 = ' '.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_gist(bot, '\n\n'.join(msgs)) if not url: return bot.memory['command-gist'] = (len(bot.command_groups), url) bot.say("I've posted a list of my commands at {} - You can see " "more info about any of these commands by doing .help " " (e.g. .help time)".format(url)) def create_gist(bot, msg): payload = { 'description': 'Command listing for {}@{}'.format(bot.nick, bot.config.core.host), 'public': 'true', 'files': { 'commands.txt': { "content": msg, }, }, } try: result = requests.post('https://api.github.com/gists', data=json.dumps(payload)) except requests.RequestException: bot.say("Sorry! Something went wrong.") logger.exception("Error posting commands gist") return if not result.status_code != '201': bot.say("Sorry! Something went wrong.") logger.error("Error %s posting commands gist: %s", result.status_code, result.text) return result = result.json() if 'html_url' not in result: bot.say("Sorry! Something went wrong.") logger.error("Invalid result %s", result) return return result['html_url'] @rule('$nick' r'(?i)help(?:[?!]+)?$') @priority('low') def help2(bot, trigger): response = ( 'Hi, I\'m a bot. Say ".commands" to me in private for a list ' + 'of my commands, or see http://sopel.chat for more ' + 'general details. My owner is %s.' ) % bot.config.core.owner bot.reply(response) sopel-6.5.0/sopel/modules/ip.py000066400000000000000000000120111300743554400164200ustar00rootroot00000000000000# 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 pygeoip import socket import os import gzip 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 from sopel.config.types import StaticSection, FilenameAttribute from sopel.module import commands, example from sopel.logger import get_logger 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): config.define_section('ip', GeoipSection) config.ip.configure_setting('GeoIP_db_path', 'Path of the GeoIP db files') def setup(bot=None): if not bot: return # Because of some weird pytest thing? bot.config.define_section('ip', GeoipSection) def _decompress(source, target, delete_after_decompression=True): """ Decompress a GZip file """ f_in = gzip.open(source, 'rb') f_out = open(target, 'wb') f_out.writelines(f_in) f_out.close() f_in.close() 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, 'GeoLiteCity.dat') ipasnum_db = os.path.join(config.ip.GeoIP_db_path, 'GeoIPASNum.dat') 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(bot.config.core.homedir, 'GeoLiteCity.dat')) and os.path.isfile(os.path.join(bot.config.core.homedir, 'GeoIPASNum.dat'))): return bot.config.core.homedir elif (os.path.isfile(os.path.join('/usr/share/GeoIP', 'GeoLiteCity.dat')) and os.path.isfile(os.path.join('/usr/share/GeoIP', 'GeoIPASNum.dat'))): return '/usr/share/GeoIP' elif urlretrieve: LOGGER.warning('Downloading GeoIP database') bot.say('Downloading GeoIP database, please wait...') geolite_city_url = 'http://geolite.maxmind.com/download/geoip/database/GeoLiteCity.dat.gz' geolite_ASN_url = 'http://download.maxmind.com/download/geoip/database/asnum/GeoIPASNum.dat.gz' geolite_city_filepath = os.path.join(bot.config.core.homedir, 'GeoLiteCity.dat.gz') geolite_ASN_filepath = os.path.join(bot.config.core.homedir, 'GeoIPASNum.dat.gz') urlretrieve(geolite_city_url, geolite_city_filepath) urlretrieve(geolite_ASN_url, geolite_ASN_filepath) _decompress(geolite_city_filepath, geolite_city_filepath[:-3]) _decompress(geolite_ASN_filepath, geolite_ASN_filepath[:-3]) return bot.config.core.homedir else: return False @commands('iplookup', 'ip') @example('.ip 8.8.8.8', r'[IP/Host Lookup] Hostname: google-public-dns-a.google.com | Location: United States | Region: CA | ISP: AS15169 Google Inc.', re=True, ignore='Downloading GeoIP database, please wait...') def ip(bot, trigger): """IP Lookup tool""" if not trigger.group(2): return bot.reply("No search term.") query = trigger.group(2) 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 geolite_city_filepath = os.path.join(_find_geoip_db(bot), 'GeoLiteCity.dat') geolite_ASN_filepath = os.path.join(_find_geoip_db(bot), 'GeoIPASNum.dat') gi_city = pygeoip.GeoIP(geolite_city_filepath) gi_org = pygeoip.GeoIP(geolite_ASN_filepath) host = socket.getfqdn(query) response = "[IP/Host Lookup] Hostname: %s" % host try: response += " | Location: %s" % gi_city.country_name_by_name(query) except AttributeError: response += ' | Location: Unknown' except socket.gaierror: return bot.say('[IP/Host Lookup] Unable to resolve IP/Hostname') region_data = gi_city.region_by_name(query) try: region = region_data['region_code'] # pygeoip >= 0.3.0 except KeyError: region = region_data['region_name'] # pygeoip < 0.3.0 if region: response += " | Region: %s" % region isp = gi_org.org_by_name(query) response += " | ISP: %s" % isp bot.say(response) if __name__ == "__main__": from sopel.test_tools import run_example_tests run_example_tests(__file__) sopel-6.5.0/sopel/modules/ipython.py000066400000000000000000000044741300743554400175200ustar00rootroot00000000000000# coding=utf-8 """ ipython.py - sopel ipython console! Copyright © 2014, Elad Alfassa Licensed under the Eiffel Forum License 2. Sopel: http://sopel.chat/ """ from __future__ import unicode_literals, absolute_import, print_function, division import sopel 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.5.0/sopel/modules/isup.py000066400000000000000000000017351300743554400170030ustar00rootroot00000000000000# 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 from sopel import web from sopel.module import commands @commands('isup') def isup(bot, trigger): """isup.me website status checker""" site = trigger.group(2) 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 = web.get(site) 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.5.0/sopel/modules/lmgtfy.py000066400000000000000000000011171300743554400173170ustar00rootroot00000000000000# 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. http://sopel.chat/ """ from __future__ import unicode_literals, absolute_import, print_function, division from sopel.module import commands @commands('lmgtfy', 'lmgify', 'gify', 'gtfy') def googleit(bot, trigger): """Let me just... google that for you.""" #No input if not trigger.group(2): return bot.say('http://google.com/') bot.say('http://lmgtfy.com/?q=' + trigger.group(2).replace(' ', '+')) sopel-6.5.0/sopel/modules/meetbot.py000066400000000000000000000405561300743554400174660ustar00rootroot00000000000000# 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.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): 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: 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: return False #Start meeting (also preforms all required sanity checks) @commands('startmeeting') @example('.startmeeting title or .startmeeting') def startmeeting(bot, trigger): """ Start a meeting. https://github.com/sopel-irc/sopel/wiki/Using-the-meetbot-module """ 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: 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('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. https://github.com/sopel-irc/sopel/wiki/Using-the-meetbot-module """ 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('Current subject: ' + trigger.group(2)) #End the meeting @commands('endmeeting') @example('.endmeeting') def endmeeting(bot, trigger): """ End a meeting. https://github.com/sopel-irc/sopel/wiki/Using-the-meetbot-module """ 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("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. https://github.com/sopel-irc/sopel/wiki/Using-the-meetbot-module """ 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('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 https://github.com/sopel-irc/sopel/wiki/Using-the-meetbot-module """ 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('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('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. https://github.com/sopel-irc/sopel/wiki/Using-the-meetbot-module """ 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('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. https://github.com/sopel-irc/sopel/wiki/Using-the-meetbot-module """ 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: title = '' logplain('LINK: %s [%s]' % (link, title), trigger.sender) logHTML_listitem('%s' % (link, title), trigger.sender) bot.say('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 https://github.com/sopel-irc/sopel/wiki/Using-the-meetbot-module """ 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('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> ` https://github.com/sopel-irc/sopel/wiki/Using-the-meetbot-module """ 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. https://github.com/sopel-irc/sopel/wiki/Using-the-meetbot-module """ 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.5.0/sopel/modules/movie.py000066400000000000000000000034421300743554400171370ustar00rootroot00000000000000# coding=utf-8 """ imdb.py - Sopel Movie Information Module Copyright © 2012-2013, Elad Alfassa, Licensed under the Eiffel Forum License 2. This module relies on omdbapi.com """ from __future__ import unicode_literals, absolute_import, print_function, division import requests import sopel.module from sopel.logger import get_logger LOGGER = get_logger(__name__) @sopel.module.commands('movie', 'imdb') @sopel.module.example('.movie ThisTitleDoesNotExist', '[MOVIE] Movie not found!') @sopel.module.example('.movie Citizen Kane', '[MOVIE] Title: Citizen Kane | Year: 1941 | Rating: 8.4 | Genre: Drama, Mystery | IMDB Link: http://imdb.com/title/tt0033467') def movie(bot, trigger): """ Returns some information about a movie, like Title, Year, Rating, Genre and IMDB Link. """ if not trigger.group(2): return word = trigger.group(2).rstrip() uri = "http://www.omdbapi.com/" data = requests.get(uri, params={'t': word}, timeout=30, verify=bot.config.core.verify_ssl).json() if data['Response'] == 'False': if 'Error' in data: message = '[MOVIE] %s' % data['Error'] else: LOGGER.warning( 'Got an error from the OMDb api, search phrase was %s; data was %s', word, str(data)) message = '[MOVIE] Got an error from OMDbapi' else: message = '[MOVIE] Title: ' + data['Title'] + \ ' | Year: ' + data['Year'] + \ ' | Rating: ' + data['imdbRating'] + \ ' | Genre: ' + data['Genre'] + \ ' | IMDB Link: http://imdb.com/title/' + data['imdbID'] bot.say(message) if __name__ == "__main__": from sopel.test_tools import run_example_tests run_example_tests(__file__) sopel-6.5.0/sopel/modules/ping.py000066400000000000000000000013741300743554400167570ustar00rootroot00000000000000# coding=utf-8 """ ping.py - Sopel Ping Module Author: Sean B. Palmer, inamidst.com About: http://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.5.0/sopel/modules/rand.py000066400000000000000000000025571300743554400167520ustar00rootroot00000000000000# coding=utf-8 """ rand.py - Rand Module Copyright 2013, Ari Koivula, Licensed under the Eiffel Forum License 2. http://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.5.0/sopel/modules/reddit.py000066400000000000000000000145711300743554400173000ustar00rootroot00000000000000# 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\.|np\.)?reddit\.com' post_url = '%s/r/(.*?)/comments/([\w-]+)' % domain user_url = '%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): r = praw.Reddit(user_agent=USER_AGENT) match = match or trigger 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) match = match or trigger try: u = r.get_redditor(match.group(2)) except: 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 + ' | 13Cake day' if commanded: message = message + ' | http://reddit.com/u/' + u.name if u.is_gold: message = message + ' | 08Gold' if u.is_mod: message = message + ' | 05Mod' 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.privileges[trigger.sender][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.5.0/sopel/modules/reload.py000066400000000000000000000075171300743554400172750ustar00rootroot00000000000000# coding=utf-8 """ reload.py - Sopel Module Reloader Module Copyright 2008, Sean B. Palmer, inamidst.com Licensed under the Eiffel Forum License 2. http://sopel.chat """ from __future__ import unicode_literals, absolute_import, print_function, division import collections import sys import time from sopel.tools import iteritems import sopel.loader import sopel.module import subprocess @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) bot.setup() return bot.reply('done') if name not in sys.modules: return bot.reply('%s: not loaded, try the `load` command' % name) old_module = sys.modules[name] old_callables = {} for obj_name, obj in iteritems(vars(old_module)): bot.unregister(obj) # 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 if hasattr(old_module, "setup"): delattr(old_module, "setup") modules = sopel.loader.enumerate_modules(bot.config) path, type_ = modules[name] load_module(bot, name, path, type_) def load_module(bot, name, path, type_): 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)) 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.5.0/sopel/modules/remind.py000066400000000000000000000151751300743554400173040ustar00rootroot00000000000000# coding=utf-8 """ remind.py - Sopel Reminder Module Copyright 2011, Sean B. Palmer, inamidst.com Licensed under the Eiffel Forum License 2. http://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: 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('(\d+(?:\.\d+)? ?(?:(?i)' + periods + ')) ?', trigger.group(2))[1:]) reminder = '' stop = False for piece in message: grp = re.match('(\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 http://sopel.chat/tz . 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 timzeone 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, 'UTC') 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.5.0/sopel/modules/safety.py000066400000000000000000000157611300743554400173220ustar00rootroot00000000000000# 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 import sopel.web as web 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 json import time import os.path import re 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): 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('http://mirror1.malwaredomains.com/files/justdomains', path) @sopel.module.rule('(?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 netloc = urlparse(trigger.group(1)).netloc 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']: result = web.post(vt_base_api_url + 'report', payload) if sys.version_info.major > 2: result = result.decode('utf-8') result = json.loads(result) 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 Exception: LOGGER.debug('Error from checking URL with VT.', 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.privileges[trigger.sender][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.5.0/sopel/modules/search.py000066400000000000000000000071471300743554400172730ustar00rootroot00000000000000# 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 from sopel import web from sopel.module import commands, example import json import sys if sys.version_info.major < 3: from urllib import quote_plus else: from urllib.parse import quote_plus 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('!', '') uri = 'http://duckduckgo.com/html/?q=%s&kl=uk-en' % query bytes = web.get(uri) 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: return web.decode(m.group(1)) # 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' # This fixes issue #885 (https://github.com/sopel-irc/sopel/issues/885) # It seems that duckduckgo api redirects to its Instant answer API html page # if the query constains special charactares that aren't urlencoded. # So in order to always get a JSON response back the query is urlencoded query = quote_plus(query) uri = 'http://api.duckduckgo.com/?q=%s&format=json&no_html=1&no_redirect=1' % query results = json.loads(web.get(uri)) if results['Redirect']: return results['Redirect'] else: return None @commands('duck', 'ddg', 'g') @example('.duck privacy or .duck !mcwiki obsidian') 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: bot.reply("No results found for '%s'." % query) @commands('search') @example('.search nerdfighter') 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') 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) uri = 'http://websitedev.de/temp-bin/suggest.pl?q=' answer = web.get(uri + query.replace('+', '%2B')) if answer: bot.say(answer) else: bot.reply('Sorry, no result.') sopel-6.5.0/sopel/modules/seen.py000066400000000000000000000042611300743554400167520ustar00rootroot00000000000000# 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. http://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.5.0/sopel/modules/spellcheck.py000066400000000000000000000035411300743554400201350ustar00rootroot00000000000000# coding=utf-8 """ spellcheck.py - Sopel spell check Module Copyright © 2012, Elad Alfassa, Copyright © 2012, Lior Ramati Licensed under the Eiffel Forum License 2. http://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.5.0/sopel/modules/tell.py000066400000000000000000000126141300743554400167610ustar00rootroot00000000000000# coding=utf-8 """ tell.py - Sopel Tell and Ask Module Copyright 2008, Sean B. Palmer, inamidst.com Licensed under the Eiffel Forum License 2. http://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: 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) > 20: 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 == remkey: reminders.extend(getReminders(bot, channel, remkey, tellee)) elif tellee.startswith(remkey.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.5.0/sopel/modules/tld.py000066400000000000000000000054651300743554400166120ustar00rootroot00000000000000# coding=utf-8 """ tld.py - Sopel TLD Module Copyright 2009-10, Michael Yanovich, yanovich.net Licensed under the Eiffel Forum License 2. http://sopel.chat """ from __future__ import unicode_literals, absolute_import, print_function, division from sopel import web from sopel.module import commands, example 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 = web.get(uri) tld = trigger.group(2) 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]) bot.reply(reply) 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)) bot.reply(reply) sopel-6.5.0/sopel/modules/translate.py000066400000000000000000000146611300743554400200220ustar00rootroot00000000000000# 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. http://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 = "http://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,') data = json.loads(result) if raw: return str(data), 'en-raw' try: language = data[2] # -2][0][0] except: language = '?' return ''.join(x[0] for x in data[0]), language @rule(u'$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 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 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: 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: phrase = False if not phrase: phrase = backup break try: phrase = translate(phrase[0], lang, 'en', verify_ssl=verify_ssl) except: 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.5.0/sopel/modules/unicode_info.py000066400000000000000000000031061300743554400204560ustar00rootroot00000000000000# 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 unicodedata import sys from sopel.module import commands, example, NOLIMIT if sys.version_info.major >= 3: unichr = chr @commands('u') @example('.u ‽', 'U+203D INTERROBANG (‽)') @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 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: bot.reply("That's not a valid code point.") return NOLIMIT # Get the hex value for the code point, and drop the 0x from the front point = str(hex(ord(u'' + arg)))[2:] # Make the hex 4 characters long with preceding 0s, and all upper case point = point.rjust(4, str('0')).upper() try: name = unicodedata.name(arg) except ValueError: return 'U+%s (No name found)' % point if not unicodedata.combining(arg): template = 'U+%s %s (%s)' else: template = 'U+%s %s (\xe2\x97\x8c%s)' bot.say(template % (point, name, arg)) if __name__ == "__main__": from sopel.test_tools import run_example_tests run_example_tests(__file__) sopel-6.5.0/sopel/modules/units.py000066400000000000000000000135101300743554400171570ustar00rootroot00000000000000# 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('(-?[0-9]*\.?[0-9]*)[ °]*(K|C|F)', re.IGNORECASE) find_length = re.compile('([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('([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) bot.reply("{:.2f}°C = {:.2f}°F = {:.2f}K".format(celsius, fahrenheit, kelvin)) @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.5.0/sopel/modules/uptime.py000066400000000000000000000014201300743554400173150ustar00rootroot00000000000000# coding=utf-8 """ uptime.py - Uptime module Copyright 2014, Fabian Neundorf Licensed under the Eiffel Forum License 2. http://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.5.0/sopel/modules/url.py000066400000000000000000000203341300743554400166210ustar00rootroot00000000000000# 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/{} (http://sopel.chat)'.format(__version__) default_headers = {'User-Agent': USER_AGENT} url_finder = 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='!') def configure(config): 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' ) def setup(bot=None): global url_finder # TODO figure out why this is needed, and get rid of it, because really? if not bot: return 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() url_finder = re.compile(r'(?u)(%s?(?:http|https|ftp)(?:://\S+))' % (bot.config.url.exclusion_char), re.IGNORECASE) @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 = re.findall(url_finder, trigger) results = process_urls(bot, trigger, urls) for title, domain in results[:4]: bot.reply('[ %s ] - %s' % (title, domain)) @rule('(?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 = re.findall(url_finder, trigger) if len(urls) == 0: return results = process_urls(bot, trigger, urls) bot.memory['last_seen_url'][trigger.sender] = urls[-1] for title, domain in results[:4]: message = '[ %s ] - %s' % (title, domain) # 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 = [] 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: pass # First, check that the URL we got doesn't match matched = check_callbacks(bot, trigger, url, False) if matched: continue # Finally, actually show the URL title = find_title(url, verify=bot.config.core.verify_ssl) if title: results.append((title, get_hostname(url))) 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.""" response = requests.get(url, stream=True, verify=verify, headers=default_headers) try: 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') finally: # need to close the connexion because we have not read all the data response.close() # 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.find('') end = content.find('') 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 if __name__ == "__main__": from sopel.test_tools import run_example_tests run_example_tests(__file__) sopel-6.5.0/sopel/modules/version.py000066400000000000000000000044361300743554400175110ustar00rootroot00000000000000# coding=utf-8 """ version.py - Sopel Version Module Copyright 2009, Silas Baronda Copyright 2014, Dimitri Molenaars Licensed under the Eiffel Forum License 2. http://sopel.chat """ from __future__ import unicode_literals, absolute_import, print_function, division from datetime import datetime import sopel import re from os import path log_line = re.compile('\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 @sopel.module.commands('version') def version(bot, trigger): """Display the latest commit version, if Sopel is running in a git repo.""" release = sopel.__version__ 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(sopel.__version__, sha)) @sopel.module.intent('VERSION') @sopel.module.rate(20) @sopel.module.rule('.*') def ctcp_version(bot, trigger): print('wat') bot.write(('NOTICE', trigger.nick), '\x01VERSION Sopel IRC Bot version %s\x01' % sopel.__version__) @sopel.module.rule('\x01SOURCE\x01') @sopel.module.rate(20) def ctcp_source(bot, trigger): bot.write(('NOTICE', trigger.nick), '\x01SOURCE https://github.com/sopel-irc/sopel/\x01') @sopel.module.rule('\x01PING\s(.*)\x01') @sopel.module.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)) @sopel.module.rule('\x01TIME\x01') @sopel.module.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.5.0/sopel/modules/weather.py000066400000000000000000000141011300743554400174510ustar00rootroot00000000000000# coding=utf-8 # Copyright 2008, 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 from sopel import web from sopel.module import commands, example, NOLIMIT import xmltodict def woeid_search(query): """ Find the first Where On Earth ID for the given query. Result is the etree node for the result, so that location data can still be retrieved. Returns None if there is no result, or the woeid field is empty. """ query = 'q=select * from geo.places where text="%s"' % query body = web.get('http://query.yahooapis.com/v1/public/yql?' + query, dont_decode=True) parsed = xmltodict.parse(body).get('query') results = parsed.get('results') if results is None or results.get('place') is None: return None if type(results.get('place')) is list: return results.get('place')[0] return results.get('place') def get_cover(parsed): try: condition = parsed['channel']['item']['yweather:condition'] except KeyError: return 'unknown' text = condition['@text'] # code = int(condition['code']) # TODO parse code to get those little icon thingies. return text def get_temp(parsed): try: condition = parsed['channel']['item']['yweather:condition'] temp = int(condition['@temp']) except (KeyError, ValueError): return 'unknown' f = round((temp * 1.8) + 32, 2) return (u'%d\u00B0C (%d\u00B0F)' % (temp, f)) def get_humidity(parsed): try: humidity = parsed['channel']['yweather:atmosphere']['@humidity'] except (KeyError, ValueError): return 'unknown' return "Humidity: %s%%" % humidity def get_wind(parsed): try: wind_data = parsed['channel']['yweather:wind'] kph = float(wind_data['@speed']) m_s = float(round(kph / 3.6, 1)) speed = int(round(kph / 1.852, 0)) degrees = int(wind_data['@direction']) except (KeyError, ValueError): return 'unknown' if speed < 1: description = 'Calm' elif speed < 4: description = 'Light air' elif speed < 7: description = 'Light breeze' elif speed < 11: description = 'Gentle breeze' elif speed < 16: description = 'Moderate breeze' elif speed < 22: description = 'Fresh breeze' elif speed < 28: description = 'Strong breeze' elif speed < 34: description = 'Near gale' elif speed < 41: description = 'Gale' elif speed < 48: description = 'Strong gale' elif speed < 56: description = 'Storm' elif speed < 64: description = 'Violent storm' else: description = 'Hurricane' if (degrees <= 22.5) or (degrees > 337.5): degrees = u'\u2193' elif (degrees > 22.5) and (degrees <= 67.5): degrees = u'\u2199' elif (degrees > 67.5) and (degrees <= 112.5): degrees = u'\u2190' elif (degrees > 112.5) and (degrees <= 157.5): degrees = u'\u2196' elif (degrees > 157.5) and (degrees <= 202.5): degrees = u'\u2191' elif (degrees > 202.5) and (degrees <= 247.5): degrees = u'\u2197' elif (degrees > 247.5) and (degrees <= 292.5): degrees = u'\u2192' elif (degrees > 292.5) and (degrees <= 337.5): degrees = u'\u2198' return description + ' ' + str(m_s) + 'm/s (' + degrees + ')' @commands('weather', 'wea') @example('.weather London') def weather(bot, trigger): """.weather location - Show the weather at the given location.""" location = trigger.group(2) woeid = '' if not location: woeid = bot.db.get_nick_value(trigger.nick, 'woeid') if not woeid: return bot.msg(trigger.sender, "I don't know where you live. " + 'Give me a location, like .weather London, or tell me where you live by saying .setlocation London, for example.') else: location = location.strip() woeid = bot.db.get_nick_value(location, 'woeid') if woeid is None: first_result = woeid_search(location) if first_result is not None: woeid = first_result.get('woeid') if not woeid: return bot.reply("I don't know where that is.") query = 'q=select * from weather.forecast where woeid="%s" and u=\'c\'' % woeid body = web.get('http://query.yahooapis.com/v1/public/yql?' + query, dont_decode=True) parsed = xmltodict.parse(body).get('query') results = parsed.get('results') if results is None: return bot.reply("No forecast available. Try a more specific location.") location = results.get('channel').get('title') cover = get_cover(results) temp = get_temp(results) humidity = get_humidity(results) wind = get_wind(results) bot.say(u'%s: %s, %s, %s, %s' % (location, cover, temp, humidity, wind)) @commands('setlocation', 'setwoeid') @example('.setlocation Columbus, OH') def update_woeid(bot, trigger): """Set your default weather location.""" if not trigger.group(2): bot.reply('Give me a location, like "Washington, DC" or "London".') return NOLIMIT first_result = woeid_search(trigger.group(2)) if first_result is None: return bot.reply("I don't know where that is.") woeid = first_result.get('woeid') bot.db.set_nick_value(trigger.nick, 'woeid', woeid) neighborhood = first_result.get('locality2') or '' if neighborhood: neighborhood = neighborhood.get('#text') + ', ' city = first_result.get('locality1') or '' # This is to catch cases like 'Bawlf, Alberta' where the location is # thought to be a "LocalAdmin" rather than a "Town" if city: city = city.get('#text') else: city = first_result.get('name') state = first_result.get('admin1').get('#text') or '' country = first_result.get('country').get('#text') or '' bot.reply('I now have you at WOEID %s (%s%s, %s, %s)' % (woeid, neighborhood, city, state, country)) sopel-6.5.0/sopel/modules/wikipedia.py000066400000000000000000000103451300743554400177660ustar00rootroot00000000000000# 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 web, tools from sopel.config.types import StaticSection, ValidatedAttribute from sopel.module import NOLIMIT, commands, example, rule import json import re import sys if sys.version_info.major < 3: from urlparse import unquote as _unquote unquote = lambda s: _unquote(s.encode('utf-8')).decode('utf-8') else: from urllib.parse import 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): 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 = ('http://%s/w/api.php?format=json&action=query' '&list=search&srlimit=%d&srprop=timestamp&srwhat=text' '&srsearch=') % (server, num) search_url += query query = json.loads(web.get(search_url)) if 'query' in query: query = query['query']['search'] return [r['title'] for r in query] else: return None def say_snippet(bot, server, query, show_url=True): page_name = query.replace('_', ' ') query = query.replace(' ', '_') snippet = mw_snippet(server, query) msg = '[WIKIPEDIA] {} | "{}"'.format(page_name, snippet) if show_url: msg = msg + ' | https://{}/wiki/{}'.format(server, query) 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 = json.loads(web.get(snippet_url)) 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('.*/([a-z]+\.wikipedia.org)/wiki/([^ ]+).*') 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, 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 + '):(\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, server, query) sopel-6.5.0/sopel/modules/wiktionary.py000066400000000000000000000055051300743554400202220ustar00rootroot00000000000000# coding=utf-8 """ wiktionary.py - Sopel Wiktionary Module Copyright 2009, Sean B. Palmer, inamidst.com Licensed under the Eiffel Forum License 2. http://sopel.chat """ from __future__ import unicode_literals, absolute_import, print_function, division import re from sopel import web from sopel.module import commands, example uri = 'http://en.wiktionary.org/w/index.php?title=%s&printable=yes' r_tag = re.compile(r'<[^>]+>') r_ul = re.compile(r'(?ims)
      .*?
    ') def text(html): text = r_tag.sub('', html).strip() text = text.replace('\n', ' ') text = text.replace('\r', '') text = text.replace('(intransitive', '(intr.') text = text.replace('(transitive', '(trans.') return text def wikt(word): bytes = web.get(uri % web.quote(word)) 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="' 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.5.0/sopel/modules/xkcd.py000066400000000000000000000077111300743554400167540ustar00rootroot00000000000000# 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 google_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 = 'http://xkcd.com/{}/info.0.json'.format(number) else: url = 'http://xkcd.com/info.0.json' data = requests.get(url, verify=verify_ssl).json() data['url'] = 'http://xkcd.com/' + str(data['num']) return data def google(query): url = google_search(query + sites_query) if not url: return None match = re.match('(?: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): 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('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.5.0/sopel/run_script.py000077500000000000000000000174031300743554400165450ustar00rootroot00000000000000#!/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. http://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="Supress 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('http://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.5.0/sopel/test_tools.py000066400000000000000000000136641300743554400165560ustar00rootroot00000000000000# 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.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" self.channels = ["#channel"] 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): 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. Usefull 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 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.5.0/sopel/tools/000077500000000000000000000000001300743554400151335ustar00rootroot00000000000000sopel-6.5.0/sopel/tools/__init__.py000066400000000000000000000252211300743554400172460ustar00rootroot00000000000000# 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 = ('#', '&', '+', '!') 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 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) # This regexp match equivalently and produce 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. pattern = 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) return re.compile(pattern, re.IGNORECASE | re.VERBOSE) 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.""" # 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, Identifier): return self._lowered < other._lowered return self._lowered < Identifier._lower(other) def __le__(self, other): if isinstance(other, Identifier): return self._lowered <= other._lowered return self._lowered <= Identifier._lower(other) def __gt__(self, other): if isinstance(other, Identifier): return self._lowered > other._lowered return self._lowered > Identifier._lower(other) def __ge__(self, other): if isinstance(other, Identifier): return self._lowered >= other._lowered return self._lowered >= Identifier._lower(other) def __eq__(self, other): if isinstance(other, Identifier): return self._lowered == other._lowered return self._lowered == Identifier._lower(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: 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 ``Sopel.SopelMemory`` 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.5.0/sopel/tools/_events.py000066400000000000000000000130561300743554400171550ustar00rootroot00000000000000# 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.5.0/sopel/tools/calculation.py000066400000000000000000000160451300743554400200110ustar00rootroot00000000000000# 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.5.0/sopel/tools/jobs.py000066400000000000000000000166701300743554400164540ustar00rootroot00000000000000# 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: # 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: 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.5.0/sopel/tools/target.py000066400000000000000000000055241300743554400170010ustar00rootroot00000000000000# 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 ``Identifier``\s to ``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 username ``Identifier``\s to channel objects.""" self.privileges = {} """The permissions of the users in the channel. This maps username ``Identifier``s to bitwise integer values. This can be compared to appropriate constants from ``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 != None: user.channels.pop(self.name, None) def add_user(self, user): assert isinstance(user, User) self.users[user.nick] = user self.privileges[user.nick] = 0 user.channels[self.name] = self def rename_user(self, old, new): if old in self.users: self.users[new] = self.users.pop(old) 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.5.0/sopel/tools/time.py000066400000000000000000000121731300743554400164470ustar00rootroot00000000000000# 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: 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: 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.5.0/sopel/trigger.py000066400000000000000000000166621300743554400160230ustar00rootroot00000000000000# 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 # TODO note what this is doing and why if line.startswith(':'): self.hostmask, line = line[1:].split(' ', 1) else: self.hostmask = None # TODO note what this is doing and why 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.5.0/sopel/web.py000066400000000000000000000160521300743554400151260ustar00rootroot00000000000000# 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/{} (http://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): """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() headers = tmp.update(headers) 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): """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() headers = tmp.update(headers) 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): """Execute an HTTP POST query. Deprecated. `uri` is the target URI, and `query` is the POST data. `headers` is a dict of HTTP headers to send with the request. `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): """Return an HTTPResponse object for `uri` and `timeout` and `headers`. Deprecated """ if headers is None: headers = default_headers else: tmp = default_headers.copy() headers = tmp.update(headers) 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.5.0/test/000077500000000000000000000000001300743554400136305ustar00rootroot00000000000000sopel-6.5.0/test/test_config.py000066400000000000000000000016651300743554400165160ustar00rootroot00000000000000# 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): attr = types.ValidatedAttribute('attr') class ConfigFunctionalTest(unittest.TestCase): def read_config(self): configo = config.Config(self.filename) configo.define_section('fake', FakeConfigSection) return configo def setUp(self): self.filename = tempfile.mkstemp()[1] with open(self.filename, 'w') as fileo: fileo.write( "[core]\n" "owner=embolalia" ) self.config = self.read_config() def tearDown(self): os.remove(self.filename) def test_validated_string_when_none(self): self.config.fake.attr = None self.assertEquals(self.config.fake.attr, None) sopel-6.5.0/test/test_db.py000066400000000000000000000170761300743554400156410ustar00rootroot00000000000000# 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 from __future__ import absolute_import 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.5.0/test/test_formatting.py000066400000000000000000000013251300743554400174140ustar00rootroot00000000000000# 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, bold, underline 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_bold(): text = 'Hello World' assert bold(text) == '\x02' + text + '\x02' def test_underline(): text = 'Hello World' assert underline(text) == '\x1f' + text + '\x1f' sopel-6.5.0/test/test_irc.py000066400000000000000000000067601300743554400160270ustar00rootroot00000000000000# coding=utf8 """Tests for message formatting""" from __future__ import unicode_literals import pytest import asynchat import os import shutil import socket import select import tempfile import threading import time import asyncore from sopel import irc from sopel.tools import stderr, 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.5.0/test/test_module.py000066400000000000000000000124501300743554400165300ustar00rootroot00000000000000# 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.privileges = dict() bot.privileges[Identifier('#Sopel')] = dict() bot.privileges[Identifier('#Sopel')][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_nick_commands(): @module.nickname_commands('sopel') def mock(bot, trigger, match): return True assert mock.rule == [""" ^ $nickname[:,]? # Nickname. \s+(sopel) # 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. """] 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.5.0/test/test_trigger.py000066400000000000000000000207041300743554400167070ustar00rootroot00000000000000# 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