From 8ca625d3394d8acbd3b9b5b8b5fcc44dd04b5470 Mon Sep 17 00:00:00 2001 From: Jeremy Fincher Date: Wed, 19 Jan 2005 13:14:38 +0000 Subject: [PATCH] Initial import. --- ACKS | 7 + BUGS | 10 + ChangeLog | 1480 +++++++++++++++++++++++++++++++ DEVS | 67 ++ INSTALL | 110 +++ LICENSE | 28 + README | 38 + RELNOTES | 257 ++++++ plugins/Admin/__init__.py | 46 + plugins/Admin/config.py | 48 + plugins/Admin/plugin.py | 349 ++++++++ plugins/Admin/test.py | 117 +++ plugins/Channel/__init__.py | 49 + plugins/Channel/config.py | 82 ++ plugins/Channel/plugin.py | 822 +++++++++++++++++ plugins/Channel/test.py | 190 ++++ plugins/Config/__init__.py | 47 + plugins/Config/config.py | 48 + plugins/Config/plugin.py | 270 ++++++ plugins/Config/test.py | 83 ++ plugins/Misc/__init__.py | 49 + plugins/Misc/config.py | 63 ++ plugins/Misc/plugin.py | 597 +++++++++++++ plugins/Misc/test.py | 285 ++++++ plugins/Owner/__init__.py | 47 + plugins/Owner/config.py | 55 ++ plugins/Owner/plugin.py | 675 ++++++++++++++ plugins/Owner/test.py | 109 +++ plugins/User/__init__.py | 47 + plugins/User/config.py | 48 + plugins/User/plugin.py | 434 +++++++++ plugins/User/test.py | 111 +++ scripts/supybot | 350 ++++++++ scripts/supybot-adduser | 142 +++ scripts/supybot-newplugin | 296 +++++++ scripts/supybot-wizard | 618 +++++++++++++ setup.py | 150 ++++ src/ansi.py | 111 +++ src/callbacks.py | 1394 +++++++++++++++++++++++++++++ src/cdb.py | 475 ++++++++++ src/commands.py | 893 +++++++++++++++++++ src/conf.py | 995 +++++++++++++++++++++ src/dbi.py | 440 +++++++++ src/drivers/Socket.py | 218 +++++ src/drivers/Twisted.py | 141 +++ src/drivers/__init__.py | 220 +++++ src/fix.py | 239 +++++ src/ircdb.py | 1105 +++++++++++++++++++++++ src/irclib.py | 991 +++++++++++++++++++++ src/ircmsgs.py | 767 ++++++++++++++++ src/ircutils.py | 671 ++++++++++++++ src/log.py | 405 +++++++++ src/privmsgs.py | 138 +++ src/questions.py | 140 +++ src/registry.py | 623 +++++++++++++ src/schedule.py | 150 ++++ src/structures.py | 419 +++++++++ src/unpreserve.py | 77 ++ src/utils.py | 875 ++++++++++++++++++ src/webutils.py | 129 +++ src/world.py | 202 +++++ test/test_callbacks.py | 570 ++++++++++++ test/test_commands.py | 138 +++ test/test_fix.py | 127 +++ test/test_ircdb.py | 564 ++++++++++++ test/test_irclib.py | 457 ++++++++++ test/test_ircmsgs.py | 240 +++++ test/test_ircutils.py | 354 ++++++++ test/test_plugins.py | 44 + test/test_privmsgs.py | 65 ++ test/test_registry.py | 171 ++++ test/test_schedule.py | 78 ++ test/test_standardSubstitute.py | 91 ++ test/test_structures.py | 614 +++++++++++++ test/test_utils.py | 411 +++++++++ test/test_webutils.py | 47 + 76 files changed, 24013 insertions(+) create mode 100644 ACKS create mode 100644 BUGS create mode 100644 ChangeLog create mode 100644 DEVS create mode 100644 INSTALL create mode 100644 LICENSE create mode 100644 README create mode 100644 RELNOTES create mode 100644 plugins/Admin/__init__.py create mode 100644 plugins/Admin/config.py create mode 100644 plugins/Admin/plugin.py create mode 100644 plugins/Admin/test.py create mode 100644 plugins/Channel/__init__.py create mode 100644 plugins/Channel/config.py create mode 100644 plugins/Channel/plugin.py create mode 100644 plugins/Channel/test.py create mode 100644 plugins/Config/__init__.py create mode 100644 plugins/Config/config.py create mode 100644 plugins/Config/plugin.py create mode 100644 plugins/Config/test.py create mode 100644 plugins/Misc/__init__.py create mode 100644 plugins/Misc/config.py create mode 100644 plugins/Misc/plugin.py create mode 100644 plugins/Misc/test.py create mode 100644 plugins/Owner/__init__.py create mode 100644 plugins/Owner/config.py create mode 100644 plugins/Owner/plugin.py create mode 100644 plugins/Owner/test.py create mode 100644 plugins/User/__init__.py create mode 100644 plugins/User/config.py create mode 100644 plugins/User/plugin.py create mode 100644 plugins/User/test.py create mode 100644 scripts/supybot create mode 100644 scripts/supybot-adduser create mode 100644 scripts/supybot-newplugin create mode 100644 scripts/supybot-wizard create mode 100644 setup.py create mode 100644 src/ansi.py create mode 100644 src/callbacks.py create mode 100644 src/cdb.py create mode 100644 src/commands.py create mode 100644 src/conf.py create mode 100644 src/dbi.py create mode 100644 src/drivers/Socket.py create mode 100644 src/drivers/Twisted.py create mode 100644 src/drivers/__init__.py create mode 100644 src/fix.py create mode 100644 src/ircdb.py create mode 100644 src/irclib.py create mode 100644 src/ircmsgs.py create mode 100644 src/ircutils.py create mode 100644 src/log.py create mode 100644 src/privmsgs.py create mode 100644 src/questions.py create mode 100644 src/registry.py create mode 100644 src/schedule.py create mode 100644 src/structures.py create mode 100644 src/unpreserve.py create mode 100644 src/utils.py create mode 100644 src/webutils.py create mode 100644 src/world.py create mode 100644 test/test_callbacks.py create mode 100644 test/test_commands.py create mode 100644 test/test_fix.py create mode 100644 test/test_ircdb.py create mode 100644 test/test_irclib.py create mode 100644 test/test_ircmsgs.py create mode 100644 test/test_ircutils.py create mode 100644 test/test_plugins.py create mode 100644 test/test_privmsgs.py create mode 100644 test/test_registry.py create mode 100644 test/test_schedule.py create mode 100644 test/test_standardSubstitute.py create mode 100644 test/test_structures.py create mode 100644 test/test_utils.py create mode 100644 test/test_webutils.py diff --git a/ACKS b/ACKS new file mode 100644 index 000000000..d3b697352 --- /dev/null +++ b/ACKS @@ -0,0 +1,7 @@ +johhnyace, who gave me the modem that helped me tremendously in development. +bwp, who rewrote the Http.weather command, and also hosted the example + "supybot" in #supybot on OFTC and Freenode for quite some time. +sweede, for hosting the "main" supybot for awhile. +HostPC.com, for hosting the current example "supybot" and for graciously + providing DNS services and email. + diff --git a/BUGS b/BUGS new file mode 100644 index 000000000..6cd436ed6 --- /dev/null +++ b/BUGS @@ -0,0 +1,10 @@ +We're sure there are tons of them. When you find them, send them to us and +we'll fix them ASAP. We'd love to have a bugless bot someday... + +Incidentally, the way to "send the bugs to us" is via SourceForge: + + +Known bugs that probably won't get fixed: + +BadWords' outFilter filters colors. It's not a high priority to get +that fixed. diff --git a/ChangeLog b/ChangeLog new file mode 100644 index 000000000..2395a5481 --- /dev/null +++ b/ChangeLog @@ -0,0 +1,1480 @@ +2005-01-16 James Vega + + * Version 0.80.0! + + * Updated Babelfish to include Dutch, Greek, Russian, and traditional + Chinese as supported languages. + + * Updated RSS.rss to obey supybot.reply.oneToOne. + + * Updated registry.py to specify which registry value has an improper + docstring. + + * Fixed a bug in Ebay.auction, the "Current bid" regexp needed to be + updated. + +2005-01-12 James Vega + + * Version 0.80.0rc3! + + * Updated the Geekquote snarfer to snarf qdb.us' links. + + * Fixed a bug in Infobot, mis-typed registry value. + + * Fixed Network.connect to actually use the supplied password. + + * Fixed supybot.databases.plugins.channelSpecific.getChannelLink() + to return the proper channel link instead of returning the given + channel. + + +2005-01-11 James Vega + + * Version 0.80.0rc2! + + * Implemented Observer.remove, which disables and removes the observer + from all channels. + + * Added supybot.databases.channelSpecific.link.allow to determine + whether a channel allows other channels to link to its database. + + * Added supybot.plugins.BadWords.stripFormatting, which determines + whether the bot will strip any formatting before filtering bad words. + + * Added supybot.plugins.Markov.ignoreBotCommands, which determines + whether the Markov plugin will learn commands given to the bot. + + * Added a Network.driver command, which reports the current driver + being used. + + * Added an Infobot.update command, which allows the user to import an + existing Infobot factpack. + + * Added a Topic.replace command, which replaces the given topic with a + new topic. + + * Added a Note.search command, which allows the user to search for + notes they have sent or received. + + * Added supybot.databases.channelSpecific.getChannelLink(), which + returns a channel based on how channels link to each other. + + * Added supybot.plugins.Channel.banmask which specifies the default + method of generating Channel.kban's banmask. + + * Renamed supybot.databases.plugins.channelSpecific.channel to + supybot.databases.plugins.channelSpecific.link. + + * Updated RSS.announce such that it adds the arguments to the current + list of announced feeds instead of overwriting the current list. + + * Update the Google groupsSnarfer to work with Google's beta groups + website. + + * Updated Network.disconnect to announce that the disconnection + attempt has started. + + * Updated Debian.bug to handle website changes. + + * Updated Observer.{add,remove} to require the Admin capability. + + * Updated Infobot so that it actually works reasonably well; removed + the deprecation. + + * Updated Sourceforge to handle changes in the website. + + * Updated UrbanDict to handle changes in the website. + + * Updated plugins.getChannel and plugins.makeChannelFilename to + properly handle the new channelSpecific database setup. + + * Fixed a bug with ShrinkUrl.ln; the url needed to be urlquoted before + being passed off to ln-s.net. + + * Fixed some database conversion bugs in fundbConvert.py. + + * Fixed a bug in socketDrivers where reconnection attempts would + always occur immediately, thus continually blocking the bot. + + * Fixed an exception in registry.OnlySomeString's error method; + the parent method doesn't accept an argument. + + * Fixed a bug in RSS where announcing the same feed in multiple + channels would cause improper displaying of the new feeds. + + +2004-12-22 Jeremy Fincher + + * Version 0.80.0pre6! + + * Added a Topic.separator command, which replaces the current + separator with a new separator, changing the topic to reflect the + change. + + * Changed the supybot.user configuration variable so that if it + isn't configured, the user will stay up-to-date with the current + version of the bot. To take advantage of this, set your + supybot.user configuration variable to "" + + * Removed the supybot.databases.users.hash configuration + variable, so all bots hash by default. + + * Fixed a bug with AutoMode's auto-banning feature; a variable + was misspelled. + + * Fixed a bug with ChannelValues picking up children that aren't + channels. + + * Fixed Misc.apropos not to be case-sensitive. + + * Fixed bug in User.register; it works now. + + +2004-12-20 Jeremy Fincher + + * Version 0.80.0pre5! + + * Added a "shuffle" command to the Utilities plugin, which + shuffles its arguments. Useful in combination with + Utilities.last, which returns the last argument it's given, in + combination with Utilities.apply, in order to pick a random + string (think aliases). + + * Added supybot.plugins.Relay.noticeNonPrivmsgs, for making the + relay plugin use NOTICEs rather than PRIVMSGs to relay + non-PRIVMSG messages to a channel. This often affects tab + coloring in IRC clients and thus makes it so that relay messages + color the tabs like the actual messages would have. + + * Numerous bug fixes. Many, many bugs fixed. Oodles and oodles + of bugs have been fixed. Myriad bugs fixed. Get the idea? :) + + +2004-12-17 James Vega + + * Version 0.80.0pre4! + + * supybot.databases.plugins.channelSpecific.channel is now a + channelValue so that individual channels can decide to link their + databases. + + * Deprecated asyncoreDrivers. Use twistedDrivers or socketDrivers + instead. + + * Moved {eval,exec} from Owner.py to Debug.py in the sandbox. + + * Numerous bug fixes. + + +2004-09-30 Jeremy Fincher + + * Version 0.80.0pre3! + + * Deprecated FunDB, added two new plugins, Lart and Praise, to + handle the same features individually for larts and praises. + There is a script in tools/ that will convert from a FunDB to a + lart or praise database. + + * Deprecated the Infobot plugin, in expectation of a rewrite. + + * Deprecated the Quotes plugin, since we don't know anyone who + seriously uses it. Let us know if you do use it, because it may + be removed in a future version. + + * Added Karma.{dump,load} for dumping and subsequently loading + the Karma database. + + * Changed the News database implementation to a flatfile + database. + + * Removed the tinyurl shrinking code from the URL plugin and put + it in the ShrinkUrl plugin, and added the ability to use ln-s.net + as different URL shrinker as well. + + * Added an outFilter to the ShrinkUrl plugin that will shrink any + outgoing URL that is longer than the minimum length. + + * Added a Freenode plugin as proof-of-concept; the bot can now + use CAPAB IDENTIFY-MSG to ignore users that aren't identified. + + * Added the ability for the Seen plugin to match nicks with + wildcards. + + * Added a showLinks configuration option to the RSS plugin to + show links during announcements (and another such variable for + non-announcements). + + * Added the spellit, hebrew, and shrink filters to the Filter + plugin. + + * Added the ability to log to separate directories to + ChannelLogger. + + * Added the option to Lookup.add to add lookups that don't reply + with the key. + + * Added "at" and "until" commands to the Time plugin; they're not + perfect, and they don't parse nearly enough times, but they're + somewhat tolerable for now. + + * Added a Sourceforge.stats command. + + * Added single-letter commands to the Words plugin when a hangman + game is active. + + * Added supybot.plugins.Services.disabledNetworks, to disable the + Services plugin specific networks. + + * Added supybot.protocols.irc.vhost, for binding to a vhost + before connecting. + + * Added supybot.reply.format.time.elapsed.short, offering now a + "short" formatting for elapsed times. + + * Added supybot.commands.quotes, for configuring which quotes can + be used to quote arguments to commands. + + * Added supybot.databases.plugins.channelSpecific.channel, + specifying the "default channel" for non-channel-specific + databases to use. + + * Moved the supybot.humanTimestampFormat configuration variable + to supybot.reply.format.time. + + * Added a configuration variable determining the maximum length + of replies internally, so they can't suck up as much CPU as they + would otherwise. + + * Added a configuration variable determining the maximum nesting + depth allowed, so exceedingly nested commands will be stopped + before they're executed. + + * Added configuration variables concerning the formats used by + the central and plugin logging facilities. + + * Added a configuration variable determining the quote characters + used by the bot to quote literal strings. + + * Added support for line-wrapping the registry configuration + file. + + * Moved supybot.reply.{brackets,pipeSyntax} to + supybot.commands.nested. + + * Fixed a longstanding bug with the bot "forgetting" + channel-specific configuration variables if they weren't + exercised during the duration of the bot's uptime. + + * Fixed renames so they're finally persistent. + + * Fixed a bug having to do with Note and unsent notes. + + * Fixed several bugs in the interaction of Infobot with other + plugins. + + * Added commands.wrap, the new method for wrapping commands and + handling type validation errors consistently throughout the bot. + + * Upgraded many of the outside projects we include to their + newest versions. + + +2004-09-17 James Vega + + * Version 0.80.0pre2! + + * Added supybot.plugins.Google.colorfulSnarfer, which determines + whether the word 'google' in the bot's output will be made colorful + (like Google's logo). + + * Added the Time plugin, to hold all of our Time related commands. + + * Added max() and min() to Math.calc. + + * Added Unix.pid, which allows the Owner to retrieve the pid of the + bot's process. + + * Added supybot.plugins.Sourceforge.enableSpecificTrackerCommands, + which enables the rfe, bug, and patch commands. + + * Added Topic.topic, which returns the current topic of the channel. + + * Updated conf.Databases to use a more sane database ordering and + include anydbm. + + * Updated various plugins to use our new-style database abstraction. + Plugin databases using this new-style will be named Plugin.dbtype.db. + +2004-09-12 Jeremy Fincher + + * Version 0.80.0pre1! + + * Added the facility to supporting several different database + implementations in a plugin, allowing the user to specify which + databases are supported as well as in what order of preference. + + * Added the Insult plugin, for colorful, creative insults. + + * Added the UrbanDict plugin, for defining words based on + UrbanDictionary.com. + + * Added the Observer plugin, for watching a channel for regexps + and running commands when the bot sees such regexps. + + * Moved Http.geekquote to a new Geekquote plugin, added a command + for using qdb.us, and added a snarfer. + + * Added a SuperIgnore plugin, a good example of an inFilter and a + way to completely, totally ignore people. + + * Changed the name of the Network plugin to Internet. + + * Added a new Network plugin, and moved some commands from Owner, + Misc, and Relay to it. + + * Added CTCP flood protection. + + * Added a supybot.plugins.Karma.allowUnaddressedKarma + configuration variable, for allowing karma increments/decrements + similar to Infobot. + + * Added supybot.reply.whenAddressedBy.nicks, to allow users to + specify a list of nicks to which the bot will respond as if they + were its own nick. + + * Added the ability to support multiple-word karma + increments/decrements. + + * Changed Owner.rename to be handled persistently; now renames + work across restarts of the bot or reloads of a plugin. + + * Changed Misc.last to include a timestamp for when the message + was sent. + + * Added the Channel.alert command, to send all currently + connected ops on a channel a message. + + * Changed the MoobotFactoids plugin to allow commands to be + nested in factoids definitions and searches. + + * Removed the futurama command in favor of adding a + futurama.supyfact file to supybot-data. + + * Improved the Http.kernel command, showing more kernel types. + + * Added a new contributors command and a way of storing + contributors and their contributions in a plugin. + + * Changed the name of Anonymous.action to Anonymous.do, to be + more consistent with "say" and other bots (MozBot, iirc). + + * Added the ability for channel bans and ignores and global + ignores to have expiration times. + + * Added invalid command flood protection. + + * Changed RSS' headlines output to bold the separators in order + to make it easier to distinguish the headlines. + + * Added a --no-network option to supybot-wizard. + + * Added several attributes to IrcMsg objects as they pass through + the bot; currently we tag them with receivedAt, receivedBy, and + receivedOn. + + * Added RichReplyMethods.errorInvalid, a nice helper method for + sending errors about invalid values. + + * Changed the --nonetwork and --noplugins options to test/test.py + to --no-network and --no-plugins. + + * Changed plugins.makeChannelFilename, swapping the order of the + channel and filename arguments and making the channel optional. + + * Changed the first argument to callbacks.Privmsg.callCommand to + be a name rather than a method, for greater justice. + + * Added a new mechanism for ordering plugins (subclasses of + callbacks.Privmsg) which is much more flexible than a simple + priority system. + + +2004-09-06 James Vega + + * Version 0.79.9999! + + * Added stripFormatting option to ChannelLogger plugin, which + determines whether non-printable formatting characters are logged. + + * Added Sourceforge.patches command to complement the current bugs + and rfes commands. + + * Added abs() to Math.calc. + + * Improved the interface for Config.list. Now groups and values + are listed, with groups being denoted by a leading @. + + * Improved Config.config such that the user can specify the entire + config variable name (conf.supybot....). + + * Fixed a bug where ChannelLogger wouldn't log ignored nicks. + + * Fixed an incorrect path in INSTALL. + + * Fixed some missing imports in Unix's configure method. + + * Fixed a bug where an owner could publically retrieve a private + configuration variable. + + * Fixed an exception when trying to remove non-existent Heralds. + + * Fixed an exception in RSS.getHeadlines. + + * Fixed a couple bugs in Poll, when retrieving the Poll id. + + * Fixed a problem with trying to use socket.inet_pton under + Windows; Python doesn't build their Win32 port with IPV6 support, + so we have to brute-force IPV6 detection. + + * Fixed a few bugs with how Infobot handled the SQLite db. + + * Fixed others/convertcore.py so that liter-based units are + properly capitalized (L not l) and use 1000 as the conversion rate + for MB, KB, etc. since MiB, KiB, etc. are also known units. + + * Fixed a bug where Infobot would confirm an unaddressed factoid + definition. + + * Fixed a problem where Google.stats didn't keep track of all + searches. + + +2004-08-31 Jeremy Fincher + + * Version 0.79.999! + + * Added the ability to send long fortunes line-by-line rather + than all in one (or several) messages; it will do this if + supybot.reply.oneToOne is set to False. + + * Added many configuration variables to the Unix plugin, allowing + configurable commands and options to those commands. + + * Changed the output of Config.list to show groups with a + preceding @, rather than use the --groups option. + + * Changed the Google.stats (formerly Google.info) command to be + persistent, using the registry to record old values for the + number of searches made and the number of seconds Google has + spent searching for the bot. + + * Added module __revision__ logging to our exception logs, for + more information in bug reports. + + * Fixed a bug with asyncoreDrivers' handling of reconnects; it + would not reconnect when commanded to if it had reconnected + before. + + * Fixed several bugs where the bot was testing nicks for + equivalence without first normalizing them, leading to some false + negatives. + + * Fixed a bug with the handling of + supybot.reply.withNoticeWhenPrivate so all private messages + really are replied to with a notice. + + * Fixed Http.geekquote to match the current bash.org layout. + Also, made sure all ids are valid ids before requesting a quote + (apparently bash.org is returning a certain quote for all invalid + ids). + + * Fixed a bad regular expression in the Ebay plugin which could + cause the bot to suck up 100% CPU for a signficant (perhaps + practically infinite) amount of time. + + * Fixed a bug where the bot would sometimes reconnect to a + network after being told to disconnect. + + * Fixed an uncaught exception in Owner.connect when the user + forgets the network name + + +2004-08-30 Jeremy Fincher + + * Version 0.79.99! We're getting asymptotically closer to + 0.80.0! + + * Added Anonymous.action, to anonymously perform actions in a + specified channel. + + * Added a Karma.clear, so channel ops can clear the karma for a + given name. + + * Added a Topic.redo, to redo the last undo. + + * Added supybot.protocols.irc.umodes, to allow the bot to set + user modes upon connecting to a network. + + * Fixed numerous bugs involved with disconnecting, reconnecting, + and using multiple networks. + + * Fixed a bug that would prevent the bot from quitting except via + multiple Ctrl-Cs. + + * Fixed some mis-interaction between the Karma plugin and the + Infobot plugin. + + * Fixed RSS's announcements. + + * Fixed bug in Later whereby no more than one message could be + queued for a nick. + + * Fixed Services.configure, as well as several bugs in Services + which sometimes prevented the bot from identifying. + + * Fixed bugs in the Poll module that caused the list and poll + commands not to work. + + * Fixed the ebay snarfer. + + * Fixed exception raised by some CTCP messages when the URL + plugin was loaded. + + * Fixed Debian.version. + + * Fixed Amazon's use of unicode. + + +2004-08-27 Jeremy Fincher + + * Version 0.79.9! + + * Added Infobot, a plugin to emulate Infobot. + + * Added Anonymous, a plugin for anonymously saying things to a + channel. + + * Added Tail, a plugin which will tail logfiles and send the + new lines to a configurable list of targets. + + * Added NickCapture, a plugin which tries to recapture a nick + that's being used by someone else, both by watching for QUITs + with that nick and by checking via ISON for that nick. + + * Changed the behavior of "seen" with the --user switch instead to + be a separated command, Seen.user. + + * Changed the behavior of "seen" with no arguments instead to be + a separate command, Seen.last. + + * Moved the connect and disconnect commands from the Relay plugin + to the Owner plugin, so a Supybot can be on multiple networks + without ever loading the Relay plugin. + + * Added relay bot detection to the Relay plugin. Now, rather + than get involved in a loop with another relay bot, a Supybot + will (if it has ops) rain down its wrath upon the offender. + + * Added supybot.plugins.Quotes.requireRegistration, which + determines whether a user need be registered to add Quotes to + the Quotes database. + + * Added supybot.plugins.RSS.showLinks, which determines whether + the bot will show links to the RSS headlines along with the + normally displayed titles. + + * Removed supybot.reply.withPrivateNotice and split it into two + separate configuration variables, supybot.reply.withNotice and + supybot.reply.inPrivate. + + * Added supybot.log.stdout.wrap, to allow optional (defaulting to + True) wrapping of stdout logs. + + * Added supybot.databases.plugins.channelSpecific, a value that + determines whether the database used for channel-based plugins + will be a channel-specific database or a global database. This + value, ironically enough, is channel-specific -- channels can + each individually decide to be "part of the Borg" or to "be their + own channel." The default, of course, is for databases to be + channel-specific. + + * Changed the way channel databases are handled; instead of + generating #channel- files, instead we create a subdirectory + of the data directory named #channel, and then stick all the files + in there. It's a much cleaner way to do things, in our opinion. + + * Added several configuration variables to the Status plugin to + determine how verbose the cpu command is. These are, of course, + channel-specific. + + * Added a configuration variable to the Dunno plugin, + supybot.plugins.Dunno.prefixNick, which determines whether the + bot will prefix the nick of the user giving an invalid command to + its "dunno" response. Formerly, it never would; the default for + this variable, however, is True, because that's how the rest of + Supybot works. + + * Added Owner.rename, a command for renaming commands in other + plugins. + + * Added Config.channel, for getting/setting channel configuration + variables. + + * Fixed the problem with channels with dots or colons in them + raising exceptions whenever the registry was accessed. + + * Changed Fun.eightball to provide a similar answer for a question + asked multiple times. + + * Changed Fun.roulette to use a 6-barrel revolver. + + * Changed Bugzilla to use the registry, rather than a custom + flatfile database format. + + * Added the KeepAlive plugin, to send useless keepalive messages + to someone every some period. It's mostly just because we + noticed that MozBot had one, and we couldn't allow ourselves to + be outdone. + + * Changed the URL plugin to use flatfiles rather than SQLite + database. Also reduced the functionality of the last command by + removing some options that no one ever really used, and removed + the random command (who uses that anyway?) + + * Changed the Words plugin not to use SQLite. We lose the + anagram command, but crossword and hangman become much easier to + use, since all the user has to do is put a words file in + data/words. + + * Changed the Relay plugin to rely only on the registry, allowing + it to start and join all networks and channels with no user/owner + interaction at all. Also, removed the Relay.say and + Relay.reconnect commands, since both can be accomplished easily + using the Relay.command command in combination with + Owner.reconnect and Anonymous.say commands. + + * Added supybot.reply.withNoticeWhenPrivate, to make the bot + reply with a notice when it privately messages a user -- this + generally means that the user's client won't open a query window, + which may be nice. Do note that users can override this setting + via the user registry variable of the same name. + + * Added supybot.nick.alternates, which allows a list of alternate + nicks to try, in order, if the primary nick (supybot.nick) is + taken. Also added a nick-perturbation function that will change + supybot.nick if there are no alternates or if they're all taken + as well. As a result, removed supybot.nickmods. + + * Changed ChannelLogger to log channels to logs/ChannelLogger, + rather than simply logs. + + * Added the ChannelRelay plugin, to relay messages between two + channels. This might be useful for people who want to forward + CVS update messages from one channel (such as #commits) to + another. + + * Added Channel.mode, to set modes in the channel, Channel.limit, + to set the channel limit, Channel.moderate and + Channel.unmoderate, to set +m and -m, respectively, and + Channel.key to set or unset the channel keyword. + + * Added a new plugin, Format, which offers several commands for + formatting strings on IRC. Moved several commands from to it + from the Utilities plugin. + + * Improved the functionality of RSS.announce. Calling it with + no arguments now lists the currently announced feeds. Removing + feeds is done by specifying the --remove option. + + * Added a reconnect command to the Owner plugin. + + * Added aol and rainbow filters to the Filter plugin. + + * Added Nickometer plugin, a translation of Infobot's Nickometer. + + * Added multiple recipient support for notes. + + * Added BadWords.list, to list the bad words currently being + censored by the bot. + + * Changed Misc.help to allow plugins to specify their own help, + and added help for several of the more confusing plugins. + + * Added Dunno.stats, to return the number of dunnos in the + database. + + * Added the Currency plugin, to perform currency conversions. + + * Added conf.supybot.plugins.Karma.allowSelfRating, which + determines whether users are allowed to adjust the karma of their + current nick. + + * Added --nolimit option to Misc.last, which causes it to return + all matches that are in the history. + + * Added conf.supybot.plugins.Herald.defaultHerald, which provides + a default herald to use for unregistered users. Herald.default + was also added as a friendly interface to managing the + defaultHerald. + + * Added Weather.wunder, which uses wunderground.com to report the + current weather status. + + * Changed supybot.defaultCapabilities to be a space-separated + list rather than a comma-separated list. Also added a check to + make sure -owner was in supybot.defaultCapabilities, and to + require a command-line option to allow it not to be present. + + * Added Sourceforge.fight, which returns the list of specified + projects and their bug/rfe count in sorted order, least to most. + + * Added Utilities.reply for replying to a person with text. Like + echo, but it prepends the nick like the bot normally does. + + * Changed Utilities.success to accept an optional argument + for text to be appended to the success message. + + * Changed User.{addhostmask,removehostmask,register,unregister} + to allow owner users to do what they will with their users. You + can now add hostmasks, remove hostmasks, register users, and + unregister users willy-nilly. + + * Changed and moved several configuration variables. + supybot.{throttleTime,maxHistoryLength,pingServer,pingInterval} + all moved to supybot.protocols.irc; supybot.httpPeekSize moved to + supybot.protocols.http; supybot.threadAllCommands moved to + supybot.debug.threadAllCommands, and probably a few others I + forgot to mention. + + * Added Http.zipinfo, which returns a veritable plethora of + information about the supplied zip code. + + * Added a configuration variable for every plugin, "public", that + determines whether the plugin is considered public (i.e., whether + it will show up in the list command when the list command isn't + given the --private option). + + * Added Misc.author, a command for finding out which author + claims a particular plugin. + + * Added Topic configuration supybot.plugins.Topic.format template + string allowing full customization of the Topic items. + + * Added Topic.lock and Topic.unlock, for locking and unlocking + the topic (setting +t or -t, respectively) + + * Added Topic.restore, for restoring the topic to the last-sent + topic. Useful for when people change your carefully crafted + topic by means other than the bot. + + * Changed supybot.brackets so you can now provide the empty + string, which means you cannot do nesting of commands. + + * Added Utilities.last, which replies with the last string + given to it. This is useful for sequencing commands and then + replying with the output of the last commnad. + + * Updated RSS.info to accept a feed name as well as a url. + + * Added a signal handler for SIGTERM, so you folks killing your + bots uncleanly won't have as many bugs :) + + * Added a signal handler for SIGHUP that reloads the bot's + various configuration files. + + * Added a new configuration variable, supybot.pidFile, which + determines what file the bot should write its PID to. The + default is not to write the PID file at all. + + * Added a comma argument to utils.commaAndify, which specifies the + character to use in place of the comma. + + +2004-04-16 Jeremy Fincher + + * Version 0.77.2! + + * Fixed numerous bugs, high and low, big and small and + in-between. Definitely worthy of a release. + + * Added supybot.plugins.ChannelLogger.includeNetworkName, so the + logs aren't strangified when relaying between channels. + + * Added a --capability option to User.list, to allow people to + list all the users possessing a certain capability. The main + reason this was added is so jemfinch can tell who owns which + Supybots on #supybot :) + + * Added Utilities.success, mostly for making aliases such that + they can respond with success if no errors were encountered in + any nested commands. + + * Changed the name of the new filter, colorstrip, to be + stripcolor. Better now than after it was highly established :) + + * Added configuration variables + supybot.plugins.Services.NickServ.password (which replaces the + old supybot.plugins.Services.password) for specifying the + NickServ password, as well as + supybot.plugins.Services.ChanServ.{op,halfop,voice}, which + determine what the bot should request of ChanServ when it + identifies or joins a channel. These latter variables are, of + course, channel variables. + + * Added configuration variable + supybot.plugins.Babelfish.languages (which replaces the old + supybot.plugins.Babelfish.disabledLanguages) for specifying + which languages will be translated/spoken. + + * Fixed bug #863601, plugin BadWords fails on color codes. + + * Replaced Sourceforge.{rfe,bug} with Sourceforge.tracker, which + can query any tracker type (not just RFEs and bugs) and responds + with more information, a la trackerSnarfer. + + * Added supybot.log.individualPluginLogfiles, which determines + whether plugin logs will be logged to their individual logfiles + in addition to the misc.log logfile. + + * Added supybot.plugins.WordStats.ignoreQueries, which, when + true, makes the bot ignore queries (and not increment its word + statistics). + + * Added the LogToIrc plugin, for sending logs to an IRC + channel or nick. Useful for traceback notification and whatnot. + + * Changed supybot.log.timestampFormat to specially handle the + empty string -- if it's set to the empty string, it will log + times in seconds-since-epoch format. + + * Added supybot.plugins.Weather.convert, which determines whether + or not Weather.{weather,cnn,ham} will convert temperature to the + configured temperatureUnit. + + * Changed User.setpassword not to require the to + be correct if the requesting user has the owner capability (and + isn't the owner himself). + + * Added ircutils.strip{Bold,Reverse,Underline,Formatting}, which + will remove the specified formatting or all forms of formatting + in the case of stripFormatting. + + +2004-04-09 Jeremy Fincher + + * Version 0.77.1! + + * Added supybot.reply.errorWithNotice to make the bot give its + error messages in a notice. + + * Added Filter.colorstrip, an outfilter that strips all color + codes from messages. + + * Added supybot.plugins.BadWords.{replaceMethod, nastyChars, + simpleReplacement, requireWordBoundaries}; see the associated + help strings to find out what they do. + + * Added supybot.plugins.Babelfish.disabledLanguages, to disable + certain languages from being translated/spoken. + + * Added supybot.reply.maximumMores, to give users the ability to + set the maximum number of "more" chunks replies will generate. + + * Added supybot.reply.truncate, to turn off the normal chunking + of replies that later can be retrieved with the more command. + Setting this variable to On means that no chunks will ever be + created. + + * Added supybot.plugins.Enforcer.takeRevengeOnOps, which makes + the bot even take revenge on #channel,ops who try to violate the + channel configuration. Of course, such people can change the + channel configuration to their bidding, but it's a decent + protection against errant botscripts or something. + + * Added supybot.plugins.Channels.alwaysRejoin, to make the bot + always rejoin when it's kicked. + + * Added supybot.databases.users.hash, allowing those running bots + to specify the default hashed/unhashed state of user passwords in + the config file. + + * Added Debian.bug, which retrieve bug info from Debian's BTS. + + * Changed Relay.names to Relay.nicks, to be consistent with + Channel.nicks. + + * Added supybot.brackets, a configuration variable for specifying + which set of matching brackets to use for nested commands. Valid + values are [] (the default), {}, <>, and (). + + * Added a configuration variable to ChannelLogger, timestamp, + which determines whether the bot will timestamp its logfiles. + This is a channel-specific variable, of course. + + * Updated ChannelLogger not to log messages starting with + [nolog], in order to allow private information to remain private. + + * Added a configuration variable to ChannelLogger, + flushImmediately, to cause all channel logs to be flushed every + time they're modified, for those people who like to read the logs + through tail -f or something similar. + + * Updated WordStats to allow removing of tracked words. + + * Updated Seen.seen to accept no arguments and return the last + message spoken by anyone. + + * Updated the Herald plugin to use the standard substitute until + we get it updated to use commands as heralds instead of plain + strings. + + * Updated echo to use the standard substitute on its reply. + + * Updated Network.whois so that it can now retrieve + information on all domains. + + * Updated the Weather plugin to be retrieve weather from either + hamweather.net or cnn.com. Also added + supybot.plugins.Weather.{command,temperatureUnit} so that you can + specify which command (Weather.cnn or Weather.ham) to use when + Weather.weather is called and in which unit (C, F, K) to report + the weather. + + * Updated standard replies (such as supybot.replies.success, etc.) + to use the standard substitute (such as $nick, $who, $channel, + etc.) in their text. + + * Fixed snarfers to respect lobotomies. + + * Fixed Admin.join not to add the channel to the supybot.channels + registry entry if joining the channel wasn't successful. + +2004-02-20 Jeremy Fincher + + * Version 0.77.0! + + * Changed the format of the user and channel databases to + something much more readable and user-editable. Be sure, if + you're upgrading, to run tools/ircdbConvert.py on your user and + channel databases *BEFORE* installing the new version. + + * Updated almost every document we have, and added a + GETTING_STARTED document. Yay! + + * Added several new options for Channel.kban: --exact, --nick, + --user, --host, for more flexibility in determining what the + banmask is. + + * Added a Scheduler plugin for scheduling events to occur at + specific times. + + * Added a Herald plugin for announcing to the channel the arrival + of certain users. + + * Changed the reply method of the irc object given to plugins not + to require a msg object. + + * Added inter-factoid references for MoobotFactoids. Simply + define a factoid as "see " (and nothing else) and it + will automatically go pick up the value for that factoid and + display it in its place. Sort of a "symlink" for MoobotFactoids. + + * Added the ability to reply to factoids without the "whatis" in + the Factoids plugin. Now, you can use "@foo" instead of "@whatis + foo". + + * Added --{from,to} and --sent options to Note.sent. + + * Changed Note.get to simply be "note". Instead of "note get + 145", you should use "note 145". + + * Changed channel capabilities to use a comma to separate the + channel from the capability instead of a period. This is because + channels can include periods (but not commas) and since we now + allow "plugin.command" capabilities, there's no way to know + whether a given dot is separating the channel from the capability + or the plugin from the command. + + * Removed Admin.setprefixchar, since it's unneeded with the new + configuration. + + * Removed Status.bestuptime, since it was buggy and no one felt + like maintaining it. + + * Added Http.cyborg, a fun little command for getting cyborg + abbreviations for names. + + * Added Sourceforge.totalbugs and Sourceforge.totalrfes + + * Removed Owner.{set,unset} since they matter not. + + * Made the smileys and frowns configurable in ChannelDB. + + * Added a vast array of new configurables -- check out the various + config commands in Configurable plugins to see. + + * Added better error reporting for Admin.join, explaining to the + user if the bot can't join the channel. + + * Added a title-snarfer to the URL plugin. + + * Added Relay.command, a command for sending commands to the bot + on a different network. + + * Added Bugzilla.search, a new command for searching a bugzilla + installation + + * Added an INVITE handler in Admin, allowing users with the admin + capability to INVITE the bot to a channel. There's also a config + variable, alwaysJoinOnInvite, that will cause the bot to join + a channel whenever someone invites it to a channel, not just users + with the admin capability. + + * Added conf.requireChannelCommandsToBeSentInChannel for requiring + all channel-related commands to be sent in the channel. + + * Added conf.followIdentificationThroughNickChanges for having the + bot update user's identified hostmask when they change nicks. + + * Added conf.replyWhenNotAddressed, a configuration variable for + having the bot always attempt to parse a message as a command. + + * Added conf.replyWhenAddressedByNick, a configuration variable + for having the bot *not* respond when addressed by his nick. + + * Added conf.replyWithNickPrefix, a configuration variable for + having the bot not prefix the nick of the person giving a command + to the reply. + + * Changed all "zegrep" stuff to "zgrep -e" stuff, since zegrep is + on fewer platforms than zgrep. + +2003-12-12 Jeremy Fincher + + * Version 0.76.1! The birthday release! + + * Fixed a bug in irclib.py that could cause the bot to loop, + connecting and disconnecting from the server because it didn't + think the server responded to its PING. + + * Fixed a bug in the Services implementation that could cause + the bot to continually loop, connecting and disconnecting from the + server. + + * Fixed Misc.help to follow the same default plugin rules that the + rest of the bot follows. + + * Added better error reporting to Admin.join, so the various error + conditions that might make the bot incapable of joining a channel + will be reported. + + * Updated RootWarner to be configurable, offering each channel the + ability to define whether or not people who IRC as root will be + warned, what that warning will be, and whether or not they should + be kicked. + + * Added a configurable 'topic-sync' to the Relay plugin to + determine whether the topics of the two channels should be kept + synchronized. + + * Added Lookup.search for searching the various loaded lookups. + + * Added Topic.reorder, a new command for reordering the topics in a + specific manner. + + * Added Topic.list, a new command for listing the topics in a + channel (mostly in order to help out with Topic.reorder :)) + + * Added Http.extension, a new command to retrieve file extension + information from filext.com. + + * Updated Todo.remove to allow removing multiple taskids + + * Updated Relay.whois to include a user's away status and identified + state, if the server supports it. + + * Added utils.sorted, which does what list.sorted will do when 2.4 + is released. + + * Added ircutils.isCtcp, for determining whether or not a message + is a CTCP message. + +2003-12-6 Jeremy Fincher + + * Version 0.76.0! + + * Added a "trusted" capability that defaults to off (-trusted) and + is required for Utilities.re (with which it's possible to DoS the + bot) and for the new Math.icalc (with which it is trivially + possible to DoS the bot). + + * Added Math.icalc, a new command for doing integer arithmetic. + It requires the "trusted" capability. + + * Added the Fun.ping command, because MozBot has it. + + * Added Note.unsend command, to allow users to "unsend" notes + they've sent but whose recipient has not yet read. + + * Added Channel.{deop,devoice,dehalfop,kick}. + + * Added Http.size and Http.doctype and Http.headers to retrieve + various meta-information on URLs. + + * Added a ranking to ChannelDB.wordstats. + + * Added Karma.most for determining various "mosts" in the Karma + database. + + * Added User.list command to list registered users. + + * Added 'roulette' command to Fun plugin. + + * Added a Channel.lobotomies command to list the channels in which + the bot is lobotomized. + + * Added a swap function to Math.rpn. + + * Changed the name of User.changeusername to User.changename. + + * Changed the logging infrastructure significantly; each plugin + now has its own logger (and thus logfile), in addition to being + logged in the main logfile. + + * Fixed bug in which the bot wouldn't rejoin channels after a + reconnect. Thank the Lord for tcpkill :) + + * Fixed Http.freshmeat for projects with a space in their names. + + * Changed RSS so RSS feed commands may be added while the bot is + running, and so added RSS feed commands are in the RSS plugin. + + * Changed Lookup so added lookups are added in the Lookup plugin + itself so there's no dependency on Alias, and so loaded lookups + can be seen via 'list Lookup'. + + * Fixed bug #850931 as well as several other minor bugs in + Utilities.re. + + * Fixed bug #851254, Factoids.whatis didn't work on numeric keys. + + * Added the ability to turn on/off the showing of ids in FunDB + excuse/insult/praise/lart. + + * Added the to= keyword argument to the various reply functions to + you can specify a target to send the message to. + + * Changed socketDrivers to allow more time (10x more, to be exact) + for connecting to the network than for the normal read. + + * Fixed a bug in Http.weather that would cause an ugly error + message for a command like "weather hell". + + * Added the number of strings to the Fun.object output. + + * Fixed bug in RSS.configure; no aliases could be added. + + * Changed Alias.freeze to Alias.lock. + + * Fixed sorting in Status' uptime database. + + * Updated the Gameknot tests for expired games, and updated the + Gameknot plugin to handle such links in its snarfer. + + * Added a 'server' attribute to Irc objects to unify the way to + access such information. + + * Added revision command for finding out the revision of the files + in a running bot; also added __revision__ strings so CVS would be + happy to keep such information for us :) + + * Fixed bug #848475 -- bad error message from regexp-expecting + commands. + + * Stopped listing the plugin dispatcher command in the list of + commands for that plugin. + + * Fixed bug in Gameknot.gkstats when retrieving the stats for + users who haven't yet played a game. + + * Added a numUsers() method to ircdb.UsersDB (ircdb.users). + +2003-11-18 Jeremy Fincher + + * Changed commands so that plugins no longer have to make sure + that their commands are unique within the bot. Ambiguous commands + will reply with an error message and instruct the user to + disambiguate the command by prefixing with the appropriate plugin + name.. Many commands that formerly contained the plugin name (or a + portion thereof) have had it removed, and many plugins have had + their names changed so prefixing a command with the plugin name is + less bulky. Rather than list each individual example, you can + read the plugin documentation posted at ***TODO*** + + * Renamed numerous plugins: OwnerCommands became Owner, + AdminCommands became Admin, ChannelCommands became Channel, + MiscCommands became Misc, UserCommands became User, URLSnarfer + became URL, Notes became Note, FunCommands became Fun, IMDB became + Movies, and Aliases became Alias. + + * Made aliases persistent across reloads/bot restarts. You should + probably change your botscripts not to add the aliases onStart, but + (assuming those aliases don't change) it should still work fine. + + * Added the ability for users to specify that their passwords + should be hashed on disk. + + * Added MoobotFactoids plugin for moobot-style factoids (which are + meant to mimic blootbot-style factoids). People used to + traditional IRC bot factoids plugins will probably find this + plugin more to their taste than Factoids. + + * Added Ebay plugin for snarfing eBay URLs as well as getting info on + certain auctions. + + * Added monitoring of occurrences of certain words on a per-user + basis, adding two new commands to ChannelDB (addword and + wordstats). + + * Added Bugzilla module for accessing various data in Bugzilla + pages. + + * Added QuoteGrabs module which allows people to grab interesting + quotes from other people and store them in the bot for later retrieval + (also optionally have the bot randomly snarf quotes). + + * Added a "change" command to change factoid values in the Factoids + plugin. + + * Added Dunno plugin as an optional replacement for the boring 'no + such command' response. + + * Changed FundB to allow accessing excuses, larts, and praises by + id. + + * Added substitutions for 'me' and 'my' in insult/praise/lart. + + * Added 'change' and 'old' commands for News. + + * Added ASPN Python Cookbook URL snarfer. + + * Moved karma out of ChannelDB and into its own Karma plugin. + + * Moved uptime-related commands to from FunDB to the Status plugin. + + * Renamed the Network.internic command to whois, since we can now fix + ambiguity by prefixing the plugin name. + + * Removed the "googlesite" function. + + * Removed "dictserver" command in favor of using the Configurable + framework with the Dict plugin instead. + + * Removed TwistedCommands plugin to the sandbox; the one command + it provided (dict) is now better provided in the Dict plugin. + + * Removed the Moobot plugin (the commands were moved to the Fun + plugin or dropped entirely). + + * Removed all example strings from plugins. To be replaced with an + automated process later. + + * Converted several plugins to the new Configurable plugin type + Plugins modified include Bugzilla, ChannelDB, Dict, Ebay, + Enforcer, Gameknot, Google, Python, Relay, and URL (formerly + URLSnarfer). + + * Changed ChannelDB database to use integer user ids instead of text + usernames. + + * Changed Http.geekquote to use multiline geekquotes (and removed + the option to do so, since it's now the default). + + * Added a --id switch to geekquote to pick a specific geekquote. + + * Changed most commands in News to require the 'news' capability. + + * Changed Relay.names output to show (and sort by) status in the + channel. + + * Removed 'relaycolor' command in favor of Configurable framework. + + * Added total memory usage to 'cpustats' output for several *nix + platforms. + + * Removed the total percentage of CPU time from 'cpustats'. Not + only was it inaccurate, but we needed the room for memory stats. + + * Changed Topic.shuffle to ensure that the topic is actually + shuffled. + + * Changed all commands which take an index (various Topic and + Factoids commands) to index from 1 instead of 0. + + * Fixed several bugs in Unix.spell whereby the bot could be + frozen. + + * Changed the name of the "bug" command in the AdminCommands + plugin to "reportbug" instead. + + * Added QUIT stat-keeping to ChannelDB. + + * Removed the OwnerCommands.say command; it wasn't useful enough, + and is so easily written that anyone can have it back if they want + it. + + * Changed OwnerCommands.load (and reload) to be case-insensitive, + so "load funcommands" works just as well as "load FunCommands". + + * Changed the keyword parameter "needed" to privmsgs.getArgs to be + "required" instead. It just sounds better, works with "optional" + better, and we won't get an opportunity later to change it. + + * Updated IrcObjectProxy.reply to allow a "notice" boolean keyword + to determine whether or not to reply via a notice. + + * Added privmsgs.urlSnarfer, a wrapper around snarfer methods that + handles both the threading and the limiting of replies so loops + between two snarfing bots can't happen. + + * Added structures.PersistentDictionary for dictionaries that + should be saved to a file in repr()ed form. + + * Added structures.TwoWayDictionary for dictionaries that should + map keys to values and vice versa. + + * Added a curry function to fix.py for doing currying (if you + don't know what it is, don't worry about it :)) + + * Added utils.depluralize to do the opposite of utils.pluralize. + + * Added utils.safeEval for safe evaluation of Python data + structures (lists, dictionaries, tuples, numbers, strings, etc., + but no operations on them). + + * Added utils.saltHash for handling the hashing of passwords with + a salt. + + * Added plugins.standardSubstitute to do standard substitutions + for things like $who, $nick, $channel, etc. + + * Added plugins.Configurable, a plugin mixin that allows easy + specification and persistence of configurable data on a global and + per-channel basis. + + * Fixed plugins.ChannelDBHandler (and added plugins.DBHandler) to + be threadsafe, so threaded plugins could still use a database, and + non-threaded database-using plugins could still receive the + results of a threaded command. + + * Removed ircutils.privmsgPayload and ircutils.shrinkList, both of + which existed prior to the addition of more, and aren't needed + anymore. + + +2003-10-12 Jeremy Fincher + + * Version 0.73.1! + + * Fixed a bug in Math.{calc,rpn} where certain functions + ("degrees" in particular) that didn't like complex arguments would + fail on numbers on which they shouldn't. + + * Added an optional "key" argument to ChannelCommands.cycle. + + * Fixed bolding in supybot-wizard.py. + + * Fixed handling of the secure flag by ircdb.IrcUser.setAuth; + previously it didn't prevent someone with an unmatched hostmask + from identifying. + + * Fixed imports in the DCC plugin. + + * Fixed a bug where the bot would not reply to nick-addressed + messages on a channel if his nick wasn't entirely lowercased. + + * Fixed the Relay plugin to relay topic changes; an oversight not + caught earlier because supybot has for a long time managed our + topics. + + * Fixed a bug in the Services plugin where the bot would ghost + himself if his nick didn't match in case the nick given. + + * Added the ability for PrivmsgCommandAndRegexp to have regexps + that are called *after* callbacks.addressed has been called on the + message -- see ChannelDB.{increase,decrease}Karma for an example. + + * Fixed bug in supybot-wizard.py where plugins without configure + functions would raise an uncaught exception. + + * Fixed debincoming to work since the removal of baseplugin; it + was missing an import. + + * Fixed MiscCommands.doPrivmsg to send an IrcObjectProxyRegexp to + the replyWhenNotCommand function. + + * Fixed debversion to display the correct output when no matching + packages were found. + + * Fixed ChannelDB to import conf; karma didn't work otherwise. + + * Fixed a bug in the Enforcer plugin that would cause the bot to + ban everyone from the channel whenever ChanServ deopped someone. + + * Changed the "whois" command in the Network plugin to "internic" + instead. + +2003-10-05 Jeremy Fincher + + * Version 0.73.0! + + * Added the News plugin, news handling for channels. + + * Changed the initial character of anti capabilities to '-' + instead of '!'. '!' can be the initial character in a channel + name, and so any command using getChannel and accepting a + capability as its first argument (several in ChannelCommands) will + have difficulties (the channel then *must* be specified since + getChannel will consider !capability to be a channel name). Note + that this means you'll need to either remove and re-create or edit + your config files to change ! to - in capabilities. + + * Removed the "cvsup" command; it's been useless since we had a + global install, and can only serve to confuse. + + * Added a "private" command to MiscCommands to allow users to + specify that a response should be made in private. + + * Added a "secure" flag to user database records that *requires* + that one of the user's hostmasks match if the user is to be + recognized. This flag may be set with the "setsecure" command. + + * Added a convert command to the Math plugin. More conversions + are necessary, if anyone's interested in doing it. The available + units are available via the "units" command. + + * Fixed the pydoc command to allow access to standard Python + modules that are shared libraries rather than pure Python code. + + * Added a "Python" plugin and moved FunCommands.{pydoc,zen} to + it. + + * Fixed the supybot- scripts to use optparse so they now + accept many command line options (but most importantly, the --help + option :)) + + * Added a debincoming command to the Debian plugin; it searches + the available packages at http://incoming.debian.org/ + + * Moved the "bug" command from MiscCommands to AdminCommands in + order to prevent possible abuse. + + * Changed ChannelDB.seen to default to using nicks; a --user + option can be passed to use the old behavior. Note that this + means you'll have to update your ChannelDB database; use this + SQL statement to do so: + CREATE TABLE nick_seen ( + name TEXT UNIQUE ON CONFLICT REPLACE, + last_seen TIMESTAMP, + last_msg TEXT + ); diff --git a/DEVS b/DEVS new file mode 100644 index 000000000..adb54339d --- /dev/null +++ b/DEVS @@ -0,0 +1,67 @@ +These are the developers of Supybot, in approximate order of ____. + +Jeremy Fincher (jemfinch) is a Computer Science (and possibly +philosophy) student at The Ohio State University. He spends most of +his free time with his girlfriend Meg, but also...well, there's not +much also :) He hopes to graduate with good enough grades to go to +law school or seminary at some point in the future. He initially +wrote the majority of the Supybot framework and standard plugins, +though he's been trying to slowly phase himself out of plugin-writing +and more into framework-enhancement. Rather than list the specific +things he's done, you can just assume that if someone else isn't +claiming it, it was probably done by him. + +Daniel DiPaolo (Strike/ddipaolo) is a lazy Texan punk with a job as an IT +monkey who spends his free time coding, playing ultimate frisbee, and arguing +pointless things on the internet. As far as the bot goes, he's mainly a +plugin developer but he has helped here and there with various under-the-hood +things and is one of the few people (other than jemfinch) who understands the +inner workings of Supybot. His biggest plugin contribution (in terms of sheer +lines of code) has been the MoobotFactoids plugin and all the workd involved +in getting that plugin to work, but he has also helped with a lot of testing, +debugging, and brainstorming. He also wrote the Dunno, News, and Todo plugins +and is responsible for a significant amount of code in the Poll, Debian, +QuoteGrabs, Karma, and ChannelDB plugins. + +James Vega (jamessan) is an Electrical Engineering/Computer Science student at +Northeastern University. He wrote the Sourceforge and Ebay plugins as well as +the first incarnation of the Babelfish commands and most of Amazon. He has +also performed a significant amount of maintenance and refactoring of plugins +in general. Some of the plugins that were affected the most are Debian, +FunDB, Gameknot, Http, Note, and Quote. All of the link snarfers, save +Bugzilla's, were also written by jamessan. His meddlings have prompted the +implementation of Toggleables, which eventually evolved to Configurables and +then to the current registry system. As well as being the current webmaster, +he also overhauled the tool which is used to generate the site's HTML +documentation for Supybot and setup the weekly creation of CVS snapshots. + +Brett Kelly (inkedmn) is a hobbyist (soon to be professional :)) coder +from southern California who enjoys collecting tattoos (on his body) and +drinking coffee with his wife. He initially wrote the Note plugin as well +as several commands in the Http plugin. + +Vincent Foley-Bourgon is a recently-graduated student from Quebec who +enjoys anything pointless, unprofitable, and generally useless. Recently +returning to Supybot development (after writing the original freshmeat +command for the Http plugin) he wrote the entire Hangman infrastructure +for the Words plugin. + +Daniel Berlin is a soon to be lawyer with a background in computer science +and compilers. He enjoys selling crack to young homeless orphans, and works +on Supybot when he's not lawyering or hacking on gcc. + +Keith Jones (kmj) dislikes talking about himself in the third person. He +has an MS in Computer Science, and has decided to see how long he can go +without using that in any kind of professional capacity. To that end he +is currently taking some math classes and applying to math Ph.D programs +so some day he can be a professor at a college near a snowy mountain where +he will ski every morning. So far, he hasn't done much for the project +except squeeze Doug Bell's GPL'd unit conversion code into a supybot plugin. + +Stéphan Kochen (G-LiTe) is a lazy (soon to be) computer science student. +He's usually just freelancing and submitting patches here and there when he +bumps into a bug that bothers him, but Supybot is one of the first projects +he semi-actively tries to work on. ;) His biggest contribution has been the +refactoring of the supybot-wizard script to use the registry, though he also +likes to track down those nasty obscure bugs which haunt many of our fine +applications these days. diff --git a/INSTALL b/INSTALL new file mode 100644 index 000000000..eccaba5a8 --- /dev/null +++ b/INSTALL @@ -0,0 +1,110 @@ +So what do you do? That depends on which operating system you're +running. We've split this document up to address the different +methods, so find the section for your operating system and continue +from there. First let's start with the parts that are common to all +OSes. + + +### +# COMMON: +### + +First things first: Supybot *requires* at least Python 2.3. There ain't +no getting around it. We do not require any version greater than 2.3, +but we will be compatible with any version of Python >= 2.3. If you're +a Python developer, you probably know how superior 2.3 is to previous +incarnations. If you're not, just think about the difference between a +bowl of plain vanilla ice cream and a banana split. Or something like +that. Either way, *We're* Python developers and we like banana splits. +So, be sure to install python2.3 or greater before continuing. You can +get it from http://www.python.org/ + +For more information and help on how to use Supybot, checkout +the documents under docs/ (especially GETTING_STARTED and CONFIGURATION). +Our forums (http://forums.supybot.org/) may also be of use, especially +the "Tips and Tricks" topic under "Supybot User Discussion". + + +### +# UNIX/Linux/*BSD: +### + +If you're installing Python using your distributor's packages, you may +need a python-dev package installed, too. If you don't have a +/usr/lib/python2.3/distutils directory (assuming /usr/lib/python2.3 is +where your Python libs are installed), then you will need a python-dev +package. + +After you extract Supybot and cd into the supybot directory just +created, you'll want to run (as root) "python setup.py install". This +will install Supybot globally. If you need to install locally for +whatever reason, see the addendum near the end of this document. +You'll then have several new programs installed where Python scripts +are normally installed on your system (/usr/bin or /usr/local/bin are +common on UNIX systems). The two that might be of particular interest +to you, the new user, are "supybot" and "supybot-wizard". The former +("supybot") is the script to run an actual bot; the latter +("supybot-wizard") is an in-depth wizard that provides a nice user +interface for creating a registry file for your bot. + +So after running supybot-wizard, you've got a nice registry file +handy. If you're not satisfied with your answers to any of the +questions you were asked, feel free to run the program again until +you're satisfied with all your answers. Once you're satisfied, +though, run the "supybot" program with the registry file you created +as an argument. This will start the bot; unless you turned off +logging to stdout, you'll see some nice log messages describing what +the bot is doing at any particular moment; it may pause for a +significant amount of time after saying "Connecting to ..." while the +server tries to check its ident. + + +### +# Windows: +### + +*** If you are using an IPV6 connection, you will not be able to run +Supybot under Windows (unless Python has fixed things). Current +versions of Python for Windows are *not* built with IPV6 support. This +isn't expected to be fixed until Python 2.4, at the earliest. + +Now that you have Python installed, open up a command prompt. The +easiest way to do this is to open the run dialog (Programs -> run) and +type "cmd" (for Windows 2000/XP/2003) or "command" (for Windows 9x). +In order to reduce the amount of typing you need to do, I suggest +adding Python's directory to your path. If you installed Python using +the default settings, you would then do the following in the command +prompt (otherwise change the path to match your settings): + +set PATH=%PATH%;C:\Python23\ + +You should now be able to type "python" to start the Python +interpreter (CTRL-Z and Return to exit). Now that that's setup, +you'll want to cd into the directory that was created when you +unzipped Supybot; I'll assume you unzipped it to C:\Supybot for these +instructions. From C:\Supybot, run "python setup.py install". This +will install Supybot under C:\Python23\. If you want to install +Supybot to a non-default location, see the addendum near the end of +this document. You will now have several new programs installed in +C:\Python23\Scripts\. The two that might be of particular interest to +you, the new user, are "supybot" and "supybot-wizard". The former +("supybot") is the script to run an actual bot; the latter +("supybot-wizard") is an in-depth wizard that provides a nice user +interface for creating a registry file for your bot. + +Now you will want to run "python C:\Python23\Scripts\supybot-wizard" +to generate a registry file for your bot. So after running +supybot-wizard, you've got a nice registry file handy. If you're not +satisfied with your answers to any of the questions you were asked, +feel free to run the program again until you're satisfied with all +your answers. Once you're satisfied, though, run "python +C:\Python23\Scripts\supybot botname.conf". This will start the bot; +unless you turned off logging to stdout, you'll see some nice log +messages describing what the bot is doing at any particular moment; it +may pause for a significant amount of time after saying "Connecting +to ..." while the server tries to check its ident. + +### +# Addenda +### +Local installs: See this forum post: http://tinyurl.com/2tb37 diff --git a/LICENSE b/LICENSE new file mode 100644 index 000000000..7753327e9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,28 @@ +Copyright (c) 2002-2004 Jeremiah Fincher and others +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, + this list of conditions, and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions, and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of the author of this software nor the name of + contributors to this software may be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +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 COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +Portions of the included source code are copyright by its original author(s) +and remain subject to its associated license. diff --git a/README b/README new file mode 100644 index 000000000..2347a26c9 --- /dev/null +++ b/README @@ -0,0 +1,38 @@ +EVERYONE: +--------- +Read LICENSE. It's a 2-clause BSD license, but you should read it anyway. + + +USERS: +------ +If you're upgrading, read RELNOTES. If you're new to Supybot, +read docs/GETTING_STARTED for an introduction to the bot, and read +docs/CAPABILITIES to see how to use capabilities to your greater +benefit. + +If you have any trouble, feel free to swing by #supybot on +irc.freenode.net or irc.oftc.net (we have a Supybot there relaying, +so either network works) and ask questions. We'll be happy to help +wherever we can. And by all means, if you find anything hard to +understand or think you know of a better way to do something, +*please* post it on Sourceforge.net so we can improve the bot! + +WINDOWS USERS: +-------------- +The wizards (supybot-wizard, supybot-newplugin, and +supybot-adduser) are all installed to your Python directory's +\Scripts. What that *probably* means is that you'll run them like +this: C:\Python23\python C:\Python23\Scripts\supybot-wizard + + +DEVELOPERS: +----------- +Read OVERVIEW to see what the modules are used for. Read PLUGIN-EXAMPLE +to see some examples of callbacks and commands written for the bot. +Read INTERFACES to see what kinds of objects you'll be dealing with. +Read STYLE if you wish to contribute; all contributed code must meet +the guidelines set forth there. + +Be sure to run "test/test.py --help" to see what options are available +to you when testing. Windows users in particular should be sure to +exclude test_Debian.py and test_Unix.py. diff --git a/RELNOTES b/RELNOTES new file mode 100644 index 000000000..2d7e5aefa --- /dev/null +++ b/RELNOTES @@ -0,0 +1,257 @@ +Version 0.80.0 + +We *finally* hit 0.80.0! This release is completely compatible with +the last release candidate. + +An update to Babelfish may cause an error message to be displayed in +the console when the bot is first run. The error message should be +gone when the bot is restarted. + +We also have a new community website at http://www.supybot.com/ where +our users can submit their own plugins, view/download other people's +plugins and discuss all things Supybot-related. + + +Version 0.80.0rc3 + +Another bugfix release. This one was pretty important as it actually +makes supybot.database.plugins.channelSpecific work properly. + + +Version 0.80.0rc2 + +supybot.databases.plugins.channelSpecific.channel was renamed to +supybot.databases.plugins.channelSpecific.link. + +supybot.databases.plugins.channelSpecific.link.allow was added, which +determines whether a channel will allow other channels to link to its +database. + +Infobot is no longer deprecated and the following changes were made to +its config variables: +supybot.plugins.Infobot.answerUnaddressedQuestions was renamed to +supybot.plugins.Infobot.unaddressed.answerQuestions. +supybot.plugins.Infobot.snarfUnaddressedDefinitions was renamed to +supybot.plugins.Infobot.unaddressed.snarfDefinitions. +supybot.plugins.Infobot.unaddressed.replyExistingFactoid was added to +determine whether the bot will reply when someone attempts to create a +duplicate factoid. + + +Version 0.80.0pre6 + +Another bugfix release. No incompatibilities known. The only +registry change is that supybot.databases.users.hash has been +removed. + + +Version 0.80.0pre5 + +Completely bugfix release. No incompatibilies known. + + +Version 0.80.0pre4 + +Mainly a bug fix release. This will likely be the last release before +0.80.0final, but we're gonna let it stew for a couple weeks to attempt +to catch any lingering bugs. + +ansycoreDrivers is now deprecated in favor of socketDrivers or +twistedDrivers. + +supybot.databases.plugins.channelSpecific.channel is now a channelValue +so that you can link specific channels together (instead of all channels +being linked together). + +For those of you that use eval and/or exec, they have been removed from +the Owner plugin and are now in sandbox/Debug.py (which you'll have to +grab from CVS). + + +Version 0.80.0pre3 + +The database format for the Note plugin has changed to a flatfile +format; use tools/noteConvert.py to convert it to the new format. + +Ditto that for the URL database. + +FunDB is deprecated and will be removed at the next major release; +use tools/fundbConvert.py to convert your old FunDB databases to Lart +and Praise databases. + +If you had turned off supybot.databases.plugins.channelSpecific, your +non-channel-specific database files had gone directly into your data/ +directory. We had some problems with poor interactions between that +configuration variable and channel capabilities, though, so we +changed the implementation so that non-channel-specific databases are +considered databases of a single (configurable) channel (defaulting +to "#"). This will also help others who are converting from +channel-specific to non-channel-specific databases, but for you +who've already made the switch, you'll need to move your database +files again, from data/ to data/# (or whatever channel you might +change that variable to). + +supybot.channels doesn't exist anymore; now the only list of channels +to join is per-network, in supybot.networks..channels. + +We weren't serializing supybot.replies.* properly in older versions. +Now we are, but the old, improperly serialized versions won't work +properly. Remove from your configuration file all variables +beginning with "supybot.replies" before you start the bot. + +The URL database has been changed again, but it will use a different +filename so you shouldn't run into conflicts, just a newly-empty +database. + +We upgraded the SOAP stuff in others; you may do well to do a +setup.py install --clean this time around. + + +Version 0.80.0pre2 + +Many more bugs have been fixed. A few more plugins have been updated +to use our new-style database abstraction. If it seems like your +databases are suddenly empty, look for a new database file named +Plugin.dbtype.db. We've also added a few more configuration variables. + + +Version 0.80.0pre1 + +Tons of bugs fixed, many features and plugins added. Everything +should be entirely compatible; many more configuration variables have +been added. + + +Version 0.79.9999 + +Some more bugs fixed, added a few features and a couple configuration +variabless. This should hopefully be the last release before 0.80.0, +which will finally bring us to pure Beta status. + + +Version 0.79.999 + +Some bugs fixed, but the ones that were fixed were pretty big. This +is, of course, completely compatible with the last release. + + +Version 0.79.99 + +Many bugs fixed, thanks to the users who reported them. We're +getting asymptotically closer to 0.80.0 -- maybe this'll be the last +one, maybe we'll have to release an 0.79.999 -- either way, we're +getting close :) Check out the ChangeLog for the fixes and a few new +features. + + +Version 0.79.9 + +We've changed so much stuff in this release that we've given up on +users upgrading their configuration files for the new release. So +do a clean install (python2.3 setup.py install --clean), run the +wizard again, and kick some butt. + +(It's rumored that you can save most of your old configuration by +appending your new configuration at the end of your old configuration +and running supybot with that new configuration file. This, of +course, comes with no warranty or guarantee of utility -- try it if +you want, but backup your original configuration file!) + + +Version 0.77.2 + +This is a drop-in replacement for 0.77.1, with two exceptions. The +configuration variable formerly known as +"supybot.plugins.Services.password" is now known as +"supybot.plugins.Services.NickServ.password", due to the fact that +there might be different passwords for NickServ and ChanServ (and +ChanServ passwords are per-channel, whereas NickServ passwords are +global). If you're using the Services plugin, you'll need to make +this change in order to continue identifying with services. The +configuration variable formerly known as +"supybot.plugins.Babelfish.disabledLanguages" is now known as +"supybot.plugins.Babelfish.languages". The configuration variable now +accepts the languages that *will* be translated as opposed to ones +that are *not* translated. + +Tests and the developer sandbox are not longer delivered with our +release tarballs. If you're a developer and you want these, you +should either check out CVS or download one of our weekly CVS +snapshots, available at http://supybot.sourceforge.net/snapshots/ . + + +Version 0.77.1 + +This is a drop-in replacement for 0.77.0 -- no incompatibilities, to +out knowledge. Simply install over your old installation and restart +your bot :) + + +Version 0.77.0 + +Setup.py will automatically remove your old installations for you, no +need to worry about that yourself. + +Configuration has been *entirely* redone. Read the new +GETTING_STARTED document to see how to work with configuration +variables now. Your old botscripts from earlier versions *will not* +work with the new configuration method. We'd appreciate it if you'd +rerun the wizard in order for us to find any bugs that remain in it +before we officially declare ourselves Beta. Note also that because +of the new configuration method, the interface for plugins' configure +function has changed: there are no longer any onStart or afterConnect +arguments, so all configuration should be performed via the registry. + +Channel capabilities have been changed; rather than being +#channel.capability, they're now #channel,capability. It's a bit +uglier, we know, but dots can be valid in channel names, and we +needed the dot for handling plugin.command capabilities. +tools/ircdbConvert.py should update this for you. + +The on-disk format of the user/channel databases has changed to be far +more readable. A conversion utility is included, as mentioned before: +tools/ircdbConvert.py. Run this with no arguments to see the +directions for using it. + +Uh, we were just kidding about the upgrade script in 0.76.0 :) It'll +be a little while longer. We do have several little upgrade scripts, +though. + + +Version 0.76.1 + +Almost entirely bugfixes, just some minor (and some less minor) bugs +that need to get in before we really start hacking on the next +version. Should be *entirely* compatible with 0.76.0. + + +Version 0.76.0 + +Major bugfix release. A great number of bugs fixed. This is the last +release without an upgrade script. + +The only hiccup in the upgrade from 0.75.0 should be that you'll need +to update your botscript to reflect the removal of the debug module. +We'd rather you use supybot-wizard to generate a new botscript, of +course, but if you insist on modifying your existing botscript, take a +look at + +to see what you need to do. + + +Version 0.75.0 + +Don't forget to reinstall (i.e., run "python setup.py install" as +root). Sometimes it even does good to remove the old installation; +$PYTHON/site-packages/supybot can be removed with no problems +whatsoever. + +You will need to re-run supybot-wizard and generate a new botscript. + +The Infobot plugin has been removed from this release; it's not ready +for prime time. If you're interested in getting it running (i.e., you +want full Infobot compatibility and aren't satisfied with either +MoobotFactoids or Factoids) then swing over to #supybot and we can +discuss the tests. We simply don't know enough about Infobot to make +sure our Infobot plugin is an exact replica, and need someone's help +with making the changes necessary for that. diff --git a/plugins/Admin/__init__.py b/plugins/Admin/__init__.py new file mode 100644 index 000000000..38a07ff5f --- /dev/null +++ b/plugins/Admin/__init__.py @@ -0,0 +1,46 @@ +### +# Copyright (c) 2004, Jeremiah Fincher +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND 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 COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +### + +import supybot + +__author__ = supybot.authors.jemfinch + +# This is a dictionary mapping supybot.Author instances to lists of +# contributions. +__contributors__ = {} + +import config +import plugin +reload(plugin) # In case we're being reloaded. + +if hasattr(plugin, '__doc__') and plugin.__doc__: + __doc__ = plugin.__doc__ + +Class = plugin.Class +configure = config.configure diff --git a/plugins/Admin/config.py b/plugins/Admin/config.py new file mode 100644 index 000000000..2bbf4b6f3 --- /dev/null +++ b/plugins/Admin/config.py @@ -0,0 +1,48 @@ +### +# Copyright (c) 2004, Jeremiah Fincher +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND 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 COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +### + + +import supybot.conf as conf +import supybot.registry as registry + +def configure(advanced): + # This will be called by supybot to configure this module. advanced is + # a bool that specifies whether the user identified himself as an advanced + # user or not. You should effect your configuration by manipulating the + # registry as appropriate. + from supybot.questions import expect, anything, something, yn + conf.registerPlugin('Admin', True) + + +Admin = conf.registerPlugin('Admin') +# This is where your configuration variables (if any) should go. For example: +# conf.registerGlobalValue(Admin, 'someConfigVariableName', +# registry.Boolean(False, """Help for someConfigVariableName.""")) + +# vim:set shiftwidth=4 tabstop=8 expandtab textwidth=78 diff --git a/plugins/Admin/plugin.py b/plugins/Admin/plugin.py new file mode 100644 index 000000000..4997c7f70 --- /dev/null +++ b/plugins/Admin/plugin.py @@ -0,0 +1,349 @@ +### +# Copyright (c) 2002-2004, Jeremiah Fincher +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND 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 COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +### + +""" +These are commands useful for administrating the bot; they all require their +caller to have the 'admin' capability. This plugin is loaded by default. +""" + +import supybot + +__author__ = supybot.authors.jemfinch + +import supybot.fix as fix + +import sys +import time +import pprint +from itertools import imap + +import supybot.log as log +import supybot.conf as conf +import supybot.ircdb as ircdb +import supybot.utils as utils +from supybot.commands import * +import supybot.ircmsgs as ircmsgs +import supybot.ircutils as ircutils +import supybot.privmsgs as privmsgs +import supybot.schedule as schedule +import supybot.callbacks as callbacks + +class Admin(callbacks.Privmsg): + def __init__(self): + self.__parent = super(Admin, self) + self.__parent.__init__() + self.joins = {} + self.pendingNickChanges = {} + + def do437(self, irc, msg): + """Nick/channel temporarily unavailable.""" + target = msg.args[0] + if irc.isChannel(target): # We don't care about nicks. + t = time.time() + 30 + # Let's schedule a rejoin. + networkGroup = conf.supybot.networks.get(irc.network) + def rejoin(): + irc.queueMsg(networkGroup.channels.join(target)) + # We don't need to schedule something because we'll get another + # 437 when we try to join later. + schedule.addEvent(rejoin, t) + self.log.info('Scheduling a rejoin to %s at %s; ' + 'Channel temporarily unavailable.', target, t) + + def do471(self, irc, msg): + try: + channel = msg.args[1] + (irc, msg) = self.joins.pop(channel) + irc.error('Cannot join %s, it\'s full.' % channel) + except KeyError: + self.log.debug('Got 471 without Admin.join being called.') + + def do473(self, irc, msg): + try: + channel = msg.args[1] + (irc, msg) = self.joins.pop(channel) + irc.error('Cannot join %s, I was not invited.' % channel) + except KeyError: + self.log.debug('Got 473 without Admin.join being called.') + + def do474(self, irc, msg): + try: + channel = msg.args[1] + (irc, msg) = self.joins.pop(channel) + irc.error('Cannot join %s, it\'s banned me.' % channel) + except KeyError: + self.log.debug('Got 474 without Admin.join being called.') + + def do475(self, irc, msg): + try: + channel = msg.args[1] + (irc, msg) = self.joins.pop(channel) + irc.error('Cannot join %s, my keyword was wrong.' % channel) + except KeyError: + self.log.debug('Got 475 without Admin.join being called.') + + def do515(self, irc, msg): + try: + channel = msg.args[1] + (irc, msg) = self.joins.pop(channel) + irc.error('Cannot join %s, I\'m not identified with the NickServ.' + % channel) + except KeyError: + self.log.debug('Got 515 without Admin.join being called.') + + def doJoin(self, irc, msg): + if msg.prefix == irc.prefix: + try: + del self.joins[msg.args[0]] + except KeyError: + s = 'Joined a channel without Admin.join being called.' + self.log.debug(s) + + def doInvite(self, irc, msg): + channel = msg.args[1] + if channel not in irc.state.channels: + if conf.supybot.alwaysJoinOnInvite() or \ + ircdb.checkCapability(msg.prefix, 'admin'): + self.log.info('Invited to %s by %s.', channel, msg.prefix) + networkGroup = conf.supybot.networks.get(irc.network) + irc.queueMsg(networkGroup.channels.join(channel)) + conf.supybot.networks.get(irc.network).channels().add(channel) + else: + self.log.warning('Invited to %s by %s, but ' + 'supybot.alwaysJoinOnInvite was False and ' + 'the user lacked the "admin" capability.', + channel, msg.prefix) + + def join(self, irc, msg, args, channel, key): + """ [] + + Tell the bot to join the given channel. If is given, it is used + when attempting to join the channel. + """ + if not irc.isChannel(channel): + irc.errorInvalid('channel', channel, Raise=True) + networkGroup = conf.supybot.networks.get(irc.network) + networkGroup.channels().add(channel) + if key: + networkGroup.channels.key.get(channel).setValue(key) + maxchannels = irc.state.supported.get('maxchannels', sys.maxint) + if len(irc.state.channels) + 1 > maxchannels: + irc.error('I\'m already too close to maximum number of ' + 'channels for this network.', Raise=True) + irc.queueMsg(networkGroup.channels.join(channel)) + irc.noReply() + self.joins[channel] = (irc, msg) + join = wrap(join, ['validChannel', additional('something')]) + + def channels(self, irc, msg, args): + """takes no arguments + + Returns the channels the bot is on. Must be given in private, in order + to protect the secrecy of secret channels. + """ + L = irc.state.channels.keys() + if L: + utils.sortBy(ircutils.toLower, L) + irc.reply(utils.commaAndify(L)) + else: + irc.reply('I\'m not currently in any channels.') + channels = wrap(channels, ['private']) + + def do484(self, irc, msg): + irc = self.pendingNickChanges.get(irc, None) + if irc is not None: + irc.error('My connection is restricted, I can\'t change nicks.') + else: + self.log.debug('Got 484 without Admin.nick being called.') + + def do433(self, irc, msg): + irc = self.pendingNickChanges.get(irc, None) + if irc is not None: + irc.error('Someone else is already using that nick.') + else: + self.log.debug('Got 433 without Admin.nick being called.') + + def do435(self, irc, msg): + irc = self.pendingNickChanges.get(irc, None) + if irc is not None: + irc.error('That nick is currently banned.') + else: + self.log.debug('Got 435 without Admin.nick being called.') + + def do438(self, irc, msg): + irc = self.pendingNickChanges.get(irc, None) + if irc is not None: + irc.error('I can\'t change nicks, the server said %s.' % + utils.quoted(msg.args[2]), private=True) + else: + self.log.debug('Got 438 without Admin.nick being called.') + + def doNick(self, irc, msg): + if msg.nick == irc.nick or msg.args[0] == irc.nick: + try: + del self.pendingNickChanges[irc] + except KeyError: + self.log.debug('Got NICK without Admin.nick being called.') + + def nick(self, irc, msg, args, nick): + """[] + + Changes the bot's nick to . If no nick is given, returns the + bot's current nick. + """ + if nick: + conf.supybot.nick.setValue(nick) + irc.queueMsg(ircmsgs.nick(nick)) + self.pendingNickChanges[irc.getRealIrc()] = irc + else: + irc.reply(irc.nick) + nick = wrap(nick, [additional('nick')]) + + def part(self, irc, msg, args, channel, reason): + """[] [] + + Tells the bot to part the list of channels you give it. is + only necessary if you want the bot to part a channel other than the + current channel. If is specified, use it as the part + message. + """ + if channel is None: + if irc.isChannel(msg.args[0]): + channel = msg.args[0] + else: + irc.error(Raise=True) + try: + network = conf.supybot.networks.get(irc.network) + network.channels().remove(channel) + except KeyError: + pass + if channel not in irc.state.channels: + irc.error('I\'m not in %s.' % channel, Raise=True) + irc.queueMsg(ircmsgs.part(channel, reason or msg.nick)) + if msg.nick in irc.state.channels[channel].users: + irc.noReply() + else: + irc.replySuccess() + part = wrap(part, [optional('validChannel'), additional('text')]) + + def addcapability(self, irc, msg, args, user, capability): + """ + + Gives the user specified by (or the user to whom + currently maps) the specified capability + """ + # Ok, the concepts that are important with capabilities: + # + ### 1) No user should be able to elevate his privilege to owner. + ### 2) Admin users are *not* superior to #channel.ops, and don't + ### have God-like powers over channels. + ### 3) We assume that Admin users are two things: non-malicious and + ### and greedy for power. So they'll try to elevate their privilege + ### to owner, but they won't try to crash the bot for no reason. + + # Thus, the owner capability can't be given in the bot. Admin users + # can only give out capabilities they have themselves (which will + # depend on supybot.capabilities and its child default) but generally + # means they can't mess with channel capabilities. + if ircutils.strEqual(capability, 'owner'): + irc.error('The "owner" capability can\'t be added in the bot. ' + 'Use the supybot-adduser program (or edit the ' + 'users.conf file yourself) to add an owner capability.') + return + if ircdb.isAntiCapability(capability) or \ + ircdb.checkCapability(msg.prefix, capability): + user.addCapability(capability) + ircdb.users.setUser(user) + irc.replySuccess() + else: + irc.error('You can\'t add capabilities you don\'t have.') + addcapability = wrap(addcapability, ['otherUser', 'lowered']) + + def removecapability(self, irc, msg, args, user, capability): + """ + + Takes from the user specified by (or the user to whom + currently maps) the specified capability + """ + if ircdb.checkCapability(msg.prefix, capability) or \ + ircdb.isAntiCapability(capability): + try: + user.removeCapability(capability) + ircdb.users.setUser(user) + irc.replySuccess() + except KeyError: + irc.error('That user doesn\'t have that capability.') + else: + s = 'You can\'t remove capabilities you don\'t have.' + irc.error(s) + removecapability = wrap(removecapability, ['otherUser','lowered']) + + def ignore(self, irc, msg, args, hostmask, expires): + """ [] + + Ignores or, if a nick is given, ignores whatever hostmask + that nick is currently using. is a "seconds from now" value + that determines when the ignore will expire; if, for instance, you wish + for the ignore to expire in an hour, you could give an of + 3600. If no is given, the ignore will never automatically + expire. + """ + ircdb.ignores.add(hostmask, expires) + irc.replySuccess() + ignore = wrap(ignore, ['hostmask', additional('expiry', 0)]) + + def unignore(self, irc, msg, args, hostmask): + """ + + Ignores or, if a nick is given, ignores whatever hostmask + that nick is currently using. + """ + try: + ircdb.ignores.remove(hostmask) + irc.replySuccess() + except KeyError: + irc.error('%s wasn\'t in the ignores database.' % hostmask) + unignore = wrap(unignore, ['hostmask']) + + def ignores(self, irc, msg, args): + """takes no arguments + + Returns the hostmasks currently being globally ignored. + """ + # XXX Add the expirations. + if ircdb.ignores.hostmasks: + irc.reply(utils.commaAndify(imap(repr, ircdb.ignores.hostmasks))) + else: + irc.reply('I\'m not currently globally ignoring anyone.') + ignores = wrap(ignores) + + +Class = Admin + +# vim:set shiftwidth=4 tabstop=8 expandtab textwidth=78: diff --git a/plugins/Admin/test.py b/plugins/Admin/test.py new file mode 100644 index 000000000..17979d36a --- /dev/null +++ b/plugins/Admin/test.py @@ -0,0 +1,117 @@ +### +# Copyright (c) 2002-2004, Jeremiah Fincher +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND 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 COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +### + +from supybot.test import * + +class AdminTestCase(PluginTestCase): + plugins = ('Admin',) + def testChannels(self): + def getAfterJoinMessages(): + m = self.irc.takeMsg() + self.assertEqual(m.command, 'MODE') + m = self.irc.takeMsg() + self.assertEqual(m.command, 'WHO') + self.assertRegexp('channels', 'not.*in any') + self.irc.feedMsg(ircmsgs.join('#foo', prefix=self.prefix)) + getAfterJoinMessages() + self.assertRegexp('channels', '#foo') + self.irc.feedMsg(ircmsgs.join('#bar', prefix=self.prefix)) + getAfterJoinMessages() + self.assertRegexp('channels', '#bar and #foo') + self.irc.feedMsg(ircmsgs.join('#Baz', prefix=self.prefix)) + getAfterJoinMessages() + self.assertRegexp('channels', '#bar, #Baz, and #foo') + + def testIgnoreUnignore(self): + self.assertNotError('admin ignore foo!bar@baz') + self.assertError('admin ignore alsdkfjlasd') + self.assertNotError('admin unignore foo!bar@baz') + self.assertError('admin unignore foo!bar@baz') + + def testIgnores(self): + self.assertNotError('admin ignores') + self.assertNotError('admin ignore foo!bar@baz') + self.assertNotError('admin ignores') + self.assertNotError('admin ignore foo!bar@baz') + self.assertNotError('admin ignores') + + def testAddcapability(self): + self.assertError('addcapability sdlkfj foo') + u = ircdb.users.newUser() + u.name = 'foo' + ircdb.users.setUser(u) + self.assertError('removecapability foo bar') + self.assertNotRegexp('removecapability foo bar', 'find') + + def testRemoveCapability(self): + self.assertError('removecapability alsdfkjasd foo') + + def testJoin(self): + m = self.getMsg('join #foo') + self.assertEqual(m.command, 'JOIN') + self.assertEqual(m.args[0], '#foo') + m = self.getMsg('join #foo key') + self.assertEqual(m.command, 'JOIN') + self.assertEqual(m.args[0], '#foo') + self.assertEqual(m.args[1], 'key') + + def testPart(self): + def getAfterJoinMessages(): + m = self.irc.takeMsg() + self.assertEqual(m.command, 'MODE') + m = self.irc.takeMsg() + self.assertEqual(m.command, 'WHO') + self.assertError('part #foo') + self.assertRegexp('part #foo', 'not in') + self.irc.feedMsg(ircmsgs.join('#foo', prefix=self.prefix)) + getAfterJoinMessages() + m = self.getMsg('part #foo') + self.assertEqual(m.command, 'PART') + self.irc.feedMsg(ircmsgs.join('#foo', prefix=self.prefix)) + getAfterJoinMessages() + m = self.getMsg('part #foo reason') + self.assertEqual(m.command, 'PART') + self.assertEqual(m.args[0], '#foo') + self.assertEqual(m.args[1], 'reason') + + def testNick(self): + original = conf.supybot.nick() + try: + m = self.getMsg('nick foobar') + self.assertEqual(m.command, 'NICK') + self.assertEqual(m.args[0], 'foobar') + finally: + conf.supybot.nick.setValue(original) + + def testAddCapabilityOwner(self): + self.assertError('admin addcapability %s owner' % self.nick) + + +# vim:set shiftwidth=4 tabstop=8 expandtab textwidth=78: + diff --git a/plugins/Channel/__init__.py b/plugins/Channel/__init__.py new file mode 100644 index 000000000..fb0b518d2 --- /dev/null +++ b/plugins/Channel/__init__.py @@ -0,0 +1,49 @@ +### +# Copyright (c) 2004, Jeremiah Fincher +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND 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 COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +### + +import supybot + +__author__ = supybot.authors.jemfinch + +# This is a dictionary mapping supybot.Author instances to lists of +# contributions. +__contributors__ = { + supybot.authors.skorobeus: ['enable', 'disable'], + } + +import config +import plugin +reload(plugin) # In case we're being reloaded. + +# Backwards compatibility. +if hasattr(plugin, '__doc__') and plugin.__doc__: + __doc__ = plugin.__doc__ + +Class = plugin.Class +configure = config.configure diff --git a/plugins/Channel/config.py b/plugins/Channel/config.py new file mode 100644 index 000000000..7090b2c79 --- /dev/null +++ b/plugins/Channel/config.py @@ -0,0 +1,82 @@ +### +# Copyright (c) 2004, Jeremiah Fincher +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND 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 COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +### + + +import supybot.conf as conf +import supybot.utils as utils +import supybot.registry as registry + +def configure(advanced): + # This will be called by supybot to configure this module. advanced is + # a bool that specifies whether the user identified himself as an advanced + # user or not. You should effect your configuration by manipulating the + # registry as appropriate. + from supybot.questions import expect, anything, something, yn + conf.registerPlugin('Channel', True) + +class BanmaskStyle(registry.SpaceSeparatedSetOfStrings): + validStrings = ('exact', 'nick', 'user', 'host') + def __init__(self, *args, **kwargs): + assert self.validStrings, 'There must be some valid strings. ' \ + 'This is a bug.' + registry.SpaceSeparatedSetOfStrings.__init__(self, *args, **kwargs) + self.__doc__ = 'Valid values include %s.' % \ + utils.commaAndify(map(repr, self.validStrings)) + + def help(self): + strings = [s for s in self.validStrings if s] + return '%s Valid strings: %s.' % \ + (self._help, utils.commaAndify(strings)) + + def normalize(self, s): + lowered = s.lower() + L = list(map(str.lower, self.validStrings)) + try: + i = L.index(lowered) + except ValueError: + return s # This is handled in setValue. + return self.validStrings[i] + + def setValue(self, v): + v = map(self.normalize, v) + for s in v: + if s not in self.validStrings: + self.error() + registry.SpaceSeparatedSetOfStrings.setValue(self, self.List(v)) + +Channel = conf.registerPlugin('Channel') +conf.registerChannelValue(Channel, 'alwaysRejoin', + registry.Boolean(True, """Determines whether the bot will always try to + rejoin a channel whenever it's kicked from the channel.""")) +conf.registerChannelValue(Channel, 'banmask', + BanmaskStyle(['user', 'host'], """Determines what will be used as the + default banmask style.""")) + + +# vim:set shiftwidth=4 tabstop=8 expandtab textwidth=78 diff --git a/plugins/Channel/plugin.py b/plugins/Channel/plugin.py new file mode 100644 index 000000000..feb0339f6 --- /dev/null +++ b/plugins/Channel/plugin.py @@ -0,0 +1,822 @@ +### +# Copyright (c) 2002-2004, Jeremiah Fincher +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND 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 COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +### + +""" +Basic channel management commands. Many of these commands require their caller +to have the .op capability. This plugin is loaded by default. +""" + +import sys +import time + +from itertools import imap + +import supybot.conf as conf +import supybot.ircdb as ircdb +import supybot.utils as utils +from supybot.commands import * +import supybot.ircmsgs as ircmsgs +import supybot.schedule as schedule +import supybot.ircutils as ircutils +import supybot.callbacks as callbacks + +class Channel(callbacks.Privmsg): + def __init__(self): + callbacks.Privmsg.__init__(self) + self.invites = {} + + def doKick(self, irc, msg): + channel = msg.args[0] + if msg.args[1] == irc.nick: + if self.registryValue('alwaysRejoin', channel): + networkGroup = conf.supybot.networks.get(irc.network) + irc.sendMsg(networkGroup.channels.join(channel)) + + def _sendMsg(self, irc, msg): + irc.queueMsg(msg) + irc.noReply() + + def mode(self, irc, msg, args, channel, modes): + """[] [ ...] + + Sets the mode in to , sending the arguments given. + is only necessary if the message isn't sent in the channel + itself. + """ + self._sendMsg(irc, ircmsgs.mode(channel, modes)) + mode = wrap(mode, + [('checkChannelCapability', 'op'), + ('haveOp', 'change the mode'), + many('something')]) + + def limit(self, irc, msg, args, channel, limit): + """[] [] + + Sets the channel limit to . If is 0, or isn't given, + removes the channel limit. is only necessary if the message + isn't sent in the channel itself. + """ + if limit: + self._sendMsg(irc, ircmsgs.mode(channel, ['+l', limit])) + else: + self._sendMsg(irc, ircmsgs.mode(channel, ['-l'])) + limit = wrap(limit, [('checkChannelCapability', 'op'), + ('haveOp', 'change the limit'), + additional('nonNegativeInt', 0)]) + + def moderate(self, irc, msg, args, channel): + """[] + + Sets +m on , making it so only ops and voiced users can + send messages to the channel. is only necessary if the + message isn't sent in the channel itself. + """ + self._sendMsg(irc, ircmsgs.mode(channel, ['+m'])) + moderate = wrap(moderate, [('checkChannelCapability', 'op'), + ('haveOp', 'moderate the channel')]) + + def unmoderate(self, irc, msg, args, channel): + """[] + + Sets -m on , making it so everyone can + send messages to the channel. is only necessary if the + message isn't sent in the channel itself. + """ + self._sendMsg(irc, ircmsgs.mode(channel, ['-m'])) + unmoderate = wrap(unmoderate, [('checkChannelCapability', 'op'), + ('haveOp', 'unmoderate the channel')]) + + def key(self, irc, msg, args, channel, key): + """[] [] + + Sets the keyword in to . If is not given, removes + the keyword requirement to join . is only necessary + if the message isn't sent in the channel itself. + """ + networkGroup = conf.supybot.networks.get(irc.network) + networkGroup.channels.key.get(channel).setValue(key) + if key: + self._sendMsg(irc, ircmsgs.mode(channel, ['+k', key])) + else: + self._sendMsg(irc, ircmsgs.mode(channel, ['-k'])) + key = wrap(key, [('checkChannelCapability', 'op'), + ('haveOp', 'change the keyword'), + additional('somethingWithoutSpaces', '')]) + + def op(self, irc, msg, args, channel, nicks): + """[] [ ...] + + If you have the #channel,op capability, this will give all the s + you provide ops. If you don't provide any s, this will op you. + is only necessary if the message isn't sent in the channel + itself. + """ + if not nicks: + nicks = [msg.nick] + self._sendMsg(irc, ircmsgs.ops(channel, nicks)) + op = wrap(op, [('checkChannelCapability', 'op'), + ('haveOp', 'op someone'), + any('nickInChannel')]) + + def halfop(self, irc, msg, args, channel, nicks): + """[] [ ...] + + If you have the #channel,halfop capability, this will give all the + s you provide halfops. If you don't provide any s, this + will give you halfops. is only necessary if the message isn't + sent in the channel itself. + """ + if not nicks: + nicks = [msg.nick] + self._sendMsg(irc, ircmsgs.halfops(channel, nicks)) + halfop = wrap(halfop, [('checkChannelCapability', 'halfop'), + ('haveOp', 'halfop someone'), + any('nickInChannel')]) + + def voice(self, irc, msg, args, channel, nicks): + """[] [ ...] + + If you have the #channel,voice capability, this will voice all the + s you provide. If you don't provide any s, this will + voice you. is only necessary if the message isn't sent in the + channel itself. + """ + if nicks: + if len(nicks) == 1 and msg.nick in nicks: + capability = 'voice' + else: + capability = 'op' + else: + nicks = [msg.nick] + capability = 'voice' + capability = ircdb.makeChannelCapability(channel, capability) + if ircdb.checkCapability(msg.prefix, capability): + self._sendMsg(irc, ircmsgs.voices(channel, nicks)) + else: + irc.errorNoCapability(capability) + voice = wrap(voice, ['channel', ('haveOp', 'voice someone'), + any('nickInChannel')]) + + def deop(self, irc, msg, args, channel, nicks): + """[] [ ...] + + If you have the #channel,op capability, this will remove operator + privileges from all the nicks given. If no nicks are given, removes + operator privileges from the person sending the message. + """ + if irc.nick in nicks: + irc.error('I cowardly refuse to deop myself. If you really want ' + 'me deopped, tell me to op you and then deop me ' + 'yourself.', Raise=True) + if not nicks: + nicks = [msg.nick] + self._sendMsg(irc, ircmsgs.deops(channel, nicks)) + deop = wrap(deop, [('checkChannelCapability', 'op'), + ('haveOp', 'deop someone'), + any('nickInChannel')]) + + def dehalfop(self, irc, msg, args, channel, nicks): + """[] [ ...] + + If you have the #channel,op capability, this will remove half-operator + privileges from all the nicks given. If no nicks are given, removes + half-operator privileges from the person sending the message. + """ + if irc.nick in nicks: + irc.error('I cowardly refuse to dehalfop myself. If you really ' + 'want me dehalfopped, tell me to op you and then ' + 'dehalfop me yourself.', Raise=True) + if not nicks: + nicks = [msg.nick] + self._sendMsg(irc, ircmsgs.dehalfops(channel, nicks)) + dehalfop = wrap(dehalfop, [('checkChannelCapability', 'halfop'), + ('haveOp', 'dehalfop someone'), + any('nickInChannel')]) + + # XXX We should respect the MODES part of an 005 here. Helper function + # material. + def devoice(self, irc, msg, args, channel, nicks): + """[] [ ...] + + If you have the #channel,op capability, this will remove voice from all + the nicks given. If no nicks are given, removes voice from the person + sending the message. + """ + if irc.nick in nicks: + irc.error('I cowardly refuse to devoice myself. If you really ' + 'want me devoiced, tell me to op you and then devoice ' + 'me yourself.', Raise=True) + if not nicks: + nicks = [msg.nick] + self._sendMsg(irc, ircmsgs.devoices(channel, nicks)) + devoice = wrap(devoice, [('checkChannelCapability', 'voice'), + ('haveOp', 'devoice someone'), + any('nickInChannel')]) + + def cycle(self, irc, msg, args, channel): + """[] + + If you have the #channel,op capability, this will cause the bot to + "cycle", or PART and then JOIN the channel. is only necessary + if the message isn't sent in the channel itself. + """ + self._sendMsg(irc, ircmsgs.part(channel, msg.nick)) + networkGroup = conf.supybot.networks.get(irc.network) + self._sendMsg(irc, networkGroup.channels.join(channel)) + cycle = wrap(cycle, [('checkChannelCapability','op')]) + + def kick(self, irc, msg, args, channel, nick, reason): + """[] [] + + Kicks from for . If isn't given, + uses the nick of the person making the command as the reason. + is only necessary if the message isn't sent in the channel + itself. + """ + if ircutils.strEqual(nick, irc.nick): + irc.error('I cowardly refuse to kick myself.', Raise=True) + if not reason: + reason = msg.nick + kicklen = irc.state.supported.get('kicklen', sys.maxint) + if len(reason) > kicklen: + irc.error('The reason you gave is longer than the allowed ' + 'length for a KICK reason on this server.') + return + self._sendMsg(irc, ircmsgs.kick(channel, nick, reason)) + kick = wrap(kick, [('checkChannelCapability', 'op'), + ('haveOp', 'kick someone'), + 'nickInChannel', + additional('text')]) + + def kban(self, irc, msg, args, + channel, optlist, bannedNick, expiry, reason): + """[] [--{exact,nick,user,host}] [] [] + + If you have the #channel,op capability, this will kickban for + as many seconds as you specify, or else (if you specify 0 seconds or + don't specify a number of seconds) it will ban the person indefinitely. + --exact bans only the exact hostmask; --nick bans just the nick; + --user bans just the user, and --host bans just the host. You can + combine these options as you choose. is a reason to give for + the kick. + is only necessary if the message isn't sent in the channel + itself. + """ + # Check that they're not trying to make us kickban ourself. + self.log.debug('In kban') + if not irc.isNick(bannedNick): + self.log.warning('%s tried to kban a non nick: %s', + utils.quoted(msg.prefix), + utils.quoted(bannedNick)) + raise callbacks.ArgumentError + elif bannedNick == irc.nick: + self.log.warning('%s tried to make me kban myself.', + utils.quoted(msg.prefix)) + irc.error('I cowardly refuse to kickban myself.') + return + if not reason: + reason = msg.nick + try: + bannedHostmask = irc.state.nickToHostmask(bannedNick) + except KeyError: + irc.error('I haven\'t seen %s.' % bannedNick, Raise=True) + capability = ircdb.makeChannelCapability(channel, 'op') + def makeBanmask(bannedHostmask, options): + (nick, user, host) = ircutils.splitHostmask(bannedHostmask) + self.log.debug('*** nick: %s' % nick) + self.log.debug('*** user: %s' % user) + self.log.debug('*** host: %s' % host) + bnick = '*' + buser = '*' + bhost = '*' + for option in options: + if option == 'nick': + bnick = nick + elif option == 'user': + buser = user + elif option == 'host': + bhost = host + elif option == 'exact': + (bnick, buser, bhost) = \ + ircutils.splitHostmask(bannedHostmask) + return ircutils.joinHostmask(bnick, buser, bhost) + if optlist: + banmask = makeBanmask(bannedHostmask, [o[0] for o in optlist]) + else: + banmask = makeBanmask(bannedHostmask, + self.registryValue('banmask', channel)) + # Check (again) that they're not trying to make us kickban ourself. + if ircutils.hostmaskPatternEqual(banmask, irc.prefix): + if ircutils.hostmaskPatternEqual(banmask, irc.prefix): + self.log.warning('%s tried to make me kban myself.', + utils.quoted(msg.prefix)) + irc.error('I cowardly refuse to ban myself.') + return + else: + banmask = bannedHostmask + # Now, let's actually get to it. Check to make sure they have + # #channel,op and the bannee doesn't have #channel,op; or that the + # bannee and the banner are both the same person. + def doBan(): + if irc.state.channels[channel].isOp(bannedNick): + irc.queueMsg(ircmsgs.deop(channel, bannedNick)) + irc.queueMsg(ircmsgs.ban(channel, banmask)) + irc.queueMsg(ircmsgs.kick(channel, bannedNick, reason)) + if expiry > 0: + def f(): + if channel in irc.state.channels and \ + banmask in irc.state.channels[channel].bans: + irc.queueMsg(ircmsgs.unban(channel, banmask)) + schedule.addEvent(f, expiry) + if bannedNick == msg.nick: + doBan() + elif ircdb.checkCapability(msg.prefix, capability): + if ircdb.checkCapability(bannedHostmask, capability): + self.log.warning('%s tried to ban %s, but both have %s', + msg.prefix, utils.quoted(bannedHostmask), + capability) + irc.error('%s has %s too, you can\'t ban him/her/it.' % + (bannedNick, capability)) + else: + doBan() + else: + self.log.warning('%s attempted kban without %s', + utils.quoted(msg.prefix), capability) + irc.errorNoCapability(capability) + exact,nick,user,host + kban = wrap(kban, + [('checkChannelCapability', 'op'), + getopts({'exact':'', 'nick':'', 'user':'', 'host':''}), + ('haveOp', 'kick or ban someone'), + 'nickInChannel', + optional('expiry', 0), + additional('text')]) + + def unban(self, irc, msg, args, channel, hostmask): + """[] [] + + Unbans on . If is not given, unbans + any hostmask currently banned on that matches your current + hostmask. Especially useful for unbanning yourself when you get + unexpectedly (or accidentally) banned from the channel. is + only necessary if the message isn't sent in the channel itself. + """ + if hostmask: + self._sendMsg(irc, ircmsgs.unban(channel, hostmask)) + else: + bans = [] + for banmask in irc.state.channels[channel].bans: + if ircutils.hostmaskPatternEqual(banmask, msg.prefix): + bans.append(banmask) + if bans: + irc.queueMsg(ircmsgs.unbans(channel, bans)) + irc.replySuccess('All bans on %s matching %s ' + 'have been removed.' % (channel, msg.prefix)) + else: + irc.error('No bans matching %s were found on %s.' % + (msg.prefix, channel)) + unban = wrap(unban, [('checkChannelCapability', 'op'), + ('haveOp', 'unban someone'), + additional('hostmask')]) + + def invite(self, irc, msg, args, channel, nick): + """[] + + If you have the #channel,op capability, this will invite + to join . is only necessary if the message isn't + sent in the channel itself. + """ + self._sendMsg(irc, ircmsgs.invite(nick or msg.nick, channel)) + self.invites[(irc.getRealIrc(), ircutils.toLower(nick))] = irc + invite = wrap(invite, [('checkChannelCapability', 'op'), + ('haveOp', 'invite someone'), + additional('nick')]) + + def do341(self, irc, msg): + (_, nick, channel) = msg.args + nick = ircutils.toLower(nick) + replyIrc = self.invites.pop((irc, nick), None) + if replyIrc is not None: + self.log.info('Inviting %s to %s by command of %s.', + nick, channel, replyIrc.msg.prefix) + replyIrc.replySuccess() + else: + self.log.info('Inviting %s to %s.', nick, channel) + + def do443(self, irc, msg): + (_, nick, channel, _) = msg.args + nick = ircutils.toLower(nick) + replyIrc = self.invites.pop((irc, nick), None) + if replyIrc is not None: + replyIrc.error('%s is already in %s.' % (nick, channel)) + + def do401(self, irc, msg): + nick = msg.args[1] + nick = ircutils.toLower(nick) + replyIrc = self.invites.pop((irc, nick), None) + if replyIrc is not None: + replyIrc.error('There is no %s on this network.' % nick) + + def do504(self, irc, msg): + nick = msg.args[1] + nick = ircutils.toLower(nick) + replyIrc = self.invites.pop((irc, nick), None) + if replyirc is not None: + replyIrc.error('There is no %s on this server.' % nick) + + def lobotomize(self, irc, msg, args, channel): + """[] + + If you have the #channel,op capability, this will "lobotomize" the + bot, making it silent and unanswering to all requests made in the + channel. is only necessary if the message isn't sent in the + channel itself. + """ + c = ircdb.channels.getChannel(channel) + c.lobotomized = True + ircdb.channels.setChannel(channel, c) + irc.replySuccess() + lobotomize = wrap(lobotomize, [('checkChannelCapability', 'op')]) + + def unlobotomize(self, irc, msg, args, channel): + """[] + + If you have the #channel,op capability, this will unlobotomize the bot, + making it respond to requests made in the channel again. + is only necessary if the message isn't sent in the channel + itself. + """ + c = ircdb.channels.getChannel(channel) + c.lobotomized = False + ircdb.channels.setChannel(channel, c) + irc.replySuccess() + unlobotomize = wrap(unlobotomize, [('checkChannelCapability', 'op')]) + + def permban(self, irc, msg, args, channel, banmask, expires): + """[] [] + + If you have the #channel,op capability, this will effect a permanent + (persistent) ban from interacting with the bot on the given + (or the current hostmask associated with . Other plugins may + enforce this ban by actually banning users with matching hostmasks when + they join. is an optional argument specifying when (in + "seconds from now") the ban should expire; if none is given, the ban + will never automatically expire. is only necessary if the + message isn't sent in the channel itself. + """ + c = ircdb.channels.getChannel(channel) + c.addBan(banmask, expires) + ircdb.channels.setChannel(channel, c) + irc.replySuccess() + permban = wrap(permban, [('checkChannelCapability', 'op'), + 'hostmask', + additional('expiry', 0)]) + + def unpermban(self, irc, msg, args, channel, banmask): + """[] + + If you have the #channel,op capability, this will remove the permanent + ban on . is only necessary if the message isn't + sent in the channel itself. + """ + c = ircdb.channels.getChannel(channel) + c.removeBan(banmask) + ircdb.channels.setChannel(channel, c) + irc.replySuccess() + unpermban = wrap(unpermban, [('checkChannelCapability', 'op'), 'hostmask']) + + def permbans(self, irc, msg, args, channel): + """[] + + If you have the #channel,op capability, this will show you the + current bans on #channel. + """ + # XXX Add the expirations. + c = ircdb.channels.getChannel(channel) + if c.bans: + irc.reply(utils.commaAndify(map(utils.dqrepr, c.bans))) + else: + irc.reply('There are currently no permanent bans on %s' % channel) + permbans = wrap(permbans, [('checkChannelCapability', 'op')]) + + def ignore(self, irc, msg, args, channel, banmask, expires): + """[] [] + + If you have the #channel,op capability, this will set a permanent + (persistent) ignore on or the hostmask currently associated + with . is an optional argument specifying when (in + "seconds from now") the ignore will expire; if it isn't given, the + ignore will never automatically expire. is only necessary + if the message isn't sent in the channel itself. + """ + c = ircdb.channels.getChannel(channel) + c.addIgnore(banmask, expires) + ircdb.channels.setChannel(channel, c) + irc.replySuccess() + ignore = wrap(ignore, [('checkChannelCapability', 'op'), + 'hostmask', additional('expiry', 0)]) + + def unignore(self, irc, msg, args, channel, banmask): + """[] + + If you have the #channel,op capability, this will remove the permanent + ignore on in the channel. is only necessary if the + message isn't sent in the channel itself. + """ + c = ircdb.channels.getChannel(channel) + c.removeIgnore(banmask) + ircdb.channels.setChannel(channel, c) + irc.replySuccess() + unignore = wrap(unignore, [('checkChannelCapability', 'op'), 'hostmask']) + + def ignores(self, irc, msg, args, channel): + """[] + + Lists the hostmasks that the bot is ignoring on the given channel. + is only necessary if the message isn't sent in the channel + itself. + """ + # XXX Add the expirations. + c = ircdb.channels.getChannel(channel) + if len(c.ignores) == 0: + s = 'I\'m not currently ignoring any hostmasks in %s' % \ + utils.quoted(channel) + irc.reply(s) + else: + L = sorted(c.ignores) + irc.reply(utils.commaAndify(imap(repr, L))) + ignores = wrap(ignores, [('checkChannelCapability', 'op')]) + + def addcapability(self, irc, msg, args, channel, user, capabilities): + """[] [ ...] + + If you have the #channel,op capability, this will give the user + (or the user to whom maps) + the capability in the channel. is only necessary + if the message isn't sent in the channel itself. + """ + for c in capabilities.split(): + c = ircdb.makeChannelCapability(channel, c) + user.addCapability(c) + ircdb.users.setUser(user) + irc.replySuccess() + addcapability = wrap(addcapability, [('checkChannelCapability', 'op'), + 'otherUser', 'capability']) + + def removecapability(self, irc, msg, args, channel, user, capabilities): + """[] [ ...] + + If you have the #channel,op capability, this will take from the user + currently identified as (or the user to whom maps) + the capability in the channel. is only necessary + if the message isn't sent in the channel itself. + """ + fail = [] + for c in capabilities.split(): + cap = ircdb.makeChannelCapability(channel, c) + try: + user.removeCapability(cap) + except KeyError: + fail.append(c) + ircdb.users.setUser(user) + if fail: + irc.error('That user didn\'t have the %s %s.' % + (utils.commaAndify(fail), + utils.pluralize('capability', len(fail))), Raise=True) + irc.replySuccess() + removecapability = wrap(removecapability, + [('checkChannelCapability', 'op'), + 'otherUser', 'capability']) + + # XXX This needs to be fix0red to be like Owner.defaultcapability. Or + # something else. This is a horrible interface. + def setdefaultcapability(self, irc, msg, args, channel, v): + """[] {True|False} + + If you have the #channel,op capability, this will set the default + response to non-power-related (that is, not {op, halfop, voice} + capabilities to be the value you give. is only necessary if + the message isn't sent in the channel itself. + """ + c = ircdb.channels.getChannel(channel) + if v: + c.setDefaultCapability(True) + else: + c.setDefaultCapability(False) + ircdb.channels.setChannel(channel, c) + irc.replySuccess() + setdefaultcapability = wrap(setdefaultcapability, + [('checkChannelCapability', 'op'), 'boolean']) + + def setcapability(self, irc, msg, args, channel, capabilities): + """[] [ ...] + + If you have the #channel,op capability, this will add the channel + capability for all users in the channel. is + only necessary if the message isn't sent in the channel itself. + """ + chan = ircdb.channels.getChannel(channel) + for c in capabilities: + chan.addCapability(c) + ircdb.channels.setChannel(channel, chan) + irc.replySuccess() + setcapability = wrap(setcapability, + [('checkChannelCapability', 'op'), many('capability')]) + + def unsetcapability(self, irc, msg, args, channel, capabilities): + """[] [ ...] + + If you have the #channel,op capability, this will unset the channel + capability so each user's specific capability or the + channel default capability will take precedence. is only + necessary if the message isn't sent in the channel itself. + """ + chan = ircdb.channels.getChannel(channel) + fail = [] + for c in capabilities: + try: + chan.removeCapability(c) + except KeyError: + fail.append(c) + ircdb.channels.setChannel(channel, chan) + if fail: + irc.error('I do not know about the %s %s.' % + (utils.commaAndify(fail), + utils.pluralize('capability', len(fail))), Raise=True) + irc.replySuccess() + unsetcapability = wrap(unsetcapability, + [('checkChannelCapability', 'op'), + many('capability')]) + + def capabilities(self, irc, msg, args, channel): + """[] + + Returns the capabilities present on the . is only + necessary if the message isn't sent in the channel itself. + """ + c = ircdb.channels.getChannel(channel) + L = sorted(c.capabilities) + irc.reply(' '.join(L)) + capabilities = wrap(capabilities, ['channel']) + + def disable(self, irc, msg, args, channel, plugin, command): + """[] [] [] + + If you have the #channel,op capability, this will disable the + in . If is provided, will be disabled only + for that plugin. If only is provided, all commands in the + given plugin will be disabled. is only necessary if the + message isn't sent in the channel itself. + """ + chan = ircdb.channels.getChannel(channel) + failMsg = '' + if plugin: + s = '-%s' % plugin.name() + if command: + if plugin.isCommand(command): + s = '-%s.%s' % (plugin.name(), command) + else: + failMsg = 'The %s plugin does not have a command called %s.'\ + % (plugin.name(), command) + elif command: + # findCallbackForCommand + if irc.findCallbackForCommand(command): + s = '-%s' % command + else: + failMsg = 'No plugin or command named %s could be found.'\ + % (command) + else: + raise callbacks.ArgumentError + if failMsg: + irc.error(failMsg) + else: + chan.addCapability(s) + ircdb.channels.setChannel(channel, chan) + irc.replySuccess() + disable = wrap(disable, [('checkChannelCapability', 'op'), + optional(('plugin', False)), + additional('commandName')]) + + def enable(self, irc, msg, args, channel, plugin, command): + """[] [] [] + + If you have the #channel,op capability, this will enable the + in if it has been disabled. If is provided, + will be enabled only for that plugin. If only is + provided, all commands in the given plugin will be enabled. + is only necessary if the message isn't sent in the channel itself. + """ + chan = ircdb.channels.getChannel(channel) + failMsg = '' + if plugin: + s = '-%s' % plugin.name() + if command: + if plugin.isCommand(command): + s = '-%s.%s' % (plugin.name(), command) + else: + failMsg = 'The %s plugin does not have a command called %s.'\ + % (plugin.name(), command) + elif command: + # findCallbackForCommand + if irc.findCallbackForCommand(command): + s = '-%s' % command + else: + failMsg = 'No plugin or command named %s could be found.'\ + % (command) + else: + raise callbacks.ArgumentError + if failMsg: + irc.error(failMsg) + else: + fail = [] + try: + chan.removeCapability(s) + except KeyError: + fail.append(s) + ircdb.channels.setChannel(channel, chan) + if fail: + irc.error('%s was not disabled.' % s[1:]) + else: + irc.replySuccess() + enable = wrap(enable, [('checkChannelCapability', 'op'), + optional(('plugin', False)), + additional('commandName')]) + + def lobotomies(self, irc, msg, args): + """takes no arguments + + Returns the channels in which this bot is lobotomized. + """ + L = [] + for (channel, c) in ircdb.channels.iteritems(): + if c.lobotomized: + L.append(channel) + if L: + L.sort() + s = 'I\'m currently lobotomized in %s.' % utils.commaAndify(L) + irc.reply(s) + else: + irc.reply('I\'m not currently lobotomized in any channels.') + + def nicks(self, irc, msg, args, channel): + """[] + + Returns the nicks in . is only necessary if the + message isn't sent in the channel itself. + """ + L = list(irc.state.channels[channel].users) + utils.sortBy(str.lower, L) + irc.reply(utils.commaAndify(L)) + nicks = wrap(nicks, ['inChannel']) # XXX Check that the caller is in chan. + + def alertOps(self, irc, channel, s, frm=None): + """Internal message for notifying all the #channel,ops in a channel of + a given situation.""" + capability = ircdb.makeChannelCapability(channel, 'op') + s = 'Alert to all %s ops: %s' % (channel, s) + if frm is not None: + s += ' (from %s)' % frm + for nick in irc.state.channels[channel].users: + hostmask = irc.state.nickToHostmask(nick) + if ircdb.checkCapability(hostmask, capability): + irc.reply(s, to=nick, private=True) + + def alert(self, irc, msg, args, channel, text): + """[] + + Sends to all the users in who have the ,op + capability. + """ + self.alertOps(irc, channel, text, frm=msg.nick) + alert = wrap(alert, [('checkChannelCapability', 'op'), 'text']) + + +Class = Channel + +# vim:set shiftwidth=4 tabstop=8 expandtab textwidth=78: diff --git a/plugins/Channel/test.py b/plugins/Channel/test.py new file mode 100644 index 000000000..8b86564a1 --- /dev/null +++ b/plugins/Channel/test.py @@ -0,0 +1,190 @@ +### +# Copyright (c) 2002-2004, Jeremiah Fincher +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND 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 COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +### + +from supybot.test import * + +import supybot.conf as conf +import supybot.ircdb as ircdb +import supybot.ircmsgs as ircmsgs + +class ChannelTestCase(ChannelPluginTestCase): + plugins = ('Channel', 'User') + + def setUp(self): + super(ChannelTestCase, self).setUp() + self.irc.state.channels[self.channel].addUser('foo') + self.irc.state.channels[self.channel].addUser('bar') + + def testLobotomies(self): + self.assertRegexp('lobotomies', 'not.*any') + +## def testCapabilities(self): +## self.prefix = 'foo!bar@baz' +## self.irc.feedMsg(ircmsgs.privmsg(self.irc.nick, 'register foo bar', +## prefix=self.prefix)) +## u = ircdb.users.getUser(0) +## u.addCapability('%s.op' % self.channel) +## ircdb.users.setUser(u) +## self.assertNotError(' ') +## self.assertResponse('user capabilities foo', '[]') +## self.assertNotError('channel addcapability foo op') +## self.assertRegexp('channel capabilities foo', 'op') +## self.assertNotError('channel removecapability foo op') +## self.assertResponse('user capabilities foo', '[]') + + def testCapabilities(self): + self.assertNotError('channel capabilities') + self.assertNotError('channel setcapability -foo') + self.assertNotError('channel unsetcapability -foo') + self.assertError('channel unsetcapability -foo') + self.assertNotError('channel setcapability -foo bar baz') + self.assertRegexp('channel capabilities', 'baz') + self.assertNotError('channel unsetcapability -foo baz') + self.assertError('channel unsetcapability baz') + + def testEnableDisable(self): + self.assertNotRegexp('channel capabilities', '-Channel') + self.assertError('channel enable channel') + self.assertNotError('channel disable channel') + self.assertRegexp('channel capabilities', '-Channel') + self.assertNotError('channel enable channel') + self.assertNotRegexp('channel capabilities', '-Channel') + self.assertNotError('channel disable channel nicks') + self.assertRegexp('channel capabilities', '-Channel.nicks') + self.assertNotError('channel enable channel nicks') + self.assertNotRegexp('channel capabilities', '-Channel.nicks') + self.assertNotRegexp('channel capabilities', 'nicks') + self.assertNotError('channel disable nicks') + self.assertRegexp('channel capabilities', 'nicks') + self.assertNotError('channel enable nicks') + self.assertError('channel disable invalidPlugin') + self.assertError('channel disable channel invalidCommand') + + def testUnban(self): + self.assertError('unban foo!bar@baz') + self.irc.feedMsg(ircmsgs.op(self.channel, self.nick)) + m = self.getMsg('unban foo!bar@baz') + self.assertEqual(m.command, 'MODE') + self.assertEqual(m.args, (self.channel, '-b', 'foo!bar@baz')) + self.assertNoResponse(' ', 2) + + def testErrorsWithoutOps(self): + for s in 'op deop halfop dehalfop voice devoice kick invite'.split(): + self.assertError('%s foo' % s) + self.irc.feedMsg(ircmsgs.op(self.channel, self.nick)) + self.assertNotError('%s foo' % s) + self.irc.feedMsg(ircmsgs.deop(self.channel, self.nick)) + + def testWontDeItself(self): + for s in 'deop dehalfop devoice'.split(): + self.irc.feedMsg(ircmsgs.op(self.channel, self.nick)) + self.assertError('%s %s' % (s, self.nick)) + + def testOp(self): + self.assertError('op') + self.irc.feedMsg(ircmsgs.op(self.channel, self.nick)) + self.assertNotError('op') + m = self.getMsg('op foo') + self.failUnless(m.command == 'MODE' and + m.args == (self.channel, '+o', 'foo')) + m = self.getMsg('op foo bar') + self.failUnless(m.command == 'MODE' and + m.args == (self.channel, '+oo', 'foo', 'bar')) + + def testHalfOp(self): + self.assertError('halfop') + self.irc.feedMsg(ircmsgs.op(self.channel, self.nick)) + self.assertNotError('halfop') + m = self.getMsg('halfop foo') + self.failUnless(m.command == 'MODE' and + m.args == (self.channel, '+h', 'foo')) + m = self.getMsg('halfop foo bar') + self.failUnless(m.command == 'MODE' and + m.args == (self.channel, '+hh', 'foo', 'bar')) + + def testVoice(self): + self.assertError('voice') + self.irc.feedMsg(ircmsgs.op(self.channel, self.nick)) + self.assertNotError('voice') + m = self.getMsg('voice foo') + self.failUnless(m.command == 'MODE' and + m.args == (self.channel, '+v', 'foo')) + m = self.getMsg('voice foo bar') + self.failUnless(m.command == 'MODE' and + m.args == (self.channel, '+vv', 'foo', 'bar')) + + def assertBan(self, query, hostmask, **kwargs): + m = self.getMsg(query, **kwargs) + self.assertEqual(m, ircmsgs.ban(self.channel, hostmask)) + m = self.getMsg(' ') + self.assertEqual(m.command, 'KICK') + +## def testKban(self): +## self.irc.prefix = 'something!else@somehwere.else' +## self.irc.nick = 'something' +## self.irc.feedMsg(ircmsgs.join(self.channel, +## prefix='foobar!user@host.domain.tld')) +## self.assertError('kban foobar') +## self.irc.feedMsg(ircmsgs.op(self.channel, self.irc.nick)) +## self.assertError('kban foobar -1') +## self.assertBan('kban foobar', '*!*@*.domain.tld') +## self.assertBan('kban --exact foobar', 'foobar!user@host.domain.tld') +## self.assertBan('kban --host foobar', '*!*@host.domain.tld') +## self.assertBan('kban --user foobar', '*!user@*') +## self.assertBan('kban --nick foobar', 'foobar!*@*') +## self.assertBan('kban --nick --user foobar', 'foobar!user@*') +## self.assertBan('kban --nick --host foobar', 'foobar!*@host.domain.tld') +## self.assertBan('kban --user --host foobar', '*!user@host.domain.tld') +## self.assertBan('kban --nick --user --host foobar', +## 'foobar!user@host.domain.tld') +## self.assertNotRegexp('kban adlkfajsdlfkjsd', 'KeyError') +## self.assertNotRegexp('kban foobar time', 'ValueError') +## self.assertError('kban %s' % self.irc.nick) + + def testPermban(self): + self.assertNotError('permban foo!bar@baz') + self.assertNotError('unpermban foo!bar@baz') + orig = conf.supybot.protocols.irc.strictRfc() + try: + conf.supybot.protocols.irc.strictRfc.setValue(True) + # something wonky is going on here. irc.error (src/Channel.py|449) + # is being called but the assert is failing + self.assertError('permban not!a.hostmask') + self.assertNotRegexp('permban not!a.hostmask', 'KeyError') + finally: + conf.supybot.protocols.irc.strictRfc.setValue(orig) + + def testIgnore(self): + self.assertNotError('Channel ignore foo!bar@baz') + self.assertResponse('Channel ignores', "'foo!bar@baz'") + self.assertNotError('Channel unignore foo!bar@baz') + self.assertError('permban not!a.hostmask') + +# vim:set shiftwidth=4 tabstop=8 expandtab textwidth=78: + diff --git a/plugins/Config/__init__.py b/plugins/Config/__init__.py new file mode 100644 index 000000000..62999b91e --- /dev/null +++ b/plugins/Config/__init__.py @@ -0,0 +1,47 @@ +### +# Copyright (c) 2004, Jeremiah Fincher +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND 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 COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +### + +import supybot + +__author__ = supybot.authors.jemfinch + +# This is a dictionary mapping supybot.Author instances to lists of +# contributions. +__contributors__ = {} + +import config +import plugin +reload(plugin) # In case we're being reloaded. + +# Backwards compatibility. +if hasattr(plugin, '__doc__') and plugin.__doc__: + __doc__ = plugin.__doc__ + +Class = plugin.Class +configure = config.configure diff --git a/plugins/Config/config.py b/plugins/Config/config.py new file mode 100644 index 000000000..67e58f9b1 --- /dev/null +++ b/plugins/Config/config.py @@ -0,0 +1,48 @@ +### +# Copyright (c) 2004, Jeremiah Fincher +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND 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 COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +### + + +import supybot.conf as conf +import supybot.registry as registry + +def configure(advanced): + # This will be called by supybot to configure this module. advanced is + # a bool that specifies whether the user identified himself as an advanced + # user or not. You should effect your configuration by manipulating the + # registry as appropriate. + from supybot.questions import expect, anything, something, yn + conf.registerPlugin('Config', True) + + +Config = conf.registerPlugin('Config') +# This is where your configuration variables (if any) should go. For example: +# conf.registerGlobalValue(Config, 'someConfigVariableName', +# registry.Boolean(False, """Help for someConfigVariableName.""")) + +# vim:set shiftwidth=4 tabstop=8 expandtab textwidth=78 diff --git a/plugins/Config/plugin.py b/plugins/Config/plugin.py new file mode 100644 index 000000000..3a49fb530 --- /dev/null +++ b/plugins/Config/plugin.py @@ -0,0 +1,270 @@ +### +# Copyright (c) 2002-2004, Jeremiah Fincher +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND 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 COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +### + +""" +Handles configuration of the bot while it is running. +""" + +import os +import getopt +import signal + +import supybot.log as log +import supybot.conf as conf +import supybot.utils as utils +import supybot.world as world +import supybot.ircdb as ircdb +from supybot.commands import * +import supybot.ircutils as ircutils +import supybot.registry as registry +import supybot.callbacks as callbacks + +### +# Now, to setup the registry. +### + +def getWrapper(name): + parts = registry.split(name) + if not parts or parts[0] not in ('supybot', 'users'): + raise InvalidRegistryName, name + group = getattr(conf, parts.pop(0)) + while parts: + try: + group = group.get(parts.pop(0)) + # We'll catch registry.InvalidRegistryName and re-raise it here so + # that we have a useful error message for the user. + except (registry.NonExistentRegistryEntry, + registry.InvalidRegistryName): + raise registry.InvalidRegistryName, name + return group + +def getCapability(name): + capability = 'owner' # Default to requiring the owner capability. + parts = registry.split(name) + while parts: + part = parts.pop() + if ircutils.isChannel(part): + # If a registry value has a channel in it, it requires a channel.op + # capability, or so we assume. We'll see if we're proven wrong. + capability = ircdb.makeChannelCapability(part, 'op') + ### Do more later, for specific capabilities/sections. + return capability + +def _reload(): + ircdb.users.reload() + ircdb.channels.reload() + registry.open(world.registryFilename) + +def _hupHandler(sig, frame): + log.info('Received SIGHUP, reloading configuration.') + _reload() + +if os.name == 'posix': + signal.signal(signal.SIGHUP, _hupHandler) + + +def getConfigVar(irc, msg, args, state): + name = args[0] + if name.startswith('conf.'): + name = name[5:] + if not name.startswith('supybot') and not name.startswith('users'): + name = 'supybot.' + name + try: + group = getWrapper(name) + state.args.append(group) + del args[0] + except registry.InvalidRegistryName, e: + irc.errorInvalid('configuration variable', str(e)) +addConverter('configVar', getConfigVar) + +class Config(callbacks.Privmsg): + def callCommand(self, name, irc, msg, *L, **kwargs): + try: + super(Config, self).callCommand(name, irc, msg, *L, **kwargs) + except registry.InvalidRegistryValue, e: + irc.error(str(e)) + + def _list(self, group): + L = [] + for (vname, v) in group._children.iteritems(): + if hasattr(group, 'channelValue') and group.channelValue and \ + ircutils.isChannel(vname) and not v._children: + continue + if hasattr(v, 'channelValue') and v.channelValue: + vname = '#' + vname + if v._added and not all(ircutils.isChannel, v._added): + vname = '@' + vname + L.append(vname) + utils.sortBy(str.lower, L) + return L + + def list(self, irc, msg, args, group): + """ + + Returns the configuration variables available under the given + configuration . If a variable has values under it, it is + preceded by an '@' sign. If a variable is a 'ChannelValue', that is, + it can be separately configured for each channel using the 'channel' + command in this plugin, it is preceded by an '#' sign. + """ + L = self._list(group) + if L: + irc.reply(utils.commaAndify(L)) + else: + irc.error('There don\'t seem to be any values in %s.' % group._name) + list = wrap(list, ['configVar']) + + def search(self, irc, msg, args, word): + """ + + Searches for in the current configuration variables. + """ + L = [] + for (name, _) in conf.supybot.getValues(getChildren=True): + if word in name.lower(): + possibleChannel = registry.split(name)[-1] + if not ircutils.isChannel(possibleChannel): + L.append(name) + if L: + irc.reply(utils.commaAndify(L)) + else: + irc.reply('There were no matching configuration variables.') + search = wrap(search, ['lowered']) # XXX compose with withoutSpaces? + + def _getValue(self, irc, msg, group): + value = str(group) or ' ' + if hasattr(group, 'value'): + if not group._private: + irc.reply(value) + else: + capability = getCapability(group._name) + if ircdb.checkCapability(msg.prefix, capability): + irc.reply(value, private=True) + else: + irc.errorNoCapability(capability) + else: + irc.error('That registry variable has no value. Use the list ' + 'command in this plugin to see what variables are ' + 'available in this group.') + + def _setValue(self, irc, msg, group, value): + capability = getCapability(group._name) + if ircdb.checkCapability(msg.prefix, capability): + # I think callCommand catches exceptions here. Should it? + group.set(value) + irc.replySuccess() + else: + irc.errorNoCapability(capability) + + def channel(self, irc, msg, args, channel, group, value): + """[] [] + + If is given, sets the channel configuration variable for + to for . Otherwise, returns the current channel + configuration value of . is only necessary if the + message isn't sent in the channel itself.""" + if not group.channelValue: + irc.error('That configuration variable is not a channel-specific ' + 'configuration variable.') + return + group = group.get(channel) + if value is not None: + self._setValue(irc, msg, group, value) + else: + self._getValue(irc, msg, group) + channel = wrap(channel, ['channel', 'configVar', additional('text')]) + + def config(self, irc, msg, args, group, value): + """ [] + + If is given, sets the value of to . Otherwise, + returns the current value of . You may omit the leading + "supybot." in the name if you so choose. + """ + if value is not None: + self._setValue(irc, msg, group, value) + else: + self._getValue(irc, msg, group) + config = wrap(config, ['configVar', additional('text')]) + + def help(self, irc, msg, args, group): + """ + + Returns the description of the configuration variable . + """ + if hasattr(group, '_help'): + s = group.help() + if s: + if hasattr(group, 'value') and not group._private: + s += ' (Current value: %s)' % group + irc.reply(s) + else: + irc.reply('That configuration group exists, but seems to have ' + 'no help. Try "config list %s" to see if it has ' + 'any children values.') + else: + irc.error('%s has no help.' % name) + help = wrap(help, ['configVar']) + + def default(self, irc, msg, args, group): + """ + + Returns the default value of the configuration variable . + """ + v = group.__class__(group._default, '') + irc.reply(str(v)) + default = wrap(default, ['configVar']) + + def reload(self, irc, msg, args): + """takes no arguments + + Reloads the various configuration files (user database, channel + database, registry, etc.). + """ + _reload() # This was factored out for SIGHUP handling. + irc.replySuccess() + reload = wrap(reload, [('checkCapability', 'owner')]) + + def export(self, irc, msg, args, filename): + """ + + Exports the public variables of your configuration to . + If you want to show someone your configuration file, but you don't + want that person to be able to see things like passwords, etc., this + command will export a "sanitized" configuration file suitable for + showing publicly. + """ + registry.close(conf.supybot, filename, private=False) + irc.replySuccess() + export = wrap(export, [('checkCapability', 'owner'), 'filename']) + + +Class = Config + +# vim:set shiftwidth=4 tabstop=8 expandtab textwidth=78: diff --git a/plugins/Config/test.py b/plugins/Config/test.py new file mode 100644 index 000000000..94c222003 --- /dev/null +++ b/plugins/Config/test.py @@ -0,0 +1,83 @@ +### +# Copyright (c) 2002-2004, Jeremiah Fincher +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND 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 COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +### + +from supybot.test import * + +import supybot.conf as conf + +class ConfigTestCase(ChannelPluginTestCase): + # We add utilities so there's something in supybot.plugins. + plugins = ('Config', 'Ebay') + def testGet(self): + self.assertNotRegexp('config get supybot.reply', r'registry\.Group') + self.assertResponse('config supybot.protocols.irc.throttleTime', '0.0') + + def testList(self): + self.assertError('config list asldfkj') + self.assertError('config list supybot.asdfkjsldf') + self.assertNotError('config list supybot') + self.assertNotError('config list supybot.replies') + self.assertRegexp('config list supybot', r'@plugins.*@replies.*@reply') + + def testHelp(self): + self.assertError('config help alsdkfj') + self.assertError('config help supybot.alsdkfj') + self.assertNotError('config help supybot') # We tell the user to list. + self.assertNotError('config help supybot.plugins') + self.assertNotError('config help supybot.replies.success') + self.assertNotError('config help replies.success') + + def testHelpDoesNotAssertionError(self): + self.assertNotRegexp('config help ' # Cont'd. + 'supybot.commands.defaultPlugins.help', + 'AssertionError') + + def testHelpExhaustively(self): + L = conf.supybot.getValues(getChildren=True) + for (name, v) in L: + self.assertNotError('config help %s' % name) + + def testSearch(self): + self.assertNotError('config search chars') + self.assertNotError('config channel reply.whenAddressedBy.chars @') + self.assertNotRegexp('config search chars', self.channel) + + def testDefault(self): + self.assertNotError('config default ' + 'supybot.replies.genericNoCapability') + + def testConfigErrors(self): + self.assertRegexp('config supybot.replies.', 'not a valid') + self.assertRegexp('config supybot.repl', 'not a valid') + self.assertRegexp('config supybot.reply.withNickPrefix 123', + 'True or False') + + +# vim:set shiftwidth=4 tabstop=8 expandtab textwidth=78: + diff --git a/plugins/Misc/__init__.py b/plugins/Misc/__init__.py new file mode 100644 index 000000000..c9cf79cda --- /dev/null +++ b/plugins/Misc/__init__.py @@ -0,0 +1,49 @@ +### +# Copyright (c) 2004, Jeremiah Fincher +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND 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 COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +### + +import supybot + +__author__ = supybot.authors.jemfinch + +# This is a dictionary mapping supybot.Author instances to lists of +# contributions. +__contributors__ = { + supybot.authors.skorobeus: ['contributors'], + } + +import config +import plugin +reload(plugin) # In case we're being reloaded. + +# Backwards compatibility. +if hasattr(plugin, '__doc__') and plugin.__doc__: + __doc__ = plugin.__doc__ + +Class = plugin.Class +configure = config.configure diff --git a/plugins/Misc/config.py b/plugins/Misc/config.py new file mode 100644 index 000000000..91a6f15fe --- /dev/null +++ b/plugins/Misc/config.py @@ -0,0 +1,63 @@ +### +# Copyright (c) 2004, Jeremiah Fincher +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND 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 COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +### + +import supybot.conf as conf +import supybot.registry as registry + +def configure(advanced): + # This will be called by supybot to configure this module. advanced is + # a bool that specifies whether the user identified himself as an advanced + # user or not. You should effect your configuration by manipulating the + # registry as appropriate. + from supybot.questions import expect, anything, something, yn + conf.registerPlugin('Misc', True) + +Misc = conf.registerPlugin('Misc') +conf.registerGlobalValue(Misc, 'listPrivatePlugins', + registry.Boolean(True, """Determines whether the bot will list private + plugins with the list command if given the --private switch. If this is + disabled, non-owner users should be unable to see what private plugins + are loaded.""")) +conf.registerGlobalValue(Misc, 'timestampFormat', + registry.String('[%H:%M:%S]', """Determines the format string for + timestamps in the Misc.last command. Refer to the Python documentation + for the time module to see what formats are accepted. If you set this + variable to the empty string, the timestamp will not be shown.""")) +conf.registerGroup(Misc, 'last') +conf.registerGroup(Misc.last, 'nested') +conf.registerChannelValue(Misc.last.nested, + 'includeTimestamp', registry.Boolean(False, """Determines whether or not + the timestamp will be included in the output of last when it is part of a + nested command""")) +conf.registerChannelValue(Misc.last.nested, + 'includeNick', registry.Boolean(False, """Determines whether or not the + nick will be included in the output of last when it is part of a nested + command""")) + +# vim:set shiftwidth=4 tabstop=8 expandtab textwidth=78 diff --git a/plugins/Misc/plugin.py b/plugins/Misc/plugin.py new file mode 100644 index 000000000..ffb79079a --- /dev/null +++ b/plugins/Misc/plugin.py @@ -0,0 +1,597 @@ +### +# Copyright (c) 2002-2004, Jeremiah Fincher +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND 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 COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +### + +""" +Miscellaneous commands. +""" + +import supybot + +import supybot.fix as fix + +import os +import sys +import time + +from itertools import imap, ifilter + +import supybot.log as log +import supybot.conf as conf +import supybot.utils as utils +import supybot.world as world +from supybot.commands import * +import supybot.ircdb as ircdb +import supybot.irclib as irclib +import supybot.ircmsgs as ircmsgs +import supybot.ircutils as ircutils +import supybot.webutils as webutils +import supybot.callbacks as callbacks + +class Misc(callbacks.Privmsg): + def __init__(self): + super(Misc, self).__init__() + self.invalidCommands = ircutils.FloodQueue(60) + + def callPrecedence(self, irc): + return ([cb for cb in irc.callbacks if cb is not self], []) + + def invalidCommand(self, irc, msg, tokens): + assert not msg.repliedTo, 'repliedTo msg in Misc.invalidCommand.' + assert self is irc.callbacks[-1], 'Misc isn\'t last callback.' + self.log.debug('Misc.invalidCommand called (tokens %s)', tokens) + # First, we check for invalidCommand floods. This is rightfully done + # here since this will be the last invalidCommand called, and thus it + # will only be called if this is *truly* an invalid command. + maximum = conf.supybot.abuse.flood.command.invalid.maximum() + self.invalidCommands.enqueue(msg) + if self.invalidCommands.len(msg) > maximum and \ + not ircdb.checkCapability(msg.prefix, 'owner'): + punishment = conf.supybot.abuse.flood.command.invalid.punishment() + banmask = '*!%s@%s' % (msg.user, msg.host) + self.log.info('Ignoring %s for %s seconds due to an apparent ' + 'invalid command flood.', banmask, punishment) + if tokens and tokens[0] == 'Error:': + self.log.warning('Apparent error loop with another Supybot ' + 'observed at %s. Consider ignoring this bot ' + 'permanently.', log.timestamp()) + ircdb.ignores.add(banmask, time.time() + punishment) + irc.reply('You\'ve given me %s invalid commands within the last ' + 'minute; I\'m now ignoring you for %s.' % + (maximum, utils.timeElapsed(punishment, seconds=False))) + return + # Now, for normal handling. + channel = msg.args[0] + if conf.get(conf.supybot.reply.whenNotCommand, channel): + command = tokens and tokens[0] or '' + irc.errorInvalid('command', command, repr=False) + else: + if tokens: + # echo [] will get us an empty token set, but there's no need + # to log this in that case anyway, it being a nested command. + self.log.info('Not replying to %s, not a command.' % tokens[0]) + if not isinstance(irc.irc, irclib.Irc): + bracketConfig = conf.supybot.commands.nested.brackets + brackets = conf.get(bracketConfig, channel) + if brackets: + (left, right) = brackets + irc.reply(left + ' '.join(tokens) + right) + else: + pass # Let's just do nothing, I can't think of better. + + def list(self, irc, msg, args, optlist, cb): + """[--private] [] + + Lists the commands available in the given plugin. If no plugin is + given, lists the public plugins available. If --private is given, + lists the private plugins. + """ + private = False + for (option, argument) in optlist: + if option == 'private': + private = True + if not self.registryValue('listPrivatePlugins') and \ + not ircdb.checkCapability(msg.prefix, 'owner'): + irc.errorNoCapability('owner') + if not cb: + def isPublic(cb): + name = cb.name() + return conf.supybot.plugins.get(name).public() + names = [cb.name() for cb in irc.callbacks + if (private and not isPublic(cb)) or + (not private and isPublic(cb))] + names.sort() + if names: + irc.reply(utils.commaAndify(names)) + else: + if private: + irc.reply('There are no private plugins.') + else: + irc.reply('There are no public plugins.') + else: + if isinstance(cb, callbacks.PrivmsgRegexp) or \ + not isinstance(cb, callbacks.Privmsg): + irc.error('That plugin exists, but it has no commands. ' + 'You may wish to check if it has any useful ' + 'configuration variables with the command ' + '"config list supybot.plugins.%s".' % cb.name()) + else: + name = callbacks.canonicalName(cb.name()) + commands = [] + for s in dir(cb): + if cb.isCommand(s) and \ + (s != name or cb._original) and \ + s == callbacks.canonicalName(s): + method = getattr(cb, s) + if hasattr(method, '__doc__') and method.__doc__: + commands.append(s) + if commands: + commands.sort() + irc.reply(utils.commaAndify(commands)) + else: + irc.error('That plugin exists, but it has no ' + 'commands with help.') + list = wrap(list, [getopts({'private':''}), additional('plugin')]) + + def apropos(self, irc, msg, args, s): + """ + + Searches for in the commands currently offered by the bot, + returning a list of the commands containing that string. + """ + commands = {} + L = [] + for cb in irc.callbacks: + if isinstance(cb, callbacks.Privmsg) and \ + not isinstance(cb, callbacks.PrivmsgRegexp): + for attr in dir(cb): + if s in attr and cb.isCommand(attr): + if attr == callbacks.canonicalName(attr): + commands.setdefault(attr, []).append(cb.name()) + for (key, names) in commands.iteritems(): + if len(names) == 1: + L.append(key) + else: + for name in names: + L.append('%s %s' % (name, key)) + if L: + L.sort() + irc.reply(utils.commaAndify(L)) + else: + irc.reply('No appropriate commands were found.') + apropos = wrap(apropos, ['lowered']) + + def help(self, irc, msg, args, cb, command): + """[] [] + + This command gives a useful description of what does. + is only necessary if the command is in more than one plugin. + """ + def getHelp(cb): + if hasattr(cb, 'isCommand'): + if cb.isCommand(command): + irc.reply(cb.getCommandHelp(command)) + else: + irc.error('There is no %s command in the %s plugin.' % + (command, cb.name())) + else: + irc.error('The %s plugin exists, but has no commands.' % + cb.name()) + if cb: + if command: + getHelp(cb) + else: + irc.reply(cb.getCommandHelp(cb.name())) + elif command: + cbs = irc.findCallbackForCommand(command) + if not cbs: + irc.error('There is no command %s.' % command) + elif len(cbs) > 1: + names = sorted([cb.name() for cb in cbs]) + irc.error('That command exists in the %s plugins. ' + 'Please specify exactly which plugin command ' + 'you want help with.'% utils.commaAndify(names)) + else: + getHelp(cbs[0]) + else: + raise callbacks.ArgumentError + help = wrap(help, [optional(('plugin', False)), additional('commandName')]) + + def hostmask(self, irc, msg, args, nick): + """[] + + Returns the hostmask of . If isn't given, return the + hostmask of the person giving the command. + """ + if not nick: + nick = msg.nick + irc.reply(irc.state.nickToHostmask(nick)) + hostmask = wrap(hostmask, [additional('seenNick')]) + + def version(self, irc, msg, args): + """takes no arguments + + Returns the version of the current bot. + """ + try: + newest = webutils.getUrl('http://supybot.sf.net/version.txt') + newest ='The newest version available online is %s.'%newest.strip() + except webutils.WebError, e: + self.log.warning('Couldn\'t get website version: %r', e) + newest = 'I couldn\'t fetch the newest version ' \ + 'from the Supybot website.' + s = 'The current (running) version of this Supybot is %s. %s' % \ + (conf.version, newest) + irc.reply(s) + version = wrap(thread(version)) + + def source(self, irc, msg, args): + """takes no arguments + + Returns a URL saying where to get Supybot. + """ + irc.reply('My source is at http://supybot.sf.net/') + source = wrap(source) + + def plugin(self, irc, msg, args, command): + """ + + Returns the plugin (or plugins) is in. If this command is + nested, it returns only the plugin name(s). If given as a normal + command, it returns a more verbose, user-friendly response. + """ + cbs = callbacks.findCallbackForCommand(irc, command) + if cbs: + names = [cb.name() for cb in cbs] + names.sort() + plugin = utils.commaAndify(names) + if irc.nested: + irc.reply(utils.commaAndify(names)) + else: + irc.reply('The %s command is available in the %s %s.' % + (utils.quoted(command), plugin, + utils.pluralize('plugin', len(names)))) + else: + irc.error('There is no such command %s.' % command) + plugin = wrap(plugin, ['commandName']) + + def author(self, irc, msg, args, cb): + """ + + Returns the author of . This is the person you should talk to + if you have ideas, suggestions, or other comments about a given plugin. + """ + if cb is None: + irc.error('That plugin does not seem to be loaded.') + return + module = sys.modules[cb.__class__.__module__] + if hasattr(module, '__author__') and module.__author__: + irc.reply(utils.mungeEmailForWeb(str(module.__author__))) + else: + irc.reply('That plugin doesn\'t have an author that claims it.') + author = wrap(author, [('plugin')]) + + def more(self, irc, msg, args, nick): + """[] + + If the last command was truncated due to IRC message length + limitations, returns the next chunk of the result of the last command. + If is given, it takes the continuation of the last command from + instead of the person sending this message. + """ + userHostmask = msg.prefix.split('!', 1)[1] + if nick: + try: + (private, L) = self._mores[nick] + if not private: + self._mores[userHostmask] = L[:] + else: + irc.error('%s has no public mores.' % nick) + return + except KeyError: + irc.error('Sorry, I can\'t find any mores for %s' % nick) + return + try: + L = self._mores[userHostmask] + chunk = L.pop() + if L: + chunk += ' \x02(%s)\x0F' % \ + utils.nItems('message', len(L), 'more') + irc.reply(chunk, True) + except KeyError: + irc.error('You haven\'t asked me a command; perhaps you want ' + 'to see someone else\'s more. To do so, call this ' + 'command with that person\'s nick.') + except IndexError: + irc.error('That\'s all, there is no more.') + more = wrap(more, [additional('seenNick')]) + + def _validLastMsg(self, msg): + return msg.prefix and \ + msg.command == 'PRIVMSG' and \ + ircutils.isChannel(msg.args[0]) + + def last(self, irc, msg, args, optlist): + """[--{from,in,on,with,without,regexp} ] [--nolimit] + + Returns the last message matching the given criteria. --from requires + a nick from whom the message came; --in requires a channel the message + was sent to; --on requires a network the message was sent on; --with + requires some string that had to be in the message; --regexp requires + a regular expression the message must match; --nolimit returns all + the messages that can be found. By default, the channel this command is + given in is searched. + """ + predicates = {} + nolimit = False + if ircutils.isChannel(msg.args[0]): + predicates['in'] = lambda m: ircutils.strEqual(m.args[0], + msg.args[0]) + for (option, arg) in optlist: + if option == 'from': + def f(m, arg=arg): + return ircutils.hostmaskPatternEqual(arg, m.nick) + predicates['from'] = f + elif option == 'in': + def f(m, arg=arg): + return ircutils.strEqual(m.args[0], arg) + predicates['in'] = f + elif option == 'on': + def f(m, arg=arg): + return m.receivedOn == arg + predicates['on'] = f + elif option == 'with': + def f(m, arg=arg): + return arg.lower() in m.args[1].lower() + predicates.setdefault('with', []).append(f) + elif option == 'without': + def f(m, arg=arg): + return arg.lower() not in m.args[1].lower() + predicates.setdefault('without', []).append(f) + elif option == 'regexp': + def f(m, arg=arg): + if ircmsgs.isAction(m): + return arg.search(ircmsgs.unAction(m)) + else: + return arg.search(m.args[1]) + predicates.setdefault('regexp', []).append(f) + elif option == 'nolimit': + nolimit = True + iterable = ifilter(self._validLastMsg, reversed(irc.state.history)) + iterable.next() # Drop the first message. + predicates = list(utils.flatten(predicates.itervalues())) + resp = [] + if irc.nested and not \ + self.registryValue('last.nested.includeTimestamp'): + tsf = None + else: + tsf = self.registryValue('timestampFormat') + if irc.nested and not self.registryValue('last.nested.includeNick'): + showNick = False + else: + showNick = True + for m in iterable: + for predicate in predicates: + if not predicate(m): + break + else: + if nolimit: + resp.append(ircmsgs.prettyPrint(m, + timestampFormat=tsf, + showNick=showNick)) + else: + irc.reply(ircmsgs.prettyPrint(m, + timestampFormat=tsf, + showNick=showNick)) + return + if not resp: + irc.error('I couldn\'t find a message matching that criteria in ' + 'my history of %s messages.' % len(irc.state.history)) + else: + irc.reply(utils.commaAndify(resp)) + last = wrap(last, [getopts({'nolimit': '', + 'on': 'something', + 'with': 'something', + 'from': 'something', + 'without': 'something', + 'in': 'callerInGivenChannel', + 'regexp': 'regexpMatcher',})]) + + + def tell(self, irc, msg, args, target, text): + """ + + Tells the whatever is. Use nested commands to your + benefit here. + """ + if target.lower() == 'me': + target = msg.nick + if ircutils.isChannel(target): + irc.error('Dude, just give the command. No need for the tell.') + return + if not ircutils.isNick(target): + irc.errorInvalid('nick', target) + if ircutils.nickEqual(target, irc.nick): + irc.error('You just told me, why should I tell myself?',Raise=True) + if target not in irc.state.nicksToHostmasks and \ + not ircdb.checkCapability(msg.prefix, 'owner'): + # We'll let owners do this. + s = 'I haven\'t seen %s, I\'ll let you do the telling.' % target + irc.error(s, Raise=True) + if irc.action: + irc.action = False + text = '* %s %s' % (irc.nick, text) + s = '%s wants me to tell you: %s' % (msg.nick, text) + irc.reply(s, to=target, private=True) + tell = wrap(tell, ['something', 'text']) + + def private(self, irc, msg, args, text): + """ + + Replies with in private. Use nested commands to your benefit + here. + """ + irc.reply(text, private=True) + private = wrap(private, ['text']) + + def action(self, irc, msg, args, text): + """ + + Replies with as an action. use nested commands to your benefit + here. + """ + if text: + irc.reply(text, action=True) + else: + raise callbacks.ArgumentError + action = wrap(action, ['text']) + + def notice(self, irc, msg, args, text): + """ + + Replies with in a notice. Use nested commands to your benefit + here. If you want a private notice, nest the private command. + """ + irc.reply(text, notice=True) + notice = wrap(notice, ['text']) + + def contributors(self, irc, msg, args, cb, nick): + """ [] + + Replies with a list of people who made contributions to a given plugin. + If is specified, that person's specific contributions will + be listed. Note: The is the part inside of the parentheses + in the people listing. + """ + def getShortName(authorInfo): + """ + Take an Authors object, and return only the name and nick values + in the format 'First Last (nick)'. + """ + return '%(name)s (%(nick)s)' % authorInfo.__dict__ + def buildContributorsString(longList): + """ + Take a list of long names and turn it into : + shortname[, shortname and shortname]. + """ + L = [getShortName(n) for n in longList] + return utils.commaAndify(L) + def sortAuthors(): + """ + Sort the list of 'long names' based on the number of contributions + associated with each. + """ + L = module.__contributors__.items() + def negativeSecondElement(x): + return -len(x[1]) + utils.sortBy(negativeSecondElement, L) + return [t[0] for t in L] + def buildPeopleString(module): + """ + Build the list of author + contributors (if any) for the requested + plugin. + """ + head = 'The %s plugin' % cb.name() + author = 'has not been claimed by an author' + conjunction = 'and' + contrib = 'has no contributors listed' + hasAuthor = False + hasContribs = False + if getattr(module, '__author__', None): + author = 'was written by %s' % \ + utils.mungeEmailForWeb(str(module.__author__)) + hasAuthor = True + if getattr(module, '__contributors__', None): + contribs = sortAuthors() + if hasAuthor: + try: + contribs.remove(module.__author__) + except ValueError: + pass + if contribs: + contrib = '%s %s contributed to it.' % \ + (buildContributorsString(contribs), + utils.has(len(contribs))) + hasContribs = True + elif hasAuthor: + contrib = 'has no additional contributors listed' + if hasContribs and not hasAuthor: + conjunction = 'but' + return ' '.join([head, author, conjunction, contrib]) + def buildPersonString(module): + """ + Build the list of contributions (if any) for the requested person + for the requested plugin + """ + isAuthor = False + authorInfo = getattr(supybot.authors, nick, None) + if not authorInfo: + return 'The nick specified (%s) is not a registered ' \ + 'contributor' % nick + fullName = utils.mungeEmailForWeb(str(authorInfo)) + contributions = [] + if hasattr(module, '__contributors__'): + if authorInfo not in module.__contributors__: + return 'The %s plugin does not have \'%s\' listed as a ' \ + 'contributor' % (cb.name(), nick) + contributions = module.__contributors__[authorInfo] + if getattr(module, '__author__', False) == authorInfo: + isAuthor = True + # XXX Partition needs moved to utils. + (nonCommands, commands) = fix.partition(lambda s: ' ' in s, + contributions) + results = [] + if commands: + results.append( + 'the %s %s' %(utils.commaAndify(commands), + utils.pluralize('command',len(commands)))) + if nonCommands: + results.append('the %s' % utils.commaAndify(nonCommands)) + if results and isAuthor: + return '%s wrote the %s plugin and also contributed %s' % \ + (fullName, cb.name(), utils.commaAndify(results)) + elif results and not isAuthor: + return '%s contributed %s to the %s plugin' % \ + (fullName, utils.commaAndify(results), cb.name()) + elif isAuthor and not results: + return '%s wrote the %s plugin' % (fullName, cb.name()) + else: + return '%s has no listed contributions for the %s plugin %s' %\ + (fullName, cb.name()) + # First we need to check and see if the requested plugin is loaded + module = sys.modules[cb.__class__.__module__] + if not nick: + irc.reply(buildPeopleString(module)) + else: + nick = ircutils.toLower(nick) + irc.reply(buildPersonString(module)) + contributors = wrap(contributors, ['plugin', additional('nick')]) + +Class = Misc + +# vim:set shiftwidth=4 tabstop=8 expandtab textwidth=78: diff --git a/plugins/Misc/test.py b/plugins/Misc/test.py new file mode 100644 index 000000000..d0dda1aaf --- /dev/null +++ b/plugins/Misc/test.py @@ -0,0 +1,285 @@ +### +# Copyright (c) 2002-2004, Jeremiah Fincher +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND 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 COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +### + +from supybot.test import * + +class MiscTestCase(ChannelPluginTestCase): + plugins = ('Misc', 'Utilities', 'Gameknot', 'Anonymous', 'Dict', 'User') + def testAction(self): + self.assertAction('action moos', 'moos') + + def testActionDoesNotAllowEmptyString(self): + self.assertHelp('action') + self.assertHelp('action ""') + + def testReplyWhenNotCommand(self): + try: + original = str(conf.supybot.reply.whenNotCommand) + conf.supybot.reply.whenNotCommand.set('True') + self.prefix = 'somethingElse!user@host.domain.tld' + self.assertRegexp('foo bar baz', 'not.*command') + finally: + conf.supybot.reply.whenNotCommand.set(original) + + if network: + def testNotReplyWhenRegexpsMatch(self): + try: + orig = conf.supybot.reply.whenNotCommand() + gk = conf.supybot.plugins.Gameknot.gameSnarfer() + conf.supybot.reply.whenNotCommand.setValue(True) + conf.supybot.plugins.Gameknot.gameSnarfer.setValue(True) + self.prefix = 'somethingElse!user@host.domain.tld' + self.assertSnarfNotError( + 'http://gameknot.com/chess.pl?bd=1019508') + finally: + conf.supybot.reply.whenNotCommand.setValue(orig) + conf.supybot.plugins.Gameknot.gameSnarfer.setValue(gk) + + def testNotReplyWhenNotCanonicalName(self): + try: + original = str(conf.supybot.reply.whenNotCommand) + conf.supybot.reply.whenNotCommand.set('True') + self.prefix = 'somethingElse!user@host.domain.tld' + self.assertNotRegexp('STrLeN foobar', 'command') + self.assertResponse('StRlEn foobar', '6') + finally: + conf.supybot.reply.whenNotCommand.set(original) + + def testHelp(self): + self.assertHelp('help list') + self.assertRegexp('help help', r'^\(\x02help') + #self.assertRegexp('help misc help', r'^\(\x02misc help') + self.assertError('help nonExistentCommand') + + def testHelpDoesAmbiguityWithDefaultPlugins(self): + m = self.getMsg('help list') # Misc.list and User.list. + self.failIf(m.args[1].startswith('Error')) + + def testHelpIsCaseInsensitive(self): + self.assertHelp('help LIST') + + def testList(self): + self.assertNotError('list') + self.assertNotError('list Misc') + + def testListIsCaseInsensitive(self): + self.assertNotError('list misc') + + def testListPrivate(self): + # If Ctcp changes to public, these tests will break. So if + # the next assert fails, change the plugin we test for public/private + # to some other non-public plugin. + name = 'Anonymous' + conf.supybot.plugins.Anonymous.public.setValue(False) + self.assertNotRegexp('list', name) + self.assertRegexp('list --private', name) + conf.supybot.plugins.Anonymous.public.setValue(True) + self.assertRegexp('list', name) + self.assertNotRegexp('list --private', name) + + def testListDoesNotIncludeNonCanonicalName(self): + self.assertNotRegexp('list Owner', '_exec') + + def testListNoIncludeDispatcher(self): + self.assertNotRegexp('list Misc', 'misc') + + def testListIncludesDispatcherIfThereIsAnOriginalCommand(self): + self.assertRegexp('list Dict', r'\bdict\b') + + def testContributors(self): + # Test ability to list contributors + self.assertNotError('contributors Misc') + # Test ability to list contributions + # Verify that when a single command contribution has been made, + # the word "command" is properly not pluralized. + # Note: This will break if the listed person ever makes more than + # one contribution to the Misc plugin + self.assertRegexp('contributors Misc skorobeus', 'command') + # Test handling of pluralization of "command" when person has + # contributed more than one command to the plugin. + # -- Need to create this case, check it with the regexp 'commands' + # Test handling of invalid plugin + self.assertRegexp('contributors InvalidPlugin', 'not a valid plugin') + # Test handling of invalid person + self.assertRegexp('contributors Misc noname', + 'not a registered contributor') + # Test handling of valid person with no contributions + # Note: This will break if the listed person ever makes a contribution + # to the Misc plugin + self.assertRegexp('contributors Misc bwp', + 'listed as a contributor') + + def testContributorsIsCaseInsensitive(self): + self.assertNotError('contributors Misc Skorobeus') + self.assertNotError('contributors Misc sKoRoBeUs') + + if network: + def testVersion(self): + print '*** This test should start passing when we have our '\ + 'threaded issues resolved.' + self.assertNotError('version') + + def testSource(self): + self.assertNotError('source') + + def testPlugin(self): + self.assertRegexp('plugin plugin', 'available.*Misc') + self.assertResponse('echo [plugin plugin]', 'Misc') + + def testTell(self): + m = self.getMsg('tell foo [plugin tell]') + self.failUnless('let you do' in m.args[1]) + m = self.getMsg('tell #foo [plugin tell]') + self.failUnless('No need for' in m.args[1]) + m = self.getMsg('tell me you love me') + self.failUnless(m.args[0] == self.nick) + + def testTellDoesNotPropogateAction(self): + m = self.getMsg('tell foo [action bar]') + self.failIf(ircmsgs.isAction(m)) + + def testLast(self): + orig = conf.supybot.plugins.Misc.timestampFormat() + try: + conf.supybot.plugins.Misc.timestampFormat.setValue('') + self.feedMsg('foo bar baz') + self.assertResponse('last', '<%s> foo bar baz' % self.nick) + self.assertRegexp('last', '<%s> @last' % self.nick) + self.assertResponse('last --with foo', '<%s> foo bar baz' % \ + self.nick) + self.assertResponse('last --without foo', '<%s> @last' % self.nick) + self.assertRegexp('last --regexp m/\s+/', 'last --without foo') + self.assertResponse('last --regexp m/bar/', + '<%s> foo bar baz' % self.nick) + self.assertResponse('last --from %s' % self.nick.upper(), + '<%s> @last --regexp m/bar/' % self.nick) + self.assertResponse('last --from %s*' % self.nick[0], + '<%s> @last --from %s' % + (self.nick, self.nick.upper())) + conf.supybot.plugins.Misc.timestampFormat.setValue('foo') + self.assertSnarfNoResponse('foo bar baz', 1) + self.assertResponse('last', 'foo <%s> foo bar baz' % self.nick) + finally: + conf.supybot.plugins.Misc.timestampFormat.setValue(orig) + + def testNestedLastTimestampConfig(self): + tsConfig = conf.supybot.plugins.Misc.last.nested.includeTimestamp + orig = tsConfig() + try: + tsConfig.setValue(True) + self.feedMsg('foo bar baz') + self.assertRegexp('echo [last]', + '\[\d+:\d+:\d+\] foo bar baz') + finally: + tsConfig.setValue(orig) + + def testNestedLastNickConfig(self): + nickConfig = conf.supybot.plugins.Misc.last.nested.includeNick + orig = nickConfig() + try: + nickConfig.setValue(True) + self.feedMsg('foo bar baz') + self.assertRegexp('echo [last]', + '<%s> foo bar baz' % self.nick) + finally: + nickConfig.setValue(orig) + + def testMore(self): + self.assertRegexp('echo %s' % ('abc'*300), 'more') + self.assertRegexp('more', 'more') + self.assertNotRegexp('more', 'more') + + def testInvalidCommand(self): + self.assertError('echo []') + + def testMoreIsCaseInsensitive(self): + self.assertNotError('echo %s' % ('abc'*2000)) + self.assertNotError('more') + nick = ircutils.nickFromHostmask(self.prefix) + self.assertNotError('more %s' % nick) + self.assertNotError('more %s' % nick.upper()) + self.assertNotError('more %s' % nick.lower()) + + def testPrivate(self): + m = self.getMsg('private [list]') + self.failIf(ircutils.isChannel(m.args[0])) + + def testNotice(self): + m = self.getMsg('notice [list]') + self.assertEqual(m.command, 'NOTICE') + + def testNoticePrivate(self): + m = self.assertNotError('notice [private [list]]') + self.assertEqual(m.command, 'NOTICE') + self.assertEqual(m.args[0], self.nick) + m = self.assertNotError('private [notice [list]]') + self.assertEqual(m.command, 'NOTICE') + self.assertEqual(m.args[0], self.nick) + + def testHostmask(self): + self.assertResponse('hostmask', self.prefix) + self.assertError('@hostmask asdf') + m = self.irc.takeMsg() + self.failIf(m is not None, m) + + def testApropos(self): + self.assertNotError('apropos f') + self.assertRegexp('apropos asldkfjasdlkfja', 'No appropriate commands') + + def testAproposIsNotCaseSensitive(self): + self.assertNotRegexp('apropos LIST', 'No appropriate commands') + + def testAproposDoesntReturnNonCanonicalNames(self): + self.assertNotRegexp('apropos exec', '_exec') + + def testRevision(self): + self.assertNotError('revision Misc') + self.assertNotError('revision Misc.py') + self.assertNotError('revision') + + def testRevisionDoesNotLowerUnnecessarily(self): + self.assertNotError('load Math') + m1 = self.assertNotError('revision Math') + m2 = self.assertNotError('revision math') + self.assertEqual(m1, m2) + + def testRevisionIsCaseInsensitive(self): + self.assertNotError('revision misc') + + +class MiscNonChannelTestCase(PluginTestCase): + plugins = ('Misc',) + def testAction(self): + self.prefix = 'something!else@somewhere.else' + self.nick = 'something' + m = self.assertAction('action foo', 'foo') + self.failIf(m.args[0] == self.irc.nick) + +# vim:set shiftwidth=4 tabstop=8 expandtab textwidth=78: + diff --git a/plugins/Owner/__init__.py b/plugins/Owner/__init__.py new file mode 100644 index 000000000..62999b91e --- /dev/null +++ b/plugins/Owner/__init__.py @@ -0,0 +1,47 @@ +### +# Copyright (c) 2004, Jeremiah Fincher +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND 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 COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +### + +import supybot + +__author__ = supybot.authors.jemfinch + +# This is a dictionary mapping supybot.Author instances to lists of +# contributions. +__contributors__ = {} + +import config +import plugin +reload(plugin) # In case we're being reloaded. + +# Backwards compatibility. +if hasattr(plugin, '__doc__') and plugin.__doc__: + __doc__ = plugin.__doc__ + +Class = plugin.Class +configure = config.configure diff --git a/plugins/Owner/config.py b/plugins/Owner/config.py new file mode 100644 index 000000000..a903262dd --- /dev/null +++ b/plugins/Owner/config.py @@ -0,0 +1,55 @@ +### +# Copyright (c) 2004, Jeremiah Fincher +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND 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 COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +### + + +import supybot.conf as conf +import supybot.registry as registry + +def configure(advanced): + # This will be called by supybot to configure this module. advanced is + # a bool that specifies whether the user identified himself as an advanced + # user or not. You should effect your configuration by manipulating the + # registry as appropriate. + from supybot.questions import expect, anything, something, yn + conf.registerPlugin('Owner', True) + +Owner = conf.registerPlugin('Owner', True) +conf.registerGlobalValue(Owner, 'public', + registry.Boolean(True, """Determines whether this plugin is publicly + visible.""")) +conf.registerGlobalValue(Owner, 'quitMsg', + registry.String('', """Determines what quit message will be used by default. + If the quit command is called without a quit message, this will be used. If + this value is empty, the nick of the person giving the quit command will be + used.""")) + +conf.registerGroup(conf.supybot.commands, 'renames', orderAlphabetically=True) + + +# vim:set shiftwidth=4 tabstop=8 expandtab textwidth=78 diff --git a/plugins/Owner/plugin.py b/plugins/Owner/plugin.py new file mode 100644 index 000000000..23c6af1d4 --- /dev/null +++ b/plugins/Owner/plugin.py @@ -0,0 +1,675 @@ +### +# Copyright (c) 2002-2004, Jeremiah Fincher +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND 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 COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +### + +""" +Provides commands useful to the owner of the bot; the commands here require +their caller to have the 'owner' capability. This plugin is loaded by default. +""" + +import supybot.fix as fix + +import gc +import os +import imp +import sre +import sys +import getopt +import socket +import logging +import linecache + +import supybot.log as log +import supybot.conf as conf +import supybot.utils as utils +import supybot.world as world +import supybot.ircdb as ircdb +from supybot.commands import * +import supybot.irclib as irclib +import supybot.drivers as drivers +import supybot.ircmsgs as ircmsgs +import supybot.ircutils as ircutils +import supybot.privmsgs as privmsgs +import supybot.registry as registry +import supybot.callbacks as callbacks +import supybot.structures as structures + +class Deprecated(ImportError): + pass + +def loadPluginModule(name, ignoreDeprecation=False): + """Loads (and returns) the module for the plugin with the given name.""" + files = [] + pluginDirs = conf.supybot.directories.plugins() + for dir in pluginDirs: + try: + files.extend(os.listdir(dir)) + except EnvironmentError: # OSError, IOError superclass. + log.warning('Invalid plugin directory: %s; removing.', + utils.quoted(dir)) + conf.supybot.directories.plugins().remove(dir) + loweredFiles = map(str.lower, files) + try: + index = loweredFiles.index(name.lower()+'.py') + name = os.path.splitext(files[index])[0] + if name in sys.modules: + m = sys.modules[name] + if not hasattr(m, 'Class'): + raise ImportError, 'Module is not a plugin.' + except ValueError: # We'd rather raise the ImportError, so we'll let go... + pass + moduleInfo = imp.find_module(name, pluginDirs) + try: + module = imp.load_module(name, *moduleInfo) + except: + sys.modules.pop(name, None) + raise + if 'deprecated' in module.__dict__ and module.deprecated: + if ignoreDeprecation: + log.warning('Deprecated plugin loaded: %s', name) + else: + raise Deprecated, 'Attempted to load deprecated plugin %s' % \ + utils.quoted(name) + if module.__name__ in sys.modules: + sys.modules[module.__name__] = module + linecache.checkcache() + return module + +def loadPluginClass(irc, module, register=None): + """Loads the plugin Class from the given module into the given Irc.""" + try: + cb = module.Class() + except AttributeError, e: + if 'Class' in str(e): + raise callbacks.Error, \ + 'This plugin module doesn\'t have a "Class" ' \ + 'attribute to specify which plugin should be ' \ + 'instantiated. If you didn\'t write this ' \ + 'plugin, but received it with Supybot, file ' \ + 'a bug with us about this error.' + else: + raise + plugin = cb.name() + public = True + if hasattr(cb, 'public'): + public = cb.public + conf.registerPlugin(plugin, register, public) + assert not irc.getCallback(plugin) + try: + renames = registerRename(plugin)() + if renames: + for command in renames: + v = registerRename(plugin, command) + newName = v() + assert newName + renameCommand(cb, command, newName) + else: + conf.supybot.commands.renames.unregister(plugin) + except registry.NonExistentRegistryEntry, e: + pass # The plugin isn't there. + irc.addCallback(cb) + return cb + +### +# supybot.commands. +### + +def registerDefaultPlugin(command, plugin): + command = callbacks.canonicalName(command) + conf.registerGlobalValue(conf.supybot.commands.defaultPlugins, + command, registry.String(plugin, '')) + # This must be set, or the quotes won't be removed. + conf.supybot.commands.defaultPlugins.get(command).set(plugin) + +def registerRename(plugin, command=None, newName=None): + g = conf.registerGlobalValue(conf.supybot.commands.renames, plugin, + registry.SpaceSeparatedSetOfStrings([], """Determines what commands + in this plugin are to be renamed.""")) + if command is not None: + g().add(command) + v = conf.registerGlobalValue(g, command, registry.String('', '')) + if newName is not None: + v.setValue(newName) # In case it was already registered. + return v + else: + return g + +def renameCommand(cb, name, newName): + assert not hasattr(cb, newName), 'Cannot rename over existing attributes.' + assert newName == callbacks.canonicalName(newName), \ + 'newName must already be canonicalized.' + if name != newName: + method = getattr(cb.__class__, name) + setattr(cb.__class__, newName, method) + delattr(cb.__class__, name) + + +registerDefaultPlugin('list', 'Misc') +registerDefaultPlugin('help', 'Misc') +registerDefaultPlugin('ignore', 'Admin') +registerDefaultPlugin('reload', 'Owner') +registerDefaultPlugin('enable', 'Owner') +registerDefaultPlugin('disable', 'Owner') +registerDefaultPlugin('unignore', 'Admin') +registerDefaultPlugin('capabilities', 'User') +registerDefaultPlugin('addcapability', 'Admin') +registerDefaultPlugin('removecapability', 'Admin') + +class holder(object): + pass + +# This is used so we can support a "log" command as well as a "self.log" +# Logger. +class LogProxy(object): + """ + + Logs to the global Supybot log at critical priority. Useful for + marking logfiles for later searching. + """ + __name__ = 'log' # Necessary for help. + def __init__(self, log): + self.log = log + self.im_func = holder() + self.im_func.func_name = 'log' + + def __call__(self, irc, msg, args, text): + log.critical(text) + irc.replySuccess() + __call__ = wrap(__call__, ['text']) + + def __getattr__(self, attr): + return getattr(self.log, attr) + + +class Owner(callbacks.Privmsg): + # This plugin must be first; its priority must be lowest; otherwise odd + # things will happen when adding callbacks. + def __init__(self, *args, **kwargs): + self.__parent = super(Owner, self) + self.__parent.__init__() + # Setup log object/command. + self.log = LogProxy(self.log) + # Setup command flood detection. + self.commands = ircutils.FloodQueue(60) + # Setup Irc objects, connected to networks. If world.ircs is already + # populated, chances are that we're being reloaded, so don't do this. + if not world.ircs: + for network in conf.supybot.networks(): + try: + self._connect(network) + except socket.error, e: + self.log.error('Could not connect to %s: %s.', network, e) + except Exception, e: + self.log.exception('Exception connecting to %s:', network) + self.log.error('Could not connect to %s: %s.', network, e) + # Setup plugins and default plugins for commands. + for (name, s) in registry._cache.iteritems(): + if 'alwaysLoadDefault' in name or 'alwaysLoadImportant' in name: + continue + if name.startswith('supybot.plugins'): + try: + (_, _, name) = registry.split(name) + except ValueError: # unpack list of wrong size. + continue + # This is just for the prettiness of the configuration file. + # There are no plugins that are all-lowercase, so we'll at + # least attempt to capitalize them. + if name == name.lower(): + name = name.capitalize() + conf.registerPlugin(name) + if name.startswith('supybot.commands.defaultPlugins'): + try: + (_, _, _, name) = registry.split(name) + except ValueError: # unpack list of wrong size. + continue + registerDefaultPlugin(name, s) + + def callPrecedence(self, irc): + return ([], [cb for cb in irc.callbacks if cb is not self]) + + def outFilter(self, irc, msg): + if msg.command == 'PRIVMSG' and not world.testing: + if ircutils.strEqual(msg.args[0], irc.nick): + self.log.warning('Tried to send a message to myself: %r.', msg) + return None + return msg + + def isCommand(self, name): + return name == 'log' or \ + self.__parent.isCommand(name) + + def reset(self): + # This has to be done somewhere, I figure here is as good place as any. + callbacks.Privmsg._mores.clear() + self.__parent.reset() + + def _connect(self, network, serverPort=None, password=''): + try: + group = conf.supybot.networks.get(network) + (server, port) = group.servers()[0] + except (registry.NonExistentRegistryEntry, IndexError): + if serverPort is None: + raise ValueError, 'connect requires a (server, port) ' \ + 'if the network is not registered.' + conf.registerNetwork(network, password) + serverS = '%s:%s' % serverPort + conf.supybot.networks.get(network).servers.append(serverS) + assert conf.supybot.networks.get(network).servers() + self.log.info('Creating new Irc for %s.', network) + newIrc = irclib.Irc(network) + for irc in world.ircs: + if irc != newIrc: + newIrc.state.history = irc.state.history + driver = drivers.newDriver(newIrc) + return newIrc + + def do001(self, irc, msg): + self.log.info('Loading plugins (connected to %s).', irc.network) + alwaysLoadImportant = conf.supybot.plugins.alwaysLoadImportant() + important = conf.supybot.commands.defaultPlugins.importantPlugins() + for (name, value) in conf.supybot.plugins.getValues(fullNames=False): + if irc.getCallback(name) is None: + load = value() + if not load and name in important: + if alwaysLoadImportant: + s = '%s is configured not to be loaded, but is being '\ + 'loaded anyway because ' \ + 'supybot.plugins.alwaysLoadImportant is True.' + self.log.warning(s, name) + load = True + if load: + if not irc.getCallback(name): + # This is debug because each log logs its beginning. + self.log.debug('Loading %s.' % name) + try: + m = loadPluginModule(name, ignoreDeprecation=True) + loadPluginClass(irc, m) + except callbacks.Error, e: + # This is just an error message. + log.warning(str(e)) + except ImportError, e: + log.warning('Failed to load %s: %s.', name, e) + except Exception, e: + log.exception('Failed to load %s:', name) + else: + # Let's import the module so configuration is preserved. + try: + _ = loadPluginModule(name) + except Exception, e: + log.debug('Attempted to load %s to preserve its ' + 'configuration, but load failed: %s', + name, e) + world.starting = False + + def do376(self, irc, msg): + networkGroup = conf.supybot.networks.get(irc.network) + for channel in networkGroup.channels(): + irc.queueMsg(networkGroup.channels.join(channel)) + do422 = do377 = do376 + + def doPrivmsg(self, irc, msg): + assert self is irc.callbacks[0], 'Owner isn\'t first callback.' + if ircmsgs.isCtcp(msg): + return + s = callbacks.addressed(irc.nick, msg) + if s: + ignored = ircdb.checkIgnored(msg.prefix) + if ignored: + self.log.info('Ignoring command from %s.' % msg.prefix) + return + try: + tokens = callbacks.tokenize(s, channel=msg.args[0]) + self.Proxy(irc, msg, tokens) + except SyntaxError, e: + irc.queueMsg(callbacks.error(msg, str(e))) + + def announce(self, irc, msg, args, text): + """ + + Sends to all channels the bot is currently on and not + lobotomized in. + """ + u = ircdb.users.getUser(msg.prefix) + text = 'Announcement from my owner (%s): %s' % (u.name, text) + for channel in irc.state.channels: + c = ircdb.channels.getChannel(channel) + if not c.lobotomized: + irc.queueMsg(ircmsgs.privmsg(channel, text)) + irc.noReply() + announce = wrap(announce, ['text']) + + def defaultplugin(self, irc, msg, args, optlist, command, plugin): + """[--remove] [] + + Sets the default plugin for to . If --remove is + given, removes the current default plugin for . If no plugin + is given, returns the current default plugin set for . + """ + remove = False + for (option, arg) in optlist: + if option == 'remove': + remove = True + cbs = callbacks.findCallbackForCommand(irc, command) + if remove: + try: + conf.supybot.commands.defaultPlugins.unregister(command) + irc.replySuccess() + except registry.NonExistentRegistryEntry: + s = 'I don\'t have a default plugin set for that command.' + irc.error(s) + elif not cbs: + irc.errorInvalid('command', command) + elif plugin: + if not plugin.isCommand(command): + irc.errorInvalid('command in the %s plugin' % plugin, command) + registerDefaultPlugin(command, plugin.name()) + irc.replySuccess() + else: + try: + irc.reply(conf.supybot.commands.defaultPlugins.get(command)()) + except registry.NonExistentRegistryEntry: + s = 'I don\'t have a default plugin set for that command.' + irc.error(s) + defaultplugin = wrap(defaultplugin, [getopts({'remove': ''}), + 'commandName', + additional('plugin')]) + + def ircquote(self, irc, msg, args, s): + """ + + Sends the raw string given to the server. + """ + try: + m = ircmsgs.IrcMsg(s) + except Exception, e: + irc.error(utils.exnToString(e)) + else: + irc.queueMsg(m) + irc.noReply() + ircquote = wrap(ircquote, ['text']) + + def quit(self, irc, msg, args, text): + """[] + + Exits the bot with the QUIT message . If is not given, + the default quit message (supybot.plugins.Owner.quitMsg) will be used. + If there is no default quitMsg set, your nick will be used. + """ + text = text or self.registryValue('quitMsg') or msg.nick + irc.noReply() + m = ircmsgs.quit(text) + world.upkeep() + for irc in world.ircs[:]: + irc.queueMsg(m) + irc.die() + quit = wrap(quit, [additional('text')]) + + def flush(self, irc, msg, args): + """takes no arguments + + Runs all the periodic flushers in world.flushers. This includes + flushing all logs and all configuration changes to disk. + """ + world.flush() + irc.replySuccess() + flush = wrap(flush) + + def upkeep(self, irc, msg, args, level): + """[] + + Runs the standard upkeep stuff (flushes and gc.collects()). If given + a level, runs that level of upkeep (currently, the only supported + level is "high", which causes the bot to flush a lot of caches as well + as do normal upkeep stuff. + """ + L = [] + if level == 'high': + L.append('Regexp cache flushed: %s cleared.' % + utils.nItems('regexp', len(sre._cache))) + sre.purge() + L.append('Pattern cache flushed: %s cleared.' % + utils.nItems('compiled pattern', + len(ircutils._patternCache))) + ircutils._patternCache.clear() + L.append('hostmaskPatternEqual cache flushed: %s cleared.' % + utils.nItems('result', + len(ircutils._hostmaskPatternEqualCache))) + ircutils._hostmaskPatternEqualCache.clear() + L.append('ircdb username cache flushed: %s cleared.' % + utils.nItems('username to id mapping', + len(ircdb.users._nameCache))) + ircdb.users._nameCache.clear() + L.append('ircdb hostmask cache flushed: %s cleared.' % + utils.nItems('hostmask to id mapping', + len(ircdb.users._hostmaskCache))) + ircdb.users._hostmaskCache.clear() + L.append('linecache line cache flushed: %s cleared.' % + utils.nItems('line', len(linecache.cache))) + linecache.clearcache() + sys.exc_clear() + collected = world.upkeep() + if gc.garbage: + L.append('Garbage! %r.' % gc.garbage) + L.append('%s collected.' % utils.nItems('object', collected)) + irc.reply(' '.join(L)) + upkeep = wrap(upkeep, [additional(('literal', ['high']))]) + + def load(self, irc, msg, args, optlist, name): + """[--deprecated] + + Loads the plugin from any of the directories in + conf.supybot.directories.plugins; usually this includes the main + installed directory and 'plugins' in the current directory. + --deprecated is necessary if you wish to load deprecated plugins. + """ + ignoreDeprecation = False + for (option, argument) in optlist: + if option == 'deprecated': + ignoreDeprecation = True + if name.endswith('.py'): + name = name[:-3] + if irc.getCallback(name): + irc.error('%s is already loaded.' % name.capitalize()) + return + try: + module = loadPluginModule(name, ignoreDeprecation) + except Deprecated: + irc.error('%s is deprecated. Use --deprecated ' + 'to force it to load.' % name.capitalize()) + return + except ImportError, e: + if name in str(e): + irc.error('No plugin named %s exists.' % utils.dqrepr(name)) + else: + irc.error(str(e)) + return + cb = loadPluginClass(irc, module) + name = cb.name() # Let's normalize this. + conf.registerPlugin(name, True) + irc.replySuccess() + load = wrap(load, [getopts({'deprecated': ''}), 'something']) + + def reload(self, irc, msg, args, name): + """ + + Unloads and subsequently reloads the plugin by name; use the 'list' + command to see a list of the currently loaded plugins. + """ + callbacks = irc.removeCallback(name) + if callbacks: + module = sys.modules[callbacks[0].__module__] + if hasattr(module, 'reload'): + x = module.reload() + try: + module = loadPluginModule(name) + if hasattr(module, 'reload'): + module.reload(x) + for callback in callbacks: + callback.die() + del callback + gc.collect() # This makes sure the callback is collected. + callback = loadPluginClass(irc, module) + irc.replySuccess() + except ImportError: + for callback in callbacks: + irc.addCallback(callback) + irc.error('No plugin %s exists.' % name) + else: + irc.error('There was no plugin %s.' % name) + reload = wrap(reload, ['something']) + + def unload(self, irc, msg, args, name): + """ + + Unloads the callback by name; use the 'list' command to see a list + of the currently loaded callbacks. Obviously, the Owner plugin can't + be unloaded. + """ + if ircutils.strEqual(name, self.name()): + irc.error('You can\'t unload the %s plugin.' % name) + return + # Let's do this so even if the plugin isn't currently loaded, it doesn't + # stay attempting to load. + conf.registerPlugin(name, False) + callbacks = irc.removeCallback(name) + if callbacks: + for callback in callbacks: + callback.die() + del callback + gc.collect() + irc.replySuccess() + else: + irc.error('There was no plugin %s.' % name) + unload = wrap(unload, ['something']) + + def defaultcapability(self, irc, msg, args, action, capability): + """{add|remove} + + Adds or removes (according to the first argument) from the + default capabilities given to users (the configuration variable + supybot.capabilities stores these). + """ + if action == 'add': + conf.supybot.capabilities().add(capability) + irc.replySuccess() + elif action == 'remove': + try: + conf.supybot.capabilities().remove(capability) + irc.replySuccess() + except KeyError: + if ircdb.isAntiCapability(capability): + irc.error('That capability wasn\'t in ' + 'supybot.capabilities.') + else: + anticap = ircdb.makeAntiCapability(capability) + conf.supybot.capabilities().add(anticap) + irc.replySuccess() + defaultcapability = wrap(defaultcapability, + [('literal', ['add','remove']), 'capability']) + + def disable(self, irc, msg, args, plugin, command): + """[] + + Disables the command for all users (including the owners). + If is given, only disables the from . If + you want to disable a command for most users but not for yourself, set + a default capability of -plugin.command or -command (if you want to + disable the command in all plugins). + """ + if command in ('enable', 'identify'): + irc.error('You can\'t disable %s.' % command) + return + if plugin: + if plugin.isCommand(command): + pluginCommand = '%s.%s' % (plugin.name(), command) + conf.supybot.commands.disabled().add(pluginCommand) + else: + irc.error('%s is not a command in the %s plugin.' % + (command, plugin.name())) + return + self._disabled.add(command, plugin.name()) + else: + conf.supybot.commands.disabled().add(command) + self._disabled.add(command) + irc.replySuccess() + disable = wrap(disable, [optional('plugin'), 'commandName']) + + def enable(self, irc, msg, args, plugin, command): + """[] + + Enables the command for all users. If + if given, only enables the from . This command is + the inverse of disable. + """ + try: + if plugin: + command = '%s.%s' % (plugin.name(), command) + self._disabled.remove(command, plugin.name()) + else: + self._disabled.remove(command) + conf.supybot.commands.disabled().remove(command) + irc.replySuccess() + except KeyError: + irc.error('That command wasn\'t disabled.') + enable = wrap(enable, [optional('plugin'), 'commandName']) + + def rename(self, irc, msg, args, plugin, command, newName): + """ + + Renames in to the . + """ + if not plugin.isCommand(command): + what = 'command in the %s plugin' % plugin.name() + irc.errorInvalid(what, command) + if hasattr(plugin, newName): + irc.error('The %s plugin already has an attribute named %s.' % + (plugin, newName)) + return + registerRename(plugin.name(), command, newName) + renameCommand(plugin, command, newName) + irc.replySuccess() + rename = wrap(rename, ['plugin', 'commandName', 'commandName']) + + def unrename(self, irc, msg, args, plugin): + """ + + Removes all renames in . The plugin will be reloaded after + this command is run. + """ + try: + conf.supybot.commands.renames.unregister(plugin.name()) + except registry.NonExistentRegistryEntry: + irc.errorInvalid('plugin', plugin.name()) + self.reload(irc, msg, [plugin.name()]) # This makes the replySuccess. + unrename = wrap(unrename, ['plugin']) + + +Class = Owner + +# vim:set shiftwidth=4 tabstop=8 expandtab textwidth=78: + diff --git a/plugins/Owner/test.py b/plugins/Owner/test.py new file mode 100644 index 000000000..0a9a9f6b9 --- /dev/null +++ b/plugins/Owner/test.py @@ -0,0 +1,109 @@ +### +# Copyright (c) 2002-2004, Jeremiah Fincher +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND 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 COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +### + +from supybot.test import * + +import supybot.conf as conf +import supybot.plugins.Owner as Owner + +class OwnerTestCase(PluginTestCase, PluginDocumentation): + plugins = ('Utilities', 'Relay', 'Network', 'Admin', 'Channel') + def testHelpLog(self): + self.assertHelp('help log') + + def testSrcAmbiguity(self): + self.assertError('addcapability foo bar') + + def testIrcquote(self): + self.assertResponse('ircquote PRIVMSG %s :foo' % self.irc.nick, 'foo') + + def testFlush(self): + self.assertNotError('flush') + + def testUpkeep(self): + self.assertNotError('upkeep') + + def testLoad(self): + self.assertError('load Owner') + self.assertError('load owner') + self.assertNotError('load Alias') + self.assertNotError('list Owner') + + def testReload(self): + self.assertError('reload Alias') + self.assertNotError('load Alias') + self.assertNotError('reload ALIAS') + self.assertNotError('reload ALIAS') + + def testUnload(self): + self.assertError('unload Foobar') + self.assertNotError('load Alias') + self.assertNotError('unload Alias') + self.assertError('unload Alias') + self.assertNotError('load ALIAS') + self.assertNotError('unload ALIAS') + + def testDisable(self): + self.assertError('disable enable') + self.assertError('disable identify') + + def testEnable(self): + self.assertError('enable enable') + + def testEnableIsCaseInsensitive(self): + self.assertNotError('disable Foo') + self.assertNotError('enable foo') + + def testRename(self): + self.assertError('rename admin ignore IGNORE') + self.assertError('rename admin ignore ig-nore') + self.assertNotError('rename admin removecapability rmcap') + self.assertNotRegexp('list admin', 'removecapability') + self.assertRegexp('list admin', 'rmcap') + self.assertNotError('reload admin') + self.assertNotRegexp('list admin', 'removecapability') + self.assertRegexp('list admin', 'rmcap') + self.assertNotError('unrename admin') + self.assertRegexp('list admin', 'removecapability') + self.assertNotRegexp('list admin', 'rmcap') + + def testDefaultPluginErrorsWhenCommandNotInPlugin(self): + self.assertError('defaultplugin foobar owner') + + + +class FunctionsTestCase(SupyTestCase): + def testLoadPluginModule(self): + self.assertRaises(ImportError, Owner.loadPluginModule, 'asldj') + self.failUnless(Owner.loadPluginModule('Owner')) + self.failUnless(Owner.loadPluginModule('owner')) + + +# vim:set shiftwidth=4 tabstop=8 expandtab textwidth=78: + diff --git a/plugins/User/__init__.py b/plugins/User/__init__.py new file mode 100644 index 000000000..62999b91e --- /dev/null +++ b/plugins/User/__init__.py @@ -0,0 +1,47 @@ +### +# Copyright (c) 2004, Jeremiah Fincher +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND 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 COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +### + +import supybot + +__author__ = supybot.authors.jemfinch + +# This is a dictionary mapping supybot.Author instances to lists of +# contributions. +__contributors__ = {} + +import config +import plugin +reload(plugin) # In case we're being reloaded. + +# Backwards compatibility. +if hasattr(plugin, '__doc__') and plugin.__doc__: + __doc__ = plugin.__doc__ + +Class = plugin.Class +configure = config.configure diff --git a/plugins/User/config.py b/plugins/User/config.py new file mode 100644 index 000000000..ce25eea8b --- /dev/null +++ b/plugins/User/config.py @@ -0,0 +1,48 @@ +### +# Copyright (c) 2004, Jeremiah Fincher +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND 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 COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +### + + +import supybot.conf as conf +import supybot.registry as registry + +def configure(advanced): + # This will be called by supybot to configure this module. advanced is + # a bool that specifies whether the user identified himself as an advanced + # user or not. You should effect your configuration by manipulating the + # registry as appropriate. + from supybot.questions import expect, anything, something, yn + conf.registerPlugin('User', True) + + +User = conf.registerPlugin('User') +# This is where your configuration variables (if any) should go. For example: +# conf.registerGlobalValue(User, 'someConfigVariableName', +# registry.Boolean(False, """Help for someConfigVariableName.""")) + +# vim:set shiftwidth=4 tabstop=8 expandtab textwidth=78 diff --git a/plugins/User/plugin.py b/plugins/User/plugin.py new file mode 100644 index 000000000..797f28e05 --- /dev/null +++ b/plugins/User/plugin.py @@ -0,0 +1,434 @@ +### +# Copyright (c) 2002-2004, Jeremiah Fincher +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND 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 COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +### + +""" +Provides commands useful to users in general. This plugin is loaded by default. +""" + +import supybot.fix as fix + +import re +import getopt +import fnmatch +from itertools import imap, ifilter + +import supybot.conf as conf +import supybot.utils as utils +import supybot.ircdb as ircdb +from supybot.commands import * +import supybot.ircutils as ircutils +import supybot.privmsgs as privmsgs +import supybot.callbacks as callbacks + +class User(callbacks.Privmsg): + def _checkNotChannel(self, irc, msg, password=' '): + if password and ircutils.isChannel(msg.args[0]): + raise callbacks.Error, conf.supybot.replies.requiresPrivacy() + + def list(self, irc, msg, args, optlist, glob): + """[--capability=] [] + + Returns the valid registered usernames matching . If is + not given, returns all registered usernames. + """ + predicates = [] + for (option, arg) in optlist: + if option == 'capability': + def p(u, cap=arg): + try: + return u._checkCapability(cap) + except KeyError: + return False + predicates.append(p) + if glob: + r = re.compile(fnmatch.translate(glob), re.I) + def p(u): + return r.match(u.name) is not None + predicates.append(p) + users = [] + for u in ircdb.users.itervalues(): + for predicate in predicates: + if not predicate(u): + break + else: + users.append(u.name) + if users: + utils.sortBy(str.lower, users) + irc.reply(utils.commaAndify(users)) + else: + if predicates: + irc.reply('There are no matching registered users.') + else: + irc.reply('There are no registered users.') + list = wrap(list, [getopts({'capability':'capability'}), + additional('glob')]) + + def register(self, irc, msg, args, name, password): + """ + + Registers with the given password and the current + hostmask of the person registering. You shouldn't register twice; if + you're not recognized as a user but you've already registered, use the + addhostmask command to add another hostmask to your already-registered + user, or use the identify command to identify just for a session. + This command (and all other commands that include a password) must be + sent to the bot privately, not in a channel. + """ + addHostmask = True + try: + ircdb.users.getUserId(name) + irc.error('That name is already assigned to someone.', Raise=True) + except KeyError: + pass + if ircutils.isUserHostmask(name): + irc.errorInvalid('username', name, + 'Hostmasks are not valid usernames.', Raise=True) + try: + u = ircdb.users.getUser(msg.prefix) + if u._checkCapability('owner'): + addHostmask = False + else: + irc.error('Your hostmask is already registered to %s' % u.name) + return + except KeyError: + pass + user = ircdb.users.newUser() + user.name = name + user.setPassword(password) + if addHostmask: + user.addHostmask(msg.prefix) + ircdb.users.setUser(user) + irc.replySuccess() + register = wrap(register, ['private', 'something', 'something']) + + def unregister(self, irc, msg, args, user, password): + """ [] + + Unregisters from the user database. If the user giving this + command is an owner user, the password is not necessary. + """ + try: + caller = ircdb.users.getUser(msg.prefix) + isOwner = caller._checkCapability('owner') + except KeyError: + caller = None + isOwner = False + if not conf.supybot.databases.users.allowUnregistration(): + if not caller or not isOwner: + self.log.warning('%s tried to unregister user %s.', + msg.prefix, user.name) + irc.error('This command has been disabled. You\'ll have to ' + 'ask the owner of this bot to unregister your user.') + if isOwner or user.checkPassword(password): + ircdb.users.delUser(user.id) + irc.replySuccess() + else: + irc.error(conf.supybot.replies.incorrectAuthentication()) + unregister = wrap(unregister, ['private', 'otherUser', + additional('anything')]) + + def changename(self, irc, msg, args, user, newname, password): + """ [] + + Changes your current user database name to the new name given. + is only necessary if the user isn't recognized by hostmask. + If you include the parameter, this message must be sent + to the bot privately (not on a channel). + """ + try: + id = ircdb.users.getUserId(newname) + irc.error('%s is already registered.' % utils.quoted(newname)) + return + except KeyError: + pass + if user.checkHostmask(msg.prefix) or user.checkPassword(password): + user.name = newname + ircdb.users.setUser(user) + irc.replySuccess() + changename = wrap(changename, ['private', 'otherUser', 'something', + additional('something', '')]) + + def addhostmask(self, irc, msg, args, user, hostmask, password): + """[] [] [] + + Adds the hostmask to the user specified by . The + may only be required if the user is not recognized by + hostmask. If you include the parameter, this message must + be sent to the bot privately (not on a channel). is also + not required if an owner user is giving the command on behalf of some + other user. If is not given, it defaults to your current + hostmask. If is not given, it defaults to your currently + identified name. + """ + if not hostmask: + hostmask = msg.prefix + if not ircutils.isUserHostmask(hostmask): + irc.errorInvalid('hostmask', hostmask, 'Make sure your hostmask ' + 'includes a nick, then an exclamation point (!), then ' + 'a user, then an at symbol (@), then a host. Feel ' + 'free to use wildcards (* and ?, which work just like ' + 'they do on the command line) in any of these parts.', + Raise=True) + try: + otherId = ircdb.users.getUserId(hostmask) + if otherId != user.id: + irc.error('That hostmask is already registered.', Raise=True) + except KeyError: + pass + if not user.checkPassword(password) and \ + not user.checkHostmask(msg.prefix): + try: + u = ircdb.users.getUser(msg.prefix) + except KeyError: + irc.error(conf.supybot.replies.incorrectAuthentication(), + Raise=True) + if not u._checkCapability('owner'): + irc.error(conf.supybot.replies.incorrectAuthentication(), + Raise=True) + try: + user.addHostmask(hostmask) + except ValueError, e: + irc.error(str(e), Raise=True) + try: + ircdb.users.setUser(user) + except ValueError, e: + irc.error(str(e), Raise=True) + irc.replySuccess() + addhostmask = wrap(addhostmask, [first('otherUser', 'user'), + optional('something'), + additional('something', '')]) + + def removehostmask(self, irc, msg, args, user, hostmask, password): + """ [] + + Removes the hostmask from the record of the user specified + by . If the hostmask is 'all' then all hostmasks will be + removed. The may only be required if the user is not + recognized by his hostmask. If you include the parameter, + this message must be sent to the bot privately (not on a channel). + """ + if not user.checkPassword(password) and \ + not user.checkHostmask(msg.prefix): + u = ircdb.users.getUser(msg.prefix) + if not u._checkCapability('owner'): + irc.error(conf.supybot.replies.incorrectAuthentication()) + return + try: + s = '' + if hostmask == 'all': + user.hostmasks.clear() + s = 'All hostmasks removed.' + else: + user.removeHostmask(hostmask) + except KeyError: + irc.error('There was no such hostmask.') + return + ircdb.users.setUser(user) + irc.replySuccess(s) + removehostmask = wrap(removehostmask, ['private', 'otherUser', 'something', + additional('something', '')]) + + def setpassword(self, irc, msg, args, user, password,newpassword): + """ + + Sets the new password for the user specified by to + . Obviously this message must be sent to the bot + privately (not in a channel). If the requesting user is an owner user + (and the user whose password is being changed isn't that same owner + user), then needn't be correct. + """ + u = ircdb.users.getUser(msg.prefix) + if user.checkPassword(password) or \ + (u._checkCapability('owner') and not u == user): + user.setPassword(newpassword) + ircdb.users.setUser(user) + irc.replySuccess() + else: + irc.error(conf.supybot.replies.incorrectAuthentication()) + setpassword = wrap(setpassword, ['otherUser', 'something', 'something']) + + def username(self, irc, msg, args, hostmask): + """ + + Returns the username of the user specified by or if + the user is registered. + """ + if ircutils.isNick(hostmask): + try: + hostmask = irc.state.nickToHostmask(hostmask) + except KeyError: + irc.error('I haven\'t seen %s.' % hostmask, Raise=True) + try: + user = ircdb.users.getUser(hostmask) + irc.reply(user.name) + except KeyError: + irc.error('I don\'t know who that is.') + username = wrap(username, [first('nick', 'hostmask')]) + + def hostmasks(self, irc, msg, args, name): + """[] + + Returns the hostmasks of the user specified by ; if isn't + specified, returns the hostmasks of the user calling the command. + """ + def getHostmasks(user): + hostmasks = map(repr, user.hostmasks) + hostmasks.sort() + return utils.commaAndify(hostmasks) + try: + user = ircdb.users.getUser(msg.prefix) + if name: + if name != user.name and \ + not ircdb.checkCapability(msg.prefix, 'owner'): + irc.error('You may only retrieve your own hostmasks.', + Raise=True) + else: + try: + user = ircdb.users.getUser(name) + irc.reply(getHostmasks(user)) + except KeyError: + irc.errorNoUser() + else: + irc.reply(getHostmasks(user)) + except KeyError: + irc.errorNotRegistered() + hostmasks = wrap(hostmasks, ['private', additional('something')]) + + def capabilities(self, irc, msg, args, user): + """[] + + Returns the capabilities of the user specified by ; if + isn't specified, returns the hostmasks of the user calling the command. + """ + irc.reply('[%s]' % '; '.join(user.capabilities)) + capabilities = wrap(capabilities, [first('otherUser', 'user')]) + + def identify(self, irc, msg, args, user, password): + """ + + Identifies the user as . This command (and all other + commands that include a password) must be sent to the bot privately, + not in a channel. + """ + if user.checkPassword(password): + try: + user.addAuth(msg.prefix) + ircdb.users.setUser(user) + irc.replySuccess() + except ValueError: + irc.error('Your secure flag is true and your hostmask ' + 'doesn\'t match any of your known hostmasks.') + else: + self.log.warning('Failed identification attempt by %s (password ' + 'did not match for %s).', msg.prefix, user.name) + irc.error(conf.supybot.replies.incorrectAuthentication()) + identify = wrap(identify, ['private', 'otherUser', 'something']) + + def unidentify(self, irc, msg, args, user): + """takes no arguments + + Un-identifies you. Note that this may not result in the desired + effect of causing the bot not to recognize you anymore, since you may + have added hostmasks to your user that can cause the bot to continue to + recognize you. + """ + user.clearAuth() + ircdb.users.setUser(user) + irc.replySuccess('If you remain recognized after giving this command, ' + 'you\'re being recognized by hostmask, rather than ' + 'by password. You must remove whatever hostmask is ' + 'causing you to be recognized in order not to be ' + 'recognized.') + unidentify = wrap(unidentify, ['user']) + + def whoami(self, irc, msg, args): + """takes no arguments + + Returns the name of the user calling the command. + """ + try: + user = ircdb.users.getUser(msg.prefix) + irc.reply(user.name) + except KeyError: + irc.reply('I don\'t recognize you.') + whoami = wrap(whoami) + + def setsecure(self, irc, msg, args, user, password, value): + """ [] + + Sets the secure flag on the user of the person sending the message. + Requires that the person's hostmask be in the list of hostmasks for + that user in addition to the password being correct. When the secure + flag is set, the user *must* identify before he can be recognized. + If a specific True/False value is not given, it inverts the current + value. + """ + if value is None: + value = not user.secure + if user.checkPassword(password) and \ + user.checkHostmask(msg.prefix, useAuth=False): + user.secure = value + ircdb.users.setUser(user) + irc.reply('Secure flag set to %s' % value) + else: + irc.error(conf.supybot.replies.incorrectAuthentication()) + setsecure = wrap(setsecure, ['private', 'user', 'something', + additional('boolean')]) + + def stats(self, irc, msg, args): + """takes no arguments + + Returns some statistics on the user database. + """ + users = 0 + owners = 0 + admins = 0 + hostmasks = 0 + for user in ircdb.users.itervalues(): + users += 1 + hostmasks += len(user.hostmasks) + try: + if user._checkCapability('owner'): + owners += 1 + elif user._checkCapability('admin'): + admins += 1 + except KeyError: + pass + irc.reply('I have %s registered users ' + 'with %s registered hostmasks; ' + '%s and %s.' % (users, hostmasks, + utils.nItems('owner', owners), + utils.nItems('admin', admins))) + stats = wrap(stats) + + +Class = User + +# vim:set shiftwidth=4 tabstop=8 expandtab textwidth=78: + diff --git a/plugins/User/test.py b/plugins/User/test.py new file mode 100644 index 000000000..bddbb4bd5 --- /dev/null +++ b/plugins/User/test.py @@ -0,0 +1,111 @@ +### +# Copyright (c) 2002-2004, Jeremiah Fincher +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND 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 COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +### + +from supybot.test import * + +import supybot.world as world +import supybot.ircdb as ircdb + +class UserTestCase(PluginTestCase, PluginDocumentation): + plugins = ('User',) + prefix1 = 'somethingElse!user@host.tld' + prefix2 = 'EvensomethingElse!user@host.tld' + def testHostmasks(self): + self.assertError('hostmasks') + original = self.prefix + self.prefix = self.prefix1 + self.assertNotError('register foo bar') + self.prefix = original + self.assertError('hostmasks foo') + self.assertNotError('addhostmask foo [hostmask] bar') + self.assertNotError('hostmasks foo') + self.assertNotRegexp('hostmasks foo', 'IrcSet') + + def testRegisterUnregister(self): + self.prefix = self.prefix1 + self.assertNotError('register foo bar') + self.assertError('register foo baz') + self.failUnless(ircdb.users.getUserId('foo')) + self.assertNotError('unregister foo bar') + self.assertRaises(KeyError, ircdb.users.getUserId, 'foo') + + def testList(self): + self.prefix = self.prefix1 + self.assertNotError('register foo bar') + self.assertResponse('user list', 'foo') + self.prefix = self.prefix2 + self.assertNotError('register biff quux') + self.assertResponse('user list', 'biff and foo') + self.assertResponse('user list f', 'biff and foo') + self.assertResponse('user list f*', 'foo') + self.assertResponse('user list *f', 'biff') + self.assertNotError('unregister biff quux') + self.assertResponse('user list', 'foo') + self.assertNotError('unregister foo bar') + self.assertRegexp('user list', 'no registered users') + self.assertRegexp('user list asdlfkjasldkj', 'no matching registered') + + def testListHandlesCaps(self): + self.prefix = self.prefix1 + self.assertNotError('register Foo bar') + self.assertResponse('user list', 'Foo') + self.assertResponse('user list f*', 'Foo') + + def testChangeUsername(self): + self.prefix = self.prefix1 + self.assertNotError('register foo bar') + self.prefix = self.prefix2 + self.assertNotError('register bar baz') + self.prefix = self.prefix1 + self.assertError('changename foo bar') + self.assertNotError('changename foo baz') + + def testSetpassword(self): + self.prefix = self.prefix1 + self.assertNotError('register foo bar') + password = ircdb.users.getUser(self.prefix).password + self.assertNotEqual(password, 'bar') + self.assertNotError('setpassword foo bar baz') + self.assertNotEqual(ircdb.users.getUser(self.prefix).password,password) + self.assertNotEqual(ircdb.users.getUser(self.prefix).password, 'baz') + + def testStats(self): + self.assertNotError('user stats') + self.assertNotError('load Lart') + self.assertNotError('user stats') + + def testUserPluginAndUserList(self): + self.prefix = self.prefix1 + self.assertNotError('register Foo bar') + self.assertResponse('user list', 'Foo') + self.assertNotError('load Seen') + self.assertResponse('user list', 'Foo') + +# vim:set shiftwidth=4 tabstop=8 expandtab textwidth=78: + diff --git a/scripts/supybot b/scripts/supybot new file mode 100644 index 000000000..905a2ce9f --- /dev/null +++ b/scripts/supybot @@ -0,0 +1,350 @@ +#!/usr/bin/env python + +### +# Copyright (c) 2003-2004, Jeremiah Fincher +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND 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 COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +### + +""" +This is the main program to run Supybot. +""" + + + +import re +import os +import sys +import atexit +import shutil +import signal +import cStringIO as StringIO + +if sys.version_info < (2, 3, 0): + sys.stderr.write('This program requires Python >= 2.3.0\n') + sys.exit(-1) + +def _termHandler(signalNumber, stackFrame): + raise SystemExit, 'Signal #%s.' % signalNumber + +signal.signal(signal.SIGTERM, _termHandler) + +import time +import optparse +import textwrap + +started = time.time() + +import supybot +import supybot.utils as utils +import supybot.registry as registry +import supybot.questions as questions + +def main(): + import supybot.conf as conf + import supybot.world as world + import supybot.drivers as drivers + import supybot.schedule as schedule + # We schedule this event rather than have it actually run because if there + # is a failure between now and the time it takes the Owner plugin to load + # all the various plugins, our registry file might be wiped. That's bad. + interrupted = False + when = conf.supybot.upkeepInterval() + schedule.addPeriodicEvent(world.upkeep, when, name='upkeep', now=False) + world.startedAt = started + while world.ircs: + try: + drivers.run() + except KeyboardInterrupt: + if interrupted: + # Interrupted while waiting for queues to clear. Let's clear + # them ourselves. + for irc in world.ircs: + irc._reallyDie() + continue + else: + interrupted = True + log.info('Exiting due to Ctrl-C. ' + 'If the bot doesn\'t exit within a few seconds, ' + 'feel free to press Ctrl-C again to make it exit ' + 'without flushing its message queues.') + world.upkeep() + for irc in world.ircs: + quitmsg = conf.supybot.plugins.Owner.quitMsg() or \ + 'Ctrl-C at console.' + irc.queueMsg(ircmsgs.quit(quitmsg)) + irc.die() + except SystemExit, e: + s = str(e) + if s: + log.info('Exiting due to %s', s) + break + except: + try: # Ok, now we're *REALLY* paranoid! + log.exception('Exception raised out of drivers.run:') + except Exception, e: + print 'Exception raised in log.exception. This is *really*' + print 'bad. Hopefully it won\'t happen again, but tell us' + print 'about it anyway, this is a significant problem.' + print 'Anyway, here\'s the exception: %s'% utils.exnToString(e) + except: + print 'Man, this really sucks. Not only did log.exception' + print 'raise an exception, but freaking-a, it was a string' + print 'exception. People who raise string exceptions should' + print 'die a slow, painful death.' + now = time.time() + seconds = now - world.startedAt + log.info('Total uptime: %s.', utils.timeElapsed(seconds)) + (user, system, _, _, _) = os.times() + log.info('Total CPU time taken: %s seconds.', user+system) + log.info('No more Irc objects, exiting.') + +version = '0.80.0' +if __name__ == '__main__': + ### + # Options: + # -p (profiling) + # -O (optimizing) + # -n, --nick (nick) + # --startup (commands to run onStart) + # --connect (commands to run afterConnect) + # --config (configuration values) + parser = optparse.OptionParser(usage='Usage: %prog [options] configFile', + version='supybot %s' % version) + parser.add_option('-P', '--profile', action='store_true', dest='profile', + help='enables profiling') + parser.add_option('-O', action='count', dest='optimize', + help='-O optimizes asserts out of the code; ' \ + '-OO optimizes asserts and uses psyco.') + parser.add_option('-n', '--nick', action='store', + dest='nick', default='', + help='nick the bot should use') + parser.add_option('-u', '--user', action='store', + dest='user', default='', + help='full username the bot should use') + parser.add_option('-i', '--ident', action='store', + dest='ident', default='', + help='ident the bot should use') + parser.add_option('-d', '--daemon', action='store_true', + dest='daemon', + help='Determines whether the bot will daemonize. ' + 'This is a no-op on non-POSIX systems.') + parser.add_option('', '--allow-default-owner', action='store_true', + dest='allowDefaultOwner', + help='Determines whether the bot will allow its ' + 'defaultCapabilities not to include "-owner", thus ' + 'giving all users the owner capability by default. ' + ' This is dumb, hence we require a command-line ' + 'option. Don\'t do this.') + parser.add_option('', '--allow-root', action='store_true', + dest='allowRoot', + help='Determines whether the bot will be allowed to run ' + 'as root. You don\'t want this. Don\'t do it. ' + 'Even if you think you want it, you don\'t. ' + 'You\'re probably dumb if you do this.') + parser.add_option('', '--debug', action='store_true', dest='debug', + help='Determines whether some extra debugging stuff will ' + 'be logged in this script.') + + (options, args) = parser.parse_args() + + if os.name == 'posix': + if (os.getuid() == 0 or os.geteuid() == 0) and not options.allowRoot: + sys.stderr.write('Dude, don\'t even try to run this as root.\n') + sys.exit(-1) + + if len(args) > 1: + parser.error("""Only one configuration option should be specified.""") + elif not args: + parser.error(utils.normalizeWhitespace("""It seems you've given me no + configuration file. If you have a configuration file, be sure to tell + its filename. If you don't have a configuration file, read + docs/GETTING_STARTED and follow its directions.""")) + else: + registryFilename = args.pop() + try: + # The registry *MUST* be opened before importing log or conf. + registry.open(registryFilename) + shutil.copy(registryFilename, registryFilename + '.bak') + except registry.InvalidRegistryFile, e: + s = '%s in %s. Please fix this error and start supybot again.' % \ + (e, registryFilename) + s = textwrap.fill(s) + sys.stderr.write(s) + sys.stderr.write(os.linesep) + raise + sys.exit(-1) + except EnvironmentError, e: + sys.stderr.write(str(e)) + sys.stderr.write(os.linesep) + sys.exit(-1) + + try: + import supybot.log as log + except supybot.registry.InvalidRegistryValue, e: + # This is raised here because supybot.log imports supybot.conf. + name = e.value._name + errmsg = textwrap.fill('%s: %s' % (name, e), + width=78, subsequent_indent=' '*len(name)) + sys.stderr.write(errmsg) + sys.stderr.write('\n') + sys.stderr.write('Please fix this error in your configuration file ' + 'and restart your bot.\n') + sys.exit(-1) + import supybot.conf as conf + import supybot.world as world + world.starting = True + + def closeRegistry(): + # We only print if world.dying so we don't see these messages during + # upkeep. + logger = log.debug + if world.dying: + logger = log.info + logger('Writing registry file to %s', registryFilename) + registry.close(conf.supybot, registryFilename) + logger('Finished writing registry file.') + world.flushers.append(closeRegistry) + world.registryFilename = registryFilename + + nick = options.nick or conf.supybot.nick() + user = options.user or conf.supybot.user() + ident = options.ident or conf.supybot.ident() + + networks = conf.supybot.networks() + if not networks: + questions.output("""No networks defined. Perhaps you should re-run the + wizard?""", fd=sys.stderr) + # XXX We should turn off logging here for a prettier presentation. + sys.exit(-1) + + if options.optimize: + # This doesn't work anymore. + __builtins__.__debug__ = False + if options.optimize > 1: + try: + import psyco + psyco.full() + except ImportError: + log.warning('Psyco isn\'t installed, cannot -OO.') + + if os.name == 'posix' and options.daemon: + def fork(): + child = os.fork() + if child != 0: + if options.debug: + print 'Parent exiting, child PID: %s' % child + # We must us os._exit instead of sys.exit so atexit handlers + # don't run. They shouldn't be dangerous, but they're ugly. + os._exit(0) + fork() + os.setsid() + # What the heck does this do? I wonder if it breaks anything... + os.umask(0) + # Let's not do this for now (at least until I can make sure it works): + # Actually, let's never do this -- we'll always have files open in the + # bot directories, so they won't be able to be unmounted anyway. + # os.chdir('/') + fork() + # Since this is the indicator that no writing should be done to stdout, + # we'll set it to True before closing stdout et alii. + conf.daemonized = True + # Closing stdin shouldn't cause problems. We'll let it raise an + # exception if it does. + sys.stdin.close() + # Closing these two might cause problems; we log writes to them as + # level WARNING on upkeep. + sys.stdin.close() + sys.stdout.close() + sys.stderr.close() + sys.stdout = StringIO.StringIO() + sys.stderr = StringIO.StringIO() + # We have to be really methodical here. + os.close(0) + os.close(1) + os.close(2) + fd = os.open('/dev/null', os.O_RDWR) + os.dup2(fd, 0) + os.dup2(fd, 1) + os.dup2(fd, 2) + signal.signal(signal.SIGHUP, signal.SIG_IGN) + log.info('Completed daemonization. Current PID: %s', os.getpid()) + + # Let's write the PID file. This has to go after daemonization, obviously. + pidFile = conf.supybot.pidFile() + if pidFile: + try: + fd = file(pidFile, 'w') + pid = os.getpid() + fd.write('%s\n' % pid) + fd.close() + def removePidFile(): + try: + os.remove(pidFile) + except EnvironmentError, e: + log.error('Could not remove pid file: %s', e) + atexit.register(removePidFile) + except EnvironmentError, e: + log.error('Error opening pid file %s: %s', pidFile, e) + + conf.allowDefaultOwner = options.allowDefaultOwner + + if not os.path.exists(conf.supybot.directories.log()): + os.mkdir(conf.supybot.directories.log()) + if not os.path.exists(conf.supybot.directories.conf()): + os.mkdir(conf.supybot.directories.conf()) + if not os.path.exists(conf.supybot.directories.data()): + os.mkdir(conf.supybot.directories.data()) + if not os.path.exists(conf.supybot.directories.data.tmp()): + os.mkdir(conf.supybot.directories.tmp()) + + userdataFilename = os.path.join(conf.supybot.directories.conf(), + 'userdata.conf') + # Let's open this now since we've got our directories setup. + if not os.path.exists(userdataFilename): + fd = file(userdataFilename, 'w') + fd.write('\n') + fd.close() + registry.open(userdataFilename) + + import supybot.irclib as irclib + import supybot.ircmsgs as ircmsgs + import supybot.drivers as drivers + import supybot.callbacks as callbacks + import supybot.plugins.Owner as Owner + + owner = Owner.Class() + irclib._callbacks.append(owner) + + if options.profile: + import profile + world.profiling = True + profile.run('main()', '%s-%i.prof' % (nick, time.time())) + else: + main() + + +# vim:set shiftwidth=4 tabstop=8 expandtab textwidth=78: diff --git a/scripts/supybot-adduser b/scripts/supybot-adduser new file mode 100644 index 000000000..2ce72070c --- /dev/null +++ b/scripts/supybot-adduser @@ -0,0 +1,142 @@ +#!/usr/bin/env python + +### +# Copyright (c) 2002-2004, Jeremiah Fincher +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND 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 COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +### + + + +import supybot + +import supybot.fix as fix +from supybot.questions import * + +import os +import sys +import optparse + +def main(): + import supybot.log as log + import supybot.conf as conf + conf.supybot.log.stdout.setValue(False) + parser = optparse.OptionParser(usage='Usage: %prog [options] ', + version='supybot %s' % conf.version) + parser.add_option('-u', '--username', action='store', default='', + dest='name', + help='username for the user.') + parser.add_option('-p', '--password', action='store', default='', + dest='password', + help='password for the user.') + parser.add_option('-x', '--hashed', action='store_const', const=1, + default=0, dest='hashed', + help='hash encrypt the password.') + parser.add_option('-n', '--plain', action='store_const', const=2, + default=0, dest='hashed', + help='store the password in plain text.') + parser.add_option('-c', '--capability', action='append', + dest='capabilities', metavar='CAPABILITY', + help='capability the user should have; ' + 'this option may be given multiple times.') + (options, args) = parser.parse_args() + if len(args) is not 1: + parser.error('Specify the users.conf file you\'d like to use. ' + 'Be sure *not* to specify your registry file, generated ' + 'by supybot-wizard. This is not the file you want. ' + 'Instead, take a look in your conf directory (usually ' + 'named "conf") and take a gander at the file ' + '"users.conf". That\'s the one you want.') + + filename = os.path.abspath(args[0]) + conf.supybot.directories.log.setValue('/') + conf.supybot.directories.conf.setValue('/') + conf.supybot.directories.data.setValue('/') + conf.supybot.directories.plugins.setValue(['/']) + conf.supybot.databases.users.filename.setValue(filename) + import supybot.ircdb as ircdb + + if not options.name: + name = '' + while not name: + name = something('What is the user\'s name?') + try: + # Check to see if the user is already in the database. + _ = ircdb.users.getUser(name) + # Uh oh. That user already exists; + # otherwise we'd have KeyError'ed. + output('That user already exists. Try another name.') + name = '' + except KeyError: + # Good. No such user exists. We'll pass. + pass + else: + try: + # Same as above. We exit here instead. + _ = ircdb.users.getUser(options.name) + output('That user already exists. Try another name.') + sys.exit(-1) + except KeyError: + name = options.name + + if not options.password: + password = getpass('What is %s\'s password? ' % name) + else: + password = options.password + + if options.hashed is 0: + hashed = yn('Do you want the password to be hashed instead of ' + 'storing it as plain text?', default=False) + elif options.hashed is 1: + hashed = True + else: + hashed = False + + if not options.capabilities: + capabilities = [] + prompt = 'Would you like to give %s a capability?' % name + while yn(prompt): + capabilities.append(anything('What capability?')) + prompt = 'Would you like to give %s another capability?' % name + else: + capabilities = options.capabilities + + user = ircdb.users.newUser() + user.name = name + user.setPassword(password, hashed=hashed) + for capability in capabilities: + user.addCapability(capability) + ircdb.users.setUser(user) + ircdb.users.flush() + #os.system('cat %s' % filename) # Was this here just for debugging? + ircdb.users.close() + print 'User %s added.' % name + +if __name__ == '__main__': + try: + main() + except KeyboardInterrupt: + pass diff --git a/scripts/supybot-newplugin b/scripts/supybot-newplugin new file mode 100644 index 000000000..66920d13d --- /dev/null +++ b/scripts/supybot-newplugin @@ -0,0 +1,296 @@ +#!/usr/bin/env python + +### +# Copyright (c) 2002-2004, Jeremiah Fincher +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND 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 COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +### + + + +import supybot + +import os +import sys +import os.path +import optparse + +def error(s): + sys.stderr.write(textwrap.fill(s)) + sys.stderr.write(os.linesep) + sys.exit(-1) + +if sys.version_info < (2, 3, 0): + error('This script requires Python 2.3 or newer.') + +import supybot.conf as conf +from supybot.questions import * + +copyright = ''' +### +# Copyright (c) 2004, %s +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND 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 COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +### +'''.strip() # Here we use strip() instead of lstrip() on purpose. + +pluginTemplate = ''' +%s + +import supybot.utils as utils +from supybot.commands import * +import supybot.plugins as plugins +import supybot.ircutils as ircutils +import supybot.privmsgs as privmsgs +import supybot.callbacks as callbacks + + +class %s(%s): + """Add the help for "@help %s" here.""" + %s + + +Class = %s + + +# vim:set shiftwidth=4 tabstop=8 expandtab textwidth=78: +'''.lstrip() # This removes the newlines that precede and follow the text. + +configTemplate = ''' +%s + +import supybot.conf as conf +import supybot.registry as registry + +def configure(advanced): + # This will be called by supybot to configure this module. advanced is + # a bool that specifies whether the user identified himself as an advanced + # user or not. You should effect your configuration by manipulating the + # registry as appropriate. + from supybot.questions import expect, anything, something, yn + conf.registerPlugin(%r, True) + + +%s = conf.registerPlugin(%r) +# This is where your configuration variables (if any) should go. For example: +# conf.registerGlobalValue(%s, 'someConfigVariableName', +# registry.Boolean(False, """Help for someConfigVariableName.""")) + + +# vim:set shiftwidth=4 tabstop=8 expandtab textwidth=78 +'''.lstrip() + + +__init__Template = ''' +%s + +""" +Add a description of the plugin (to be presented to the user inside the wizard) +here. +""" + +import supybot + +# XXX Replace this with an appropriate author or supybot.Author instance. +__author__ = supybot.authors.unknown + +# This is a dictionary mapping supybot.Author instances to lists of +# contributions. +__contributors__ = {} + +import config +import plugin +reload(plugin) # In case we\'re being reloaded. + +# For backwards compatibility with the old Plugin format. +if hasattr(plugin, '__doc__') and plugin.__doc__: + __doc__ = plugin.__doc__ + +Class = plugin.Class +configure = config.configure + + +# vim:set shiftwidth=4 tabstop=8 expandtab textwidth=78: +'''.lstrip() + +testTemplate = ''' +%s + +from supybot.test import * + +class %sTestCase(PluginTestCase): + plugins = (%r,) + + +# vim:set shiftwidth=4 tabstop=8 expandtab textwidth=78: +'''.lstrip() + +readmeTemplate = ''' +Insert a description of your plugin here, with any notes, etc. about using it. +'''.lstrip() + +def main(): + global copyright + parser = optparse.OptionParser(usage='Usage: %prog [options]', + version='Supybot %s' % conf.version) + parser.add_option('-r', '--regexp', action='store_true', dest='regexp', + help='uses a regexp-based callback.') + parser.add_option('-n', '--name', action='store', dest='name', + help='sets the name for the plugin.') + parser.add_option('-t', '--thread', action='store_true', dest='threaded', + help='makes the plugin threaded.') + parser.add_option('', '--real-name', action='store', dest='realName', + help='Determines what real name the copyright is ' + 'assigned to.') + (options, args) = parser.parse_args() + if options.name: + name = options.name + if options.regexp: + kind = 'regexp' + else: + kind = 'command' + if options.threaded: + threaded = True + else: + threaded = False + if options.realName: + realName = options.realName + else: + name = something('What should the name of the plugin be?') + if name.endswith('.py'): + name = name[:-3] + while name[0].islower(): + print 'Plugin names must begin with a capital.' + name = something('What should the name of the plugin be?') + if name.endswith('.py'): + name = name[:-3] + + if os.path.exists(name): + error('A file or directory named %s already exists; remove or ' + 'rename it and run this program again.' % name) + print textwrap.dedent(""" + Supybot offers two major types of plugins: command-based and + regexp-based. Command-based plugins are the kind of plugins + you've seen most when you've used supybot. They're also the most + featureful and easiest to write. Commands can be nested, for + instance, whereas regexp-based callbacks can't do nesting. + + That doesn't mean that you'll never want regexp-based callbacks. + They offer a flexibility that command-based callbacks don't + offer; however, they don't tie into the whole system as well. + + If you need to combine a command-based callback with some + regexp-based methods, you can do so by subclassing + callbacks.PrivmsgCommandAndRegexp and then adding a class-level + attribute "regexps" that is a sets.Set of methods that are + regexp-based. But you'll have to do that yourself after this + wizard is finished.)""").strip() + print + kind = expect('Do you want a command-based plugin' \ + ' or a regexp-based plugin?', ['command', 'regexp']) + + print textwrap.fill(textwrap.dedent(""" + Sometimes you'll want a callback to be threaded. If its methods + (command or regexp-based, either one) will take a significant amount + of time to run, you'll want to thread them so they don't block the + entire bot.""").strip()) + print + threaded = yn('Does your plugin need to be threaded?') + + realName = something(textwrap.dedent(""" + What is your real name, so I can fill in the copyright and license + appropriately? + """).strip()) + + if threaded: + threaded = 'threaded = True' + else: + threaded = 'pass' + if kind == 'command': + className = 'callbacks.Privmsg' + else: + className = 'callbacks.PrivmsgRegexp' + if name.endswith('.py'): + name = name[:-3] + while name[0].islower(): + print 'Plugin names must begin with a capital.' + name = something('What should the name of the plugin be?') + if name.endswith('.py'): + name = name[:-3] + copyright %= realName + + # Make the directory. + os.mkdir(name) + + def writeFile(filename, s): + fd = file(os.path.join(name, filename), 'w') + try: + fd.write(s) + finally: + fd.close() + + writeFile('plugin.py', pluginTemplate % (copyright, name, className, + name, threaded, name)) + writeFile('config.py', configTemplate % (copyright, name, name, name, name)) + writeFile('__init__.py', __init__Template % copyright) + writeFile('test.py', testTemplate % (copyright, name, name)) + writeFile('README.txt', readmeTemplate) + + print 'Your new plugin template is in the %s directory.' % name + +if __name__ == '__main__': + try: + main() + except KeyboardInterrupt: + print + output("""It looks like you cancelled out of this script before it was + finished. Obviously, nothing was written, but just run this script + again whenever you want to generate a template for a plugin.""") + +# vim:set shiftwidth=4 tabstop=8 expandtab textwidth=78: diff --git a/scripts/supybot-wizard b/scripts/supybot-wizard new file mode 100644 index 000000000..8774f8540 --- /dev/null +++ b/scripts/supybot-wizard @@ -0,0 +1,618 @@ +#!/usr/bin/env python + +### +# Copyright (c) 2003-2004, Jeremiah Fincher +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND 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 COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +### + + + +import os +import sys + +if sys.version_info < (2, 3, 0): + sys.stderr.write('This program requires Python >= 2.3.0\n') + sys.exit(-1) + +import supybot + +import supybot.fix as fix + +import re +import sets +import time +import pydoc +import pprint +import socket +import logging +import optparse +from itertools import imap + +import supybot.ansi as ansi +import supybot.utils as utils +import supybot.ircutils as ircutils +import supybot.registry as registry + +import supybot.questions as questions +from supybot.questions import output, yn, anything, something, expect, getpass + +def getPlugins(pluginDirs): + filenames = [] + for pluginDir in pluginDirs: + try: + filenames.extend(os.listdir(pluginDir)) + except OSError: + continue + plugins = sets.Set([]) + for filename in filenames: + if filename.endswith('.py') and filename[0].isupper(): + plugins.add(os.path.splitext(filename)[0]) + plugins.discard('Owner') + plugins = list(plugins) + plugins.sort() + return plugins + +def loadPlugin(name): + import supybot.Owner as Owner + try: + module = Owner.loadPluginModule(name) + if hasattr(module, 'Class'): + return module + else: + output("""That plugin loaded fine, but didn't seem to be a real + Supybot plugin; there was no Class variable to tell us what class + to load when we load the plugin. We'll skip over it for now, but + you can always add it later.""") + return None + except Exception, e: + output("""We encountered a bit of trouble trying to load plugin %r. + Python told us %r. We'll skip over it for now, you can always add it + later.""" % (name, utils.exnToString(e))) + return None + +def describePlugin(module, showUsage): + if module.__doc__: + output(module.__doc__, unformatted=False) + elif hasattr(module.Class, '__doc__'): + output(module.Class.__doc__, unformatted=False) + else: + output("""Unfortunately, this plugin doesn't seem to have any + documentation. Sorry about that.""") + if showUsage: + if hasattr(module, 'example'): + if yn('This plugin has a usage example. ' + 'Would you like to see it?', default=False): + pydoc.pager(module.example) + else: + output("""This plugin has no usage example.""") + +def clearLoadedPlugins(plugins, pluginRegistry): + for plugin in plugins: + try: + pluginKey = pluginRegistry.get(plugin) + if pluginKey(): + plugins.remove(plugin) + except registry.NonExistentRegistryEntry: + continue + +_windowsVarRe = re.compile(r'%(\w+)%') +def getDirectoryName(default, basedir=os.curdir): + done = False + while not done: + dir = something('What directory do you want to use?', + default=os.path.join(basedir, default)) + orig_dir = dir + dir = os.path.expanduser(dir) + dir = _windowsVarRe.sub(r'$\1', dir) + dir = os.path.expandvars(dir) + dir = os.path.abspath(dir) + try: + os.makedirs(dir) + done = True + except OSError, e: + if e.args[0] != 17: # File exists. + output("""Sorry, I couldn't make that directory for some + reason. The Operating System told me %s. You're going to + have to pick someplace else.""" % e) + else: + done = True + return (dir, os.path.dirname(orig_dir)) + +def main(): + import supybot.log as log + import supybot.conf as conf + log._stdoutHandler.setLevel(100) # *Nothing* gets through this! + parser = optparse.OptionParser(usage='Usage: %prog [options]', + version='Supybot %s' % conf.version) + parser.add_option('', '--allow-root', action='store_true', + dest='allowRoot', + help='Determines whether the wizard will be allowed to ' + 'run as root. You don\'t want this. Don\'t do it.' + ' Even if you think you want it, you don\'t. ' + 'You\'re probably dumb if you do this.') + parser.add_option('', '--no-network', action='store_false', + dest='network', + help='Determines whether the wizard will be allowed to ' + 'run without a network connection.') + (options, args) = parser.parse_args() + if os.name == 'posix': + if (os.getuid() == 0 or os.geteuid() == 0) and not options.allowRoot: + sys.stderr.write('Please, don\'t run this as root.\n') + sys.exit(-1) + + filename = '' + if args: + parser.error('This program takes no non-option arguments.') + output("""This is a wizard to help you start running supybot. What it + will do is create a single Python file whose effect will be that of + starting an IRC bot with the options you select here. So hold on tight + and be ready to be interrogated :)""") + + + output("""First of all, we can bold the questions you're asked so you can + easily distinguish the mostly useless blather (like this) from the + questions that you actually have to answer.""") + if yn('Would you like to try this bolding?', default=True): + questions.useBold = True + if not yn('Do you see this in bold?'): + output("""Sorry, it looks like your terminal isn't ANSI compliant. + Try again some other day, on some other terminal :)""") + questions.useBold = False + else: + output("""Great!""") + + ### + # Preliminary questions. + ### + output("""We've got some preliminary things to get out of the way before + we can really start asking you questions that directly relate to what your + bot is going to be like.""") + + # Advanced? + output("""We want to know if you consider yourself an advanced Supybot + user because some questions are just utterly boring and useless for new + users. Others might not make sense unless you've used Supybot for some + time.""") + advanced = yn('Are you an advanced Supybot user?', default=False) + + ### Directories. + # We set these variables in cache because otherwise conf and log will + # create directories for the default values, which might not be what the + # user wants. + output("""Now we've got to ask you some questions about where some of + your directories are (or, perhaps, will be :)). If you're running this + wizard from the directory you'll actually be starting your bot from and + don't mind creating some directories in the current directory, then just + don't give answers to these questions and we'll create the directories we + need right here in this directory.""") + + # conf.supybot.directories.log + output("""Your bot will need to put his logs somewhere. Do you have any + specific place you'd like them? If not, just press enter and we'll make + a directory named "logs" right here.""") + (logDir, basedir) = getDirectoryName('logs') + conf.supybot.directories.log.setValue(logDir) + + # conf.supybot.directories.data + output("""Your bot will need to put various data somewhere. Things like + databases, downloaded files, etc. Do you have any specific place you'd + like the bot to put these things? If not, just press enter and we'll make + a directory named "data" right here.""") + (dataDir, basedir) = getDirectoryName('data', basedir=basedir) + conf.supybot.directories.data.setValue(dataDir) + + # conf.supybot.directories.conf + output("""Your bot must know where to find his configuration files. It'll + probably only make one or two, but it's gotta have some place to put them. + Where should that place be? If you don't care, just press enter and we'll + make a directory right here named "conf" where it'll store his stuff. """) + (confDir, basedir) = getDirectoryName('conf', basedir=basedir) + conf.supybot.directories.conf.setValue(confDir) + + # pluginDirs + output("""Your bot will also need to know where to find his plugins at. + Of course, he already knows where the plugins that he came with are, but + your own personal plugins that you write for will probably be somewhere + else.""") + pluginDirs = conf.supybot.directories.plugins() + output("""Currently, the bot knows about the following directories:""") + output(utils.commaAndify(pluginDirs)) + while yn('Would you like to add another plugin directory?', + default=False): + (pluginDir, _) = getDirectoryName('plugins', basedir=basedir) + if pluginDir not in pluginDirs: + pluginDirs.append(pluginDir) + conf.supybot.directories.plugins.setValue(pluginDirs) + + output("Good! We're done with the directory stuff.") + + ### + # Bot stuff + ### + output("""Now we're going to ask you things that actually relate to the + bot you'll be running.""") + + network = None + while not network: + output("""First, we need to know the name of the network you'd like to + connect to. Not the server host, mind you, but the name of the + network. If you plan to connect to irc.freenode.net, for instance, you + should answer this question with 'freenode' (without the quotes).""") + network = something('What IRC network will you be connecting to?') + if '.' in network: + output("""There shouldn't be a '.' in the network name. Remember, + this is the network name, not the actual server you plan to connect + to.""") + network = None + elif not registry.isValidRegistryName(network): + output("""That's not a valid name for one reason or another. Please + pick a simpler name, one more likely to be valid.""") + network = None + + conf.supybot.networks.setValue([network]) + network = conf.registerNetwork(network) + + defaultServer = None + server = None + ip = None + while not ip: + serverString = something('What server would you like to connect to?', + default=defaultServer) + if options.network: + try: + output("""Looking up %s...""" % serverString) + ip = socket.gethostbyname(serverString) + except: + output("""Sorry, I couldn't find that server. Perhaps you + misspelled it? Also, be sure not to put the port in the + server's name -- we'll ask you about that later.""") + else: + ip = 'no network available' + + output("""Found %s (%s).""" % (serverString, ip)) + output("""IRC Servers almost always accept connections on port + 6667. They can, however, accept connections anywhere their admin + feels like he wants to accept connections from.""") + if yn('Does this server require connection on a non-standard port?', + default=False): + port = 0 + while not port: + port = something('What port is that?') + try: + i = int(port) + if not (0 < i < 65536): + raise ValueError + except ValueError: + output("""That's not a valid port.""") + port = 0 + else: + port = 6667 + server = ':'.join([serverString, str(port)]) + network.servers.setValue([server]) + + # conf.supybot.nick + # Force the user into specifying a nick if he didn't have one already + while True: + nick = something('What nick would you like your bot to use?', + default=None) + try: + conf.supybot.nick.set(nick) + break + except registry.InvalidRegistryValue: + output("""That's not a valid nick. Go ahead and pick another.""") + + # conf.supybot.user + if advanced: + output("""If you've ever done a /whois on a person, you know that IRC + provides a way for users to show the world their full name. What would + you like your bot's full name to be? If you don't care, just press + enter and it'll be the same as your bot's nick.""") + user = '' + user = something('What would you like your bot\'s full name to be?', + default=nick) + conf.supybot.user.set(user) + # conf.supybot.ident (if advanced) + defaultIdent = 'supybot' + if advanced: + output("""IRC servers also allow you to set your ident, which they + might need if they can't find your identd server. What would you + like your ident to be? If you don't care, press enter and we'll + use 'supybot'. In fact, we prefer that you + do this, because it provides free advertising for Supybot when users + /whois your bot. But, of course, it's your call.""") + while True: + ident = something('What would you like your bot\'s ident to be?', + default=defaultIdent) + try: + conf.supybot.ident.set(ident) + break + except registry.InvalidRegistryValue: + output("""That was not a valid ident. + Go ahead and pick another.""") + else: + conf.supybot.ident.set(defaultIdent) + + # conf.supybot.password + output("""Some servers require a password to connect to them. Most + public servers don't. If you try to connect to a server and for some + reason it just won't work, it might be that you need to set a + password.""") + if yn('Do you want to set such a password?', default=False): + network.password.set(getpass()) + + # conf.supybot.networks..channels + output("""Of course, having an IRC bot isn't the most useful thing in the + world unless you can make that bot join some channels.""") + if yn('Do you want your bot to join some channels when he connects?', + default=True): + defaultChannels = ' '.join(network.channels()) + output("""Separate channels with spaces. If the channel is locked + with a key, follow the channel name with the key separated + by a comma. For example: + #supybot #mychannel,mykey #otherchannel"""); + while True: + channels = something('What channels?', default=defaultChannels) + try: + network.channels.set(channels) + break + except registry.InvalidRegistryValue: + # FIXME: say which ones weren't channels. + output("""Not all of those are valid IRC channels. Be sure to + prefix the channel with # (or +, or !, or &, but no one uses + those channels, really). Be sure the channel key (if you are + supplying one) does not contain a comma.""") + else: + network.channels.setValue([]) + + ### + # Plugins + ### + def configurePlugin(module, advanced): + if hasattr(module, 'configure'): + output("""Beginning configuration for %s...""" % + module.Class.__name__) + module.configure(advanced) + print # Blank line :) + output("""Done!""") + else: + conf.registerPlugin(module.__name__, currentValue=True) + + plugins = getPlugins(pluginDirs) + for s in ('Admin', 'User', 'Channel', 'Misc', 'Config'): + configurePlugin(loadPlugin(s), advanced) + clearLoadedPlugins(plugins, conf.supybot.plugins) + + output("""Now we're going to run you through plugin configuration. There's + a variety of plugins in supybot by default, but you can create and + add your own, of course. We'll allow you to take a look at the known + plugins' descriptions and configure them + if you like what you see.""") + + # bulk + addedBulk = False + if advanced and yn('Would you like to add plugins en masse first?'): + addedBulk = True + output("""The available plugins are: %s.""" % \ + utils.commaAndify(plugins)) + output("""What plugins would you like to add? If you've changed your + mind and would rather not add plugins in bulk like this, just press + enter and we'll move on to the individual plugin configuration.""") + massPlugins = anything('Separate plugin names by spaces:') + for name in re.split(r',?\s+', massPlugins): + module = loadPlugin(name) + if module is not None: + configurePlugin(module, advanced) + clearLoadedPlugins(plugins, conf.supybot.plugins) + + # individual + if yn('Would you like to look at plugins individually?'): + output("""Next comes your opportunity to learn more about the plugins + that are available and select some (or all!) of them to run in your + bot. Before you have to make a decision, of course, you'll be able to + see a short description of the plugin and, if you choose, an example + session with the plugin. Let's begin.""") + # until we get example strings again, this will default to false + #showUsage =yn('Would you like the option of seeing usage examples?') + showUsage = False + name = expect('What plugin would you like to look at?', + plugins, acceptEmpty=True) + while name: + module = loadPlugin(name) + if module is not None: + describePlugin(module, showUsage) + if yn('Would you like to load this plugin?', default=True): + configurePlugin(module, advanced) + clearLoadedPlugins(plugins, conf.supybot.plugins) + if not yn('Would you like add another plugin?'): + break + name = expect('What plugin would you like to look at?', plugins) + + ### + # Sundry + ### + output("""Although supybot offers a supybot-adduser script, with which + you can add users to your bot's user database, it's *very* important that + you have an owner user for you bot.""") + if yn('Would you like to add an owner user for your bot?', default=True): + import supybot.ircdb as ircdb + name = something('What should the owner\'s username be?') + try: + id = ircdb.users.getUserId(name) + u = ircdb.users.getUser(id) + if u._checkCapability('owner'): + output("""That user already exists, and has owner capabilities + already. Perhaps you added it before? """) + if yn('Do you want to remove its owner capability?', + default=False): + u.removeCapability('owner') + ircdb.users.setUser(id, u) + else: + output("""That user already exists, but doesn't have owner + capabilities.""") + if yn('Do you want to add to it owner capabilities?', + default=False): + u.addCapability('owner') + ircdb.users.setUser(id, u) + except KeyError: + password = getpass('What should the owner\'s password be?') + u = ircdb.users.newUser() + u.name = name + u.setPassword(password) + u.addCapability('owner') + ircdb.users.setUser(u) + + output("""Of course, when you're in an IRC channel you can address the bot + by its nick and it will respond, if you give it a valid command (it may or + may not respond, depending on what your config variable replyWhenNotCommand + is set to). But your bot can also respond to a short "prefix character," + so instead of saying "bot: do this," you can say, "@do this" and achieve + the same effect. Of course, you don't *have* to have a prefix char, but + if the bot ends up participating significantly in your channel, it'll ease + things.""") + if yn('Would you like to set the prefix char(s) for your bot? ', + default=True): + output("""Enter any characters you want here, but be careful: they + should be rare enough that people don't accidentally address the bot + (simply because they'll probably be annoyed if they do address the bot + on accident). You can even have more than one. I (jemfinch) am quite + partial to @, but that's because I've been using it since my ocamlbot + days.""") + import supybot.callbacks as callbacks + c = '' + while not c: + try: + c = anything('What would you like your bot\'s prefix ' + 'character(s) to be?') + conf.supybot.reply.whenAddressedBy.chars.set(c) + except registry.InvalidRegistryValue, e: + output(str(e)) + c = '' + else: + conf.supybot.reply.whenAddressedBy.chars.set('') + + ### + # logging variables. + ### + + # conf.supybot.log.stdout + output("""By default, your bot will log not only to files in the logs + directory you gave it, but also to stdout. We find this useful for + debugging, and also just for the pretty output (it's colored!)""") + stdout = not yn('Would you like to turn off this logging to stdout?', + default=False) + conf.supybot.log.stdout.setValue(stdout) + if conf.supybot.log.stdout(): + # conf.something + output("""Some terminals may not be able to display the pretty colors + logged to stderr. By default, though, we turn the colors off for + Windows machines and leave it on for *nix machines.""") + if os.name is not 'nt': + conf.supybot.log.stdout.colorized.setValue( + not yn('Would you like to turn this colorization off?', + default=False)) + + if advanced: + output("""Here's some stuff you only get to choose if you're an + advanced user :)""") + + # conf.supybot.log.level + output("""Your bot can handle debug messages at several priorities, + CRITICAL, ERROR, WARNING, INFO, and DEBUG, in decreasing order of + priority. By default, your bot will log all of these priorities except + DEBUG. You can, however, specify that it only log messages above a + certain priority level.""") + priority = str(conf.supybot.log.level) + logLevel = something('What would you like the minimum priority to be?' + ' Just press enter to accept the default.', + default=priority).lower() + while logLevel not in ['debug','info','warning','error','critical']: + output("""That's not a valid priority. Valid priorities include + 'DEBUG', 'INFO', 'WARNING', 'ERROR', and 'CRITICAL'""") + logLevel = something('What would you like the minimum priority to ' + 'be? Just press enter to accept the default.', + default=priority).lower() + conf.supybot.log.level.set(logLevel) + + # conf.supybot.databases.plugins.channelSpecific + + output("""Many plugins in Supybot are channel-specific. Their + databases, likewise, are specific to each channel the bot is in. Many + people don't want this, so we have one central location in which to + say that you would prefer all databases for all channels to be shared. + This variable, supybot.databases.plugins.channelSpecific, is that + place.""") + + conf.supybot.databases.plugins.channelSpecific.setValue( + not yn('Would you like plugin databases to be shared by all ' + 'channels, rather than specific to each channel the ' + 'bot is in?')) + + output("""There are a lot of options we didn't ask you about simply + because we'd rather you get up and running and have time + left to play around with your bot. But come back and see + us! When you've played around with your bot enough to + know what you like, what you don't like, what you'd like + to change, then take a look at your configuration file + when your bot isn't running and read the comments, + tweaking values to your heart's desire.""") + + # Let's make sure that src/ plugins are loaded. + conf.registerPlugin('Admin', True) + conf.registerPlugin('Channel', True) + conf.registerPlugin('Config', True) + conf.registerPlugin('Misc', True) + conf.registerPlugin('User', True) + + ### + # Write the registry + ### + conf.supybot.debug.generated.setValue('%s; %s' % + ( + if not filename: + filename = '%s.conf' % nick + registry.close(conf.supybot, filename) + + # Done! + output("""All done! Your new bot configuration is %s. If you're running + a *nix based OS, you can probably start your bot with the command line + "supybot %s". If you're not running a *nix or similar machine, you'll + just have to start it like you start all your other Python scripts.""" % \ + (filename, filename)) + +if __name__ == '__main__': + try: + main() + except KeyboardInterrupt: + # We may still be using bold text when exiting during a prompt + if questions.useBold: + import supybot.ansi as ansi + print ansi.RESET + print + print + output("""Well, it looks like you canceled out of the wizard before + it was done. Unfortunately, I didn't get to write anything to file. + Please run the wizard again to completion.""") diff --git a/setup.py b/setup.py new file mode 100644 index 000000000..1d04d86fa --- /dev/null +++ b/setup.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python + +### +# Copyright (c) 2002, Jeremiah Fincher +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND 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 COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +### + +import sys + +if sys.version_info < (2, 3, 0): + sys.stderr.write("Supybot requires Python 2.3 or newer.\n") + sys.exit(-1) + +import textwrap + +clean = False +while '--clean' in sys.argv: + clean = True + sys.argv.remove('--clean') + +import glob +import shutil +import os.path + +def normalizeWhitespace(s): + return ' '.join(s.split()) + +try: + from distutils.core import setup + from distutils.sysconfig import get_python_lib +except ImportError, e: + s = normalizeWhitespace("""Supybot requires the distutils package to + install. This package is normally included with Python, but for some + unfathomable reason, many distributions to take it out of standard Python + and put it in another package, usually caled 'python-dev' or python-devel' + or something similar. This is one of the dumbest things a distribution can + do, because it means that developers cannot rely on *STANDARD* Python + modules to be present on systems of that distribution. Complain to your + distribution, and loudly. If you how much of our time we've wasted telling + people to install what should be included by default with Python you'd + understand why we're unhappy about this. Anyway, to reiterate, install the + development package for Python that your distribution supplies.""") + sys.stderr.write(os.linesep*2) + sys.stderr.write(textwrap.fill(s)) + sys.stderr.write(os.linesep*2) + sys.exit(-1) + + + +srcFiles = glob.glob(os.path.join('src', '*.py')) +pluginFiles = glob.glob(os.path.join('plugins', '*.py')) + +if clean: + previousInstall = os.path.join(get_python_lib(), 'supybot') + if os.path.exists(previousInstall): + try: + print 'Removing current installation.' + shutil.rmtree(previousInstall) + except Exception, e: + print 'Couldn\'t remove former installation: %s' % e + sys.exit(-1) + +plugins = [ + 'Admin', + 'Channel', + 'Config', + 'Misc', + 'Owner', + 'User', + ] + +packages = ['supybot', + 'supybot.drivers', + 'supybot.plugins',] + \ + ['supybot.plugins.'+s for s in plugins] + +package_dir = {'supybot': 'src', + 'supybot.plugins': 'plugins', + 'supybot.drivers': 'src/drivers',} + +for plugin in plugins: + package_dir['supybot.plugins.' + plugin] = 'plugins/' + plugin + +version = '0.80.0' +setup( + # Metadata + name='supybot', + version=version, + author='Jeremy Fincher', + url='http://supybot.com/', + author_email='jemfinch@supybot.com', + download_url='http://www.sf.net/project/showfiles.php?group_id=58965', + description='A flexible and extensible Python IRC bot and framework.', + long_description=normalizeWhitespace("""A robust, full-featured Python IRC + bot with a clean and flexible plugin API. Equipped with a complete ACL + system for specifying user permissions with as much as per-command + granularity. Batteries are included in the form of numerous plugins + already written."""), + classifiers = [ + 'Development Status :: 4 - Beta', + 'Environment :: Console', + 'Environment :: No Input/Output (Daemon)', + 'Intended Audience :: End Users/Desktop', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: BSD License', + 'Topic :: Communications :: Chat :: Internet Relay Chat', + 'Natural Language :: English', + 'Operating System :: OS Independent', + 'Operating System :: POSIX', + 'Operating System :: Microsoft :: Windows', + 'Programming Language :: Python', + ], + + # Installation data + packages=packages, + + package_dir=package_dir, + + scripts=['scripts/supybot', + 'scripts/supybot-wizard', + 'scripts/supybot-adduser', + 'scripts/supybot-newplugin'] + ) + + +# vim:set shiftwidth=4 tabstop=8 expandtab textwidth=78: diff --git a/src/ansi.py b/src/ansi.py new file mode 100644 index 000000000..81a127b7d --- /dev/null +++ b/src/ansi.py @@ -0,0 +1,111 @@ +### +# Copyright (c) 2002-2004, Jeremiah Fincher +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND 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 COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +### + +""" +ansi.py + +ANSI Terminal Interface + +Color Usage: + print RED + 'this is red' + RESET + print BOLD + GREEN + WHITEBG + 'this is bold green on white' + RESET + def move(new_x, new_y): 'Move cursor to new_x, new_y' + def moveUp(lines): 'Move cursor up # of lines' + def moveDown(lines): 'Move cursor down # of lines' + def moveForward(chars): 'Move cursor forward # of chars' + def moveBack(chars): 'Move cursor backward # of chars' + def save(): 'Saves cursor position' + def restore(): 'Restores cursor position' + def clear(): 'Clears screen and homes cursor' + def clrtoeol(): 'Clears screen to end of line' +""" + + + +################################ +# C O L O R C O N S T A N T S # +################################ +BLACK = '\033[30m' +RED = '\033[31m' +GREEN = '\033[32m' +YELLOW = '\033[33m' +BLUE = '\033[34m' +MAGENTA = '\033[35m' +CYAN = '\033[36m' +WHITE = '\033[37m' + +RESET = '\033[0;0m' +BOLD = '\033[1m' +REVERSE = '\033[2m' + +BLACKBG = '\033[40m' +REDBG = '\033[41m' +GREENBG = '\033[42m' +YELLOWBG = '\033[43m' +BLUEBG = '\033[44m' +MAGENTABG = '\033[45m' +CYANBG = '\033[46m' +WHITEBG = '\033[47m' + +#def move(new_x, new_y): +# 'Move cursor to new_x, new_y' +# print '\033[' + str(new_x) + ';' + str(new_y) + 'H' +# +#def moveUp(lines): +# 'Move cursor up # of lines' +# print '\033[' + str(lines) + 'A' +# +#def moveDown(lines): +# 'Move cursor down # of lines' +# print '\033[' + str(lines) + 'B' +# +#def moveForward(chars): +# 'Move cursor forward # of chars' +# print '\033[' + str(chars) + 'C' +# +#def moveBack(chars): +# 'Move cursor backward # of chars' +# print '\033[' + str(chars) + 'D' +# +#def save(): +# 'Saves cursor position' +# print '\033[s' +# +#def restore(): +# 'Restores cursor position' +# print '\033[u' +# +#def clear(): +# 'Clears screen and homes cursor' +# print '\033[2J' +# +#def clrtoeol(): +# 'Clears screen to end of line' +# print '\033[K' +# vim:set shiftwidth=4 tabstop=8 expandtab textwidth=78: diff --git a/src/callbacks.py b/src/callbacks.py new file mode 100644 index 000000000..2e5a8d751 --- /dev/null +++ b/src/callbacks.py @@ -0,0 +1,1394 @@ +### +# Copyright (c) 2002-2004, Jeremiah Fincher +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND 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 COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +### + +""" +This module contains the basic callbacks for handling PRIVMSGs. Both Privmsg +and PrivmsgRegexp classes are provided; for offering callbacks based on +commands and their arguments (much like *nix command line programs) use the +Privmsg class; for offering callbacks based on regular expressions, use the +PrivmsgRegexp class. Read their respective docstrings for more information on +how to use them. +""" + +import supybot + + + +import supybot.fix as fix + +import re +import copy +import sets +import time +import shlex +import getopt +import string +import inspect +import operator +from cStringIO import StringIO +from itertools import imap, ifilter + +import supybot.log as log +import supybot.conf as conf +import supybot.utils as utils +import supybot.world as world +import supybot.ircdb as ircdb +import supybot.irclib as irclib +import supybot.ircmsgs as ircmsgs +import supybot.ircutils as ircutils +import supybot.registry as registry + +def _addressed(nick, msg, prefixChars=None, nicks=None, + prefixStrings=None, whenAddressedByNick=None, + whenAddressedByNickAtEnd=None): + def get(group): + if ircutils.isChannel(target): + group = group.get(target) + return group() + def stripPrefixStrings(payload): + for prefixString in prefixStrings: + if payload.startswith(prefixString): + payload = payload[len(prefixString):].lstrip() + return payload + + assert msg.command == 'PRIVMSG' + (target, payload) = msg.args + if not payload: + return '' + if prefixChars is None: + prefixChars = get(conf.supybot.reply.whenAddressedBy.chars) + if whenAddressedByNick is None: + whenAddressedByNick = get(conf.supybot.reply.whenAddressedBy.nick) + if whenAddressedByNickAtEnd is None: + r = conf.supybot.reply.whenAddressedBy.nick.atEnd + whenAddressedByNickAtEnd = get(r) + if prefixStrings is None: + prefixStrings = get(conf.supybot.reply.whenAddressedBy.strings) + # We have to check this before nicks -- try "@google supybot" with supybot + # and whenAddressedBy.nick.atEnd on to see why. + if any(payload.startswith, prefixStrings): + return stripPrefixStrings(payload) + elif payload[0] in prefixChars: + return payload[1:].strip() + if nicks is None: + nicks = get(conf.supybot.reply.whenAddressedBy.nicks) + nicks = map(ircutils.toLower, nicks) + else: + nicks = list(nicks) # Just in case. + nicks.insert(0, ircutils.toLower(nick)) + # Ok, let's see if it's a private message. + if ircutils.nickEqual(target, nick): + payload = stripPrefixStrings(payload) + while payload and payload[0] in prefixChars: + payload = payload[1:].lstrip() + return payload + # Ok, not private. Does it start with our nick? + elif whenAddressedByNick: + for nick in nicks: + lowered = ircutils.toLower(payload) + if lowered.startswith(nick): + try: + (maybeNick, rest) = payload.split(None, 1) + toContinue = False + while not ircutils.isNick(maybeNick, strictRfc=True): + if maybeNick[-1].isalnum(): + toContinue = True + break + maybeNick = maybeNick[:-1] + if toContinue: + continue + if ircutils.nickEqual(maybeNick, nick): + return rest + else: + continue + except ValueError: # split didn't work. + continue + elif whenAddressedByNickAtEnd and lowered.endswith(nick): + rest = payload[:-len(nick)] + possiblePayload = rest.rstrip(' \t,;') + if possiblePayload != rest: + # There should be some separator between the nick and the + # previous alphanumeric character. + return possiblePayload + if conf.supybot.reply.whenNotAddressed(): + return payload + else: + return '' + +def addressed(nick, msg, **kwargs): + """If msg is addressed to 'name', returns the portion after the address. + Otherwise returns the empty string. + """ + payload = msg.addressed + if payload is not None: + return payload + else: + payload = _addressed(nick, msg, **kwargs) + msg.tag('addressed', payload) + return payload + +def canonicalName(command): + """Turn a command into its canonical form. + + Currently, this makes everything lowercase and removes all dashes and + underscores. + """ + if isinstance(command, unicode): + command = command.encode('utf-8') + special = '\t -_' + reAppend = '' + while command and command[-1] in special: + reAppend = command[-1] + reAppend + command = command[:-1] + return command.translate(string.ascii, special).lower() + reAppend + +def reply(msg, s, prefixName=None, private=None, + notice=None, to=None, action=None, error=False): + msg.tag('repliedTo') + # Ok, let's make the target: + # XXX This isn't entirely right. Consider to=#foo, private=True. + target = ircutils.replyTo(msg) + if ircutils.isChannel(target): + channel = target + else: + channel = None + if notice is None: + notice = conf.get(conf.supybot.reply.withNotice, channel) + if private is None: + private = conf.get(conf.supybot.reply.inPrivate, channel) + if prefixName is None: + prefixName = conf.get(conf.supybot.reply.withNickPrefix, channel) + if error: + notice =conf.get(conf.supybot.reply.error.withNotice, channel) or notice + private=conf.get(conf.supybot.reply.error.inPrivate, channel) or private + s = 'Error: ' + s + if private: + prefixName = False + if to is None: + target = msg.nick + else: + target = to + if to is None: + to = msg.nick + # Ok, now let's make the payload: + s = ircutils.safeArgument(s) + if not s and not action: + s = 'Error: I tried to send you an empty message.' + if prefixName and ircutils.isChannel(target): + # Let's may sure we don't do, "#channel: foo.". + if not ircutils.isChannel(to): + s = '%s: %s' % (to, s) + if not ircutils.isChannel(target): + if conf.supybot.reply.withNoticeWhenPrivate(): + notice = True + # And now, let's decide whether it's a PRIVMSG or a NOTICE. + msgmaker = ircmsgs.privmsg + if notice: + msgmaker = ircmsgs.notice + # We don't use elif here because actions can't be sent as NOTICEs. + if action: + msgmaker = ircmsgs.action + # Finally, we'll return the actual message. + ret = msgmaker(target, s) + ret.tag('inReplyTo', msg) + return ret + +def error(msg, s, **kwargs): + """Makes an error reply to msg with the appropriate error payload.""" + kwargs['error'] = True + msg.tag('isError') + return reply(msg, s, **kwargs) + +def getHelp(method, name=None): + if name is None: + name = method.__name__ + doclines = method.__doc__.splitlines() + s = '%s %s' % (name, doclines.pop(0)) + if doclines: + help = ' '.join(doclines) + s = '(%s) -- %s' % (ircutils.bold(s), help) + return utils.normalizeWhitespace(s) + +def getSyntax(method, name=None): + if name is None: + name = method.__name__ + doclines = method.__doc__.splitlines() + return '%s %s' % (name, doclines[0]) + +class Error(Exception): + """Generic class for errors in Privmsg callbacks.""" + pass + +class ArgumentError(Error): + """The bot replies with a help message when this is raised.""" + pass + +class Tokenizer(object): + # This will be used as a global environment to evaluate strings in. + # Evaluation is, of course, necessary in order to allowed escaped + # characters to be properly handled. + # + # These are the characters valid in a token. Everything printable except + # double-quote, left-bracket, and right-bracket. + validChars = string.ascii.translate(string.ascii, '\x00\r\n \t') + def __init__(self, brackets='', pipe=False, quotes='"'): + if brackets: + self.validChars = self.validChars.translate(string.ascii, brackets) + self.left = brackets[0] + self.right = brackets[1] + else: + self.left = '' + self.right = '' + self.pipe = pipe + if self.pipe: + self.validChars = self.validChars.translate(string.ascii, '|') + self.quotes = quotes + self.validChars = self.validChars.translate(string.ascii, quotes) + + + def _handleToken(self, token): + if token[0] == token[-1] and token[0] in self.quotes: + token = token[1:-1] + token = token.decode('string-escape') + return token + + def _insideBrackets(self, lexer): + ret = [] + while True: + token = lexer.get_token() + if not token: + raise SyntaxError, 'Missing "%s". You may want to ' \ + 'quote your arguments with double ' \ + 'quotes in order to prevent extra ' \ + 'brackets from being evaluated ' \ + 'as nested commands.' % self.right + elif token == self.right: + return ret + elif token == self.left: + ret.append(self._insideBrackets(lexer)) + else: + ret.append(self._handleToken(token)) + firstToken = False + return ret + + def tokenize(self, s): + lexer = shlex.shlex(StringIO(s)) + lexer.commenters = '' + lexer.quotes = self.quotes + lexer.wordchars = self.validChars + args = [] + ends = [] + while True: + token = lexer.get_token() + if not token: + break + elif token == '|' and self.pipe: + # The "and self.pipe" might seem redundant here, but it's there + # for strings like 'foo | bar', where a pipe stands alone as a + # token, but shouldn't be treated specially. + if not args: + raise SyntaxError, '"|" with nothing preceding. I ' \ + 'obviously can\'t do a pipe with ' \ + 'nothing before the |.' + ends.append(args) + args = [] + elif token == self.left: + args.append(self._insideBrackets(lexer)) + elif token == self.right: + raise SyntaxError, 'Spurious "%s". You may want to ' \ + 'quote your arguments with double ' \ + 'quotes in order to prevent extra ' \ + 'brackets from being evaluated ' \ + 'as nested commands.' % self.right + else: + args.append(self._handleToken(token)) + if ends: + if not args: + raise SyntaxError, '"|" with nothing following. I ' \ + 'obviously can\'t do a pipe with ' \ + 'nothing before the |.' + args.append(ends.pop()) + while ends: + args[-1].append(ends.pop()) + return args + +def tokenize(s, channel=None): + """A utility function to create a Tokenizer and tokenize a string.""" + pipe = False + brackets = '' + nested = conf.supybot.commands.nested + if nested(): + brackets = conf.get(nested.brackets, channel) + if conf.get(nested.pipeSyntax, channel): # No nesting, no pipe. + pipe = True + quotes = conf.get(conf.supybot.commands.quotes, channel) + start = time.time() + try: + ret = Tokenizer(brackets=brackets,pipe=pipe,quotes=quotes).tokenize(s) + log.stat('tokenize took %s seconds.' % (time.time() - start)) + return ret + except ValueError, e: + raise SyntaxError, str(e) + +def getCommands(tokens): + """Given tokens as output by tokenize, returns the command names.""" + L = [] + if tokens and isinstance(tokens, list): + L.append(tokens[0]) + for elt in tokens: + L.extend(getCommands(elt)) + return L + +def findCallbackForCommand(irc, name): + """Given a command name and an Irc object, returns a list of callbacks that + commandName is in.""" + L = [] + name = canonicalName(name) + for callback in irc.callbacks: + if not isinstance(callback, PrivmsgRegexp): + if hasattr(callback, 'isCommand'): + if callback.isCommand(name): + L.append(callback) + return L + +def formatArgumentError(method, name=None): + if name is None: + name = method.__name__ + if hasattr(method, '__doc__') and method.__doc__: + if conf.get(conf.supybot.reply.showSimpleSyntax, dynamic.channel): + return getSyntax(method, name=name) + else: + return getHelp(method, name=name) + else: + return 'Invalid arguments for %s.' % method.__name__ + +def checkCommandCapability(msg, cb, commandName): + assert isinstance(commandName, basestring), commandName + plugin = cb.name().lower() + pluginCommand = '%s.%s' % (plugin, commandName) + def checkCapability(capability): + assert ircdb.isAntiCapability(capability) + if ircdb.checkCapability(msg.prefix, capability): + log.info('Preventing %s from calling %s because of %s.', + msg.prefix, pluginCommand, capability) + raise RuntimeError, capability + try: + antiPlugin = ircdb.makeAntiCapability(plugin) + antiCommand = ircdb.makeAntiCapability(commandName) + antiPluginCommand = ircdb.makeAntiCapability(pluginCommand) + checkCapability(antiPlugin) + checkCapability(antiCommand) + checkCapability(antiPluginCommand) + checkAtEnd = [commandName, pluginCommand] + default = conf.supybot.capabilities.default() + if ircutils.isChannel(msg.args[0]): + channel = msg.args[0] + checkCapability(ircdb.makeChannelCapability(channel, antiCommand)) + checkCapability(ircdb.makeChannelCapability(channel, antiPlugin)) + checkCapability(ircdb.makeChannelCapability(channel, + antiPluginCommand)) + chanPlugin = ircdb.makeChannelCapability(channel, plugin) + chanCommand = ircdb.makeChannelCapability(channel, commandName) + chanPluginCommand = ircdb.makeChannelCapability(channel, + pluginCommand) + checkAtEnd += [chanCommand, chanPlugin, chanPluginCommand] + default &= ircdb.channels.getChannel(channel).defaultAllow + return not (default or \ + any(lambda x: ircdb.checkCapability(msg.prefix, x), + checkAtEnd)) + except RuntimeError, e: + s = ircdb.unAntiCapability(str(e)) + return s + + +class RichReplyMethods(object): + """This is a mixin so these replies need only be defined once. It operates + under several assumptions, including the fact that 'self' is an Irc object + of some sort and there is a self.msg that is an IrcMsg.""" + def __makeReply(self, prefix, s): + if s: + s = '%s %s' % (prefix, s) + else: + s = prefix + return ircutils.standardSubstitute(self, self.msg, s) + + def _getConfig(self, wrapper): + return conf.get(wrapper, self.msg.args[0]) + + def replySuccess(self, s='', **kwargs): + v = self._getConfig(conf.supybot.replies.success) + if v: + s = self.__makeReply(v, s) + return self.reply(s, **kwargs) + else: + self.noReply() + + def replyError(self, s='', **kwargs): + v = self._getConfig(conf.supybot.replies.error) + s = self.__makeReply(v, s) + return self.reply(s, **kwargs) + + def replies(self, L, prefixer=None, joiner=None, + onlyPrefixFirst=False, **kwargs): + if prefixer is None: + prefixer = '' + if joiner is None: + joiner = utils.commaAndify + if isinstance(prefixer, basestring): + prefixer = prefixer.__add__ + if isinstance(joiner, basestring): + joiner = joiner.join + if conf.supybot.reply.oneToOne(): + return self.reply(prefixer(joiner(L)), **kwargs) + else: + msg = None + first = True + for s in L: + if onlyPrefixFirst: + if first: + first = False + msg = self.reply(prefixer(s), **kwargs) + else: + msg = self.reply(s, **kwargs) + else: + msg = self.reply(prefixer(s), **kwargs) + return msg + + def noReply(self): + self.msg.tag('repliedTo') + + def _error(self, s, Raise=False, **kwargs): + if Raise: + raise Error, s + else: + return self.error(s, **kwargs) + + def errorNoCapability(self, capability, s='', **kwargs): + if 'Raise' not in kwargs: + kwargs['Raise'] = True + if isinstance(capability, basestring): # checkCommandCapability! + log.warning('Denying %s for lacking %s capability.', + self.msg.prefix, utils.quoted(capability)) + if not self._getConfig(conf.supybot.reply.error.noCapability): + v = self._getConfig(conf.supybot.replies.noCapability) + s = self.__makeReply(v % capability, s) + return self._error(s, **kwargs) + else: + log.debug('Not sending capability error, ' + 'supybot.reply.error.noCapability is False.') + else: + log.warning('Denying %s for some unspecified capability ' + '(or a default).', self.msg.prefix) + v = self._getConfig(conf.supybot.replies.genericNoCapability) + return self._error(self.__makeReply(v, s), **kwargs) + + def errorPossibleBug(self, s='', **kwargs): + v = self._getConfig(conf.supybot.replies.possibleBug) + if s: + s += ' (%s)' % v + else: + s = v + return self._error(s, **kwargs) + + def errorNotRegistered(self, s='', **kwargs): + v = self._getConfig(conf.supybot.replies.notRegistered) + return self._error(self.__makeReply(v, s), **kwargs) + + def errorNoUser(self, s='', name='that user', **kwargs): + if 'Raise' not in kwargs: + kwargs['Raise'] = True + v = self._getConfig(conf.supybot.replies.noUser) + try: + v = v % name + except TypeError: + log.warning('supybot.replies.noUser should have one "%s" in it.') + return self._error(self.__makeReply(v, s), **kwargs) + + def errorRequiresPrivacy(self, s='', **kwargs): + v = self._getConfig(conf.supybot.replies.requiresPrivacy) + return self._error(self.__makeReply(v, s), **kwargs) + + def errorInvalid(self, what, given=None, s='', repr=True, **kwargs): + if given is not None: + if repr: + given = _repr(given) + else: + given = '"%s"' % given + v = '%s is not a valid %s.' % (given, what) + else: + v = 'That\'s not a valid %s.' % what + if 'Raise' not in kwargs: + kwargs['Raise'] = True + return self._error(self.__makeReply(v, s), **kwargs) + +_repr = repr + +class IrcObjectProxy(RichReplyMethods): + "A proxy object to allow proper nested of commands (even threaded ones)." + def __init__(self, irc, msg, args, nested=0): + log.verbose('IrcObjectProxy.__init__: %s' % args) + assert isinstance(args, list), 'Args should be a list, not a string.' + self.irc = irc + self.msg = msg + self.nested = nested + if not self.nested and isinstance(irc, self.__class__): + # This is for plugins that indirectly spawn a Proxy, like Alias. + self.nested += irc.nested + maxNesting = conf.supybot.commands.nested.maximum() + if maxNesting and self.nested > maxNesting: + log.warning('%s attempted more than %s levels of nesting.', + self.msg.prefix, maxNesting) + return self.error('You\'ve attempted more nesting than is ' + 'currently allowed on this bot.') + # The deepcopy here is necessary for Scheduler; it re-runs already + # tokenized commands. + self.args = copy.deepcopy(args) + self.counter = 0 + self.commandMethod = None # Used in error. + self._resetReplyAttributes() + if not args: + self.finalEvaled = True + self._callInvalidCommands() + else: + self.finalEvaled = False + world.commandsProcessed += 1 + self.evalArgs() + + def __eq__(self, other): + return other == self.getRealIrc() + + def __hash__(self): + return hash(self.getRealIrc()) + + def _resetReplyAttributes(self): + self.to = None + self.action = None + self.notice = None + self.private = None + self.noLengthCheck = None + if ircutils.isChannel(self.msg.args[0]): + self.prefixName = conf.get(conf.supybot.reply.withNickPrefix, + self.msg.args[0]) + else: + self.prefixName = conf.supybot.reply.withNickPrefix() + + def evalArgs(self): + while self.counter < len(self.args): + if isinstance(self.args[self.counter], basestring): + self.counter += 1 + else: + self.__class__(self, self.msg, + self.args[self.counter], nested=self.nested+1) + return + self.finalEval() + + def _callTokenizedCommands(self): + log.verbose('Calling tokenizedCommands.') + for cb in self.irc.callbacks: + if hasattr(cb, 'tokenizedCommand'): + log.verbose('Trying to call %s.tokenizedCommand.', cb.name()) + self._callTokenizedCommand(cb) + if self.msg.repliedTo: + log.verbose('Done calling tokenizedCommands: %s.',cb.name()) + return + + def _callTokenizedCommand(self, cb): + try: + cb.tokenizedCommand(self, self.msg, self.args) + except Error, e: + return self.error(str(e)) + except Exception, e: + log.exception('Uncaught exception in %s.tokenizedCommand.' % + cb.name()) + + def _callInvalidCommands(self): + log.debug('Calling invalidCommands.') + for cb in self.irc.callbacks: + if hasattr(cb, 'invalidCommand'): + log.debug('Trying to call %s.invalidCommand.' % cb.name()) + self._callInvalidCommand(cb) + if self.msg.repliedTo: + log.debug('Done calling invalidCommands: %s.',cb.name()) + return + + def _callInvalidCommand(self, cb): + try: + cb.invalidCommand(self, self.msg, self.args) + except Error, e: + return self.error(str(e)) + except Exception, e: + log.exception('Uncaught exception in %s.invalidCommand.'% + cb.name()) + + def _callCommand(self, name, cb): + try: + self.commandMethod = cb.getCommand(name) + if not world.isMainThread(): + # If we're a threaded command, we may not reply quickly enough + # to prevent regexp stuff from running. So we do this to make + # sure it doesn't happen. Neither way (tagging or not tagging) + # is perfect, but this seems better than not tagging. + self.msg.tag('repliedTo') + try: + cb.callCommand(name, self, self.msg, self.args) + except Error, e: + self.error(str(e)) + except Exception, e: + cb.log.exception('Uncaught exception in %s.%s:', + cb.name(), name) + if conf.supybot.reply.error.detailed(): + return self.error(utils.exnToString(e)) + else: + return self.replyError() + finally: + self.commandMethod = None + + def findCallbackForCommand(self, command): + cbs = findCallbackForCommand(self, command) + if len(cbs) > 1: + command = canonicalName(command) + # Check for whether it's the name of a callback; their dispatchers + # need to get precedence. + for cb in cbs: + if canonicalName(cb.name()) == command: + return [cb] + try: + # Check if there's a configured defaultPlugin -- the user gets + # precedence in that case. + defaultPlugins = conf.supybot.commands.defaultPlugins + plugin = defaultPlugins.get(command)() + if plugin and plugin != '(Unused)': + cb = self.irc.getCallback(plugin) + if cb is None: + log.warning('%s is set as a default plugin ' + 'for %s, but it isn\'t loaded.', + plugin, command) + raise registry.NonExistentRegistryEntry + else: + return [cb] + except registry.NonExistentRegistryEntry, e: + # Check for whether it's a src/ plugin; they get precedence. + important = [] + importantPlugins = defaultPlugins.importantPlugins() + for cb in cbs: + if cb.name() in importantPlugins: + # We do this to handle multiple importants matching. + important.append(cb) + if len(important) == 1: + return important + return cbs + + def finalEval(self): + assert not self.finalEvaled, 'finalEval called twice.' + self.finalEvaled = True + command = self.args[0] + cbs = self.findCallbackForCommand(command) + if not cbs: + # Normal command not found, let's go for the specialties now. + # First, check for addressedRegexps -- they take precedence over + # tokenizedCommands. + for cb in self.irc.callbacks: + if isinstance(cb, PrivmsgCommandAndRegexp): + payload = addressed(self.irc.nick, self.msg) + for (r, name) in cb.addressedRes: + if r.search(payload): + log.debug('Skipping tokenizedCommands: %s.%s', + cb.name(), name) + return + # Now we call tokenizedCommands. + self._callTokenizedCommands() + # Return if we've replied. + if self.msg.repliedTo: + log.debug('Not calling invalidCommands, ' + 'tokenizedCommands replied.') + return + # Now we check for regexp commands, which override invalidCommand. + for cb in self.irc.callbacks: + if isinstance(cb, PrivmsgRegexp): + for (r, name) in cb.res: + if r.search(self.msg.args[1]): + log.debug('Skipping invalidCommand: %s.%s', + cb.name(), name) + return + elif isinstance(cb, PrivmsgCommandAndRegexp): + for (r, name) in cb.res: + if r.search(self.msg.args[1]): + log.debug('Skipping invalidCommand: %s.%s', + cb.name(), name) + return + # No matching regexp commands, now we do invalidCommands. + self._callInvalidCommands() + elif len(cbs) > 1: + names = sorted([cb.name() for cb in cbs]) + return self.error('The command %s is available in the %s plugins. ' + 'Please specify the plugin whose command you ' + 'wish to call by using its name as a command ' + 'before %s.' % + (command, utils.commaAndify(names), command)) + else: + cb = cbs[0] + del self.args[0] # Remove the command. + if world.isMainThread() and \ + (cb.threaded or conf.supybot.debug.threadAllCommands()): + t = CommandThread(target=self._callCommand, + args=(command, cb)) + t.start() + else: + self._callCommand(command, cb) + + def reply(self, s, noLengthCheck=False, prefixName=None, + action=None, private=None, notice=None, to=None, msg=None): + """reply(s) -> replies to msg with s + + Keyword arguments: + noLengthCheck=False: True if the length shouldn't be checked + (used for 'more' handling) + prefixName=True: False if the nick shouldn't be prefixed to the + reply. + action=False: True if the reply should be an action. + private=False: True if the reply should be in private. + notice=False: True if the reply should be noticed when the + bot is configured to do so. + to=: The nick or channel the reply should go to. + Defaults to msg.args[0] (or msg.nick if private) + """ + # These use and or or based on whether or not they default to True or + # False. Those that default to True use and; those that default to + # False use or. + assert not isinstance(s, ircmsgs.IrcMsg), \ + 'Old code alert: there is no longer a "msg" argument to reply.' + if msg is None: + msg = self.msg + if prefixName is not None: + self.prefixName = prefixName + if action is not None: + self.action = self.action or action + self.prefixName = False + if notice is not None: + self.notice = self.notice or notice + if private is not None: + self.private = self.private or private + if to is not None: + self.to = self.to or to + # action=True implies noLengthCheck=True and prefixName=False + self.noLengthCheck=noLengthCheck or self.noLengthCheck or self.action + target = self.private and self.to or self.msg.args[0] + if self.finalEvaled: + try: + if not isinstance(self.irc, irclib.Irc): + s = s[:conf.supybot.reply.maximumLength()] + return self.irc.reply(s, to=self.to, + notice=self.notice, + action=self.action, + private=self.private, + prefixName=self.prefixName, + noLengthCheck=self.noLengthCheck) + elif self.noLengthCheck: + # noLengthCheck only matters to IrcObjectProxy, so it's not + # used here. Just in case you were wondering. + m = reply(msg, s, to=self.to, + notice=self.notice, + action=self.action, + private=self.private, + prefixName=self.prefixName) + self.irc.queueMsg(m) + return m + else: + s = ircutils.safeArgument(s) + allowedLength = conf.get(conf.supybot.reply.mores.length, + target) + if not allowedLength: # 0 indicates this. + allowedLength = 450 - len(self.irc.prefix) + allowedLength -= len(msg.nick) + maximumMores = conf.get(conf.supybot.reply.mores.maximum, + target) + maximumLength = allowedLength * maximumMores + if len(s) > maximumLength: + log.warning('Truncating to %s bytes from %s bytes.', + maximumLength, len(s)) + s = s[:maximumLength] + if len(s) < allowedLength or \ + not conf.get(conf.supybot.reply.mores, target): + # In case we're truncating, we add 20 to allowedLength, + # because our allowedLength is shortened for the + # "(XX more messages)" trailer. + s = s[:allowedLength+20] + # There's no need for action=self.action here because + # action implies noLengthCheck, which has already been + # handled. Let's stick an assert in here just in case. + assert not self.action + m = reply(msg, s, to=self.to, + notice=self.notice, + private=self.private, + prefixName=self.prefixName) + self.irc.queueMsg(m) + return m + msgs = ircutils.wrap(s, allowedLength) + msgs.reverse() + instant = conf.get(conf.supybot.reply.mores.instant,target) + while instant > 1 and msgs: + instant -= 1 + response = msgs.pop() + m = reply(msg, response, to=self.to, + notice=self.notice, + private=self.private, + prefixName=self.prefixName) + self.irc.queueMsg(m) + # XXX We should somehow allow these to be returned, but + # until someone complains, we'll be fine :) We + # can't return from here, though, for obvious + # reasons. + # return m + if not msgs: + return + response = msgs.pop() + if msgs: + n = ircutils.bold('(%s)') + n %= utils.nItems('message', len(msgs), 'more') + response = '%s %s' % (response, n) + prefix = msg.prefix + if self.to and ircutils.isNick(self.to): + try: + state = self.getRealIrc().state + prefix = state.nickToHostmask(self.to) + except KeyError: + pass # We'll leave it as it is. + mask = prefix.split('!', 1)[1] + Privmsg._mores[mask] = msgs + public = ircutils.isChannel(msg.args[0]) + private = self.private or not public + Privmsg._mores[msg.nick] = (private, msgs) + m = reply(msg, response, to=self.to, + action=self.action, + notice=self.notice, + private=self.private, + prefixName=self.prefixName) + self.irc.queueMsg(m) + return m + finally: + self._resetReplyAttributes() + else: + self.args[self.counter] = s + self.evalArgs() + + def error(self, s='', Raise=False, **kwargs): + if Raise: + if s: + raise Error, s + else: + raise ArgumentError + if s: + if not isinstance(self.irc, irclib.Irc): + return self.irc.error(s, **kwargs) + else: + m = error(self.msg, s, **kwargs) + self.irc.queueMsg(m) + return m + else: + if self.commandMethod is not None: + # We can recurse here because it only gets called once. + return self.reply(formatArgumentError(self.commandMethod), + **kwargs) + else: + raise ArgumentError # We shouldn't get here, but just in case. + + def getRealIrc(self): + """Returns the real irclib.Irc object underlying this proxy chain.""" + if isinstance(self.irc, irclib.Irc): + return self.irc + else: + return self.irc.getRealIrc() + + def __getattr__(self, attr): + return getattr(self.irc, attr) + + +class CommandThread(world.SupyThread): + """Just does some extra logging and error-recovery for commands that need + to run in threads. + """ + def __init__(self, target=None, args=(), kwargs={}): + (self.name, self.cb) = args + self.__parent = super(CommandThread, self) + self.command = self.cb.getCommand(self.name) + threadName = 'Thread #%s (for %s.%s)' % (world.threadsSpawned, + self.cb.name(), self.name) + log.debug('Spawning thread %s' % threadName) + self.__parent.__init__(target=target, name=threadName, + args=args, kwargs=kwargs) + self.setDaemon(True) + self.originalThreaded = self.cb.threaded + self.cb.threaded = True + + def run(self): + try: + self.__parent.run() + finally: + self.cb.threaded = self.originalThreaded + + +class CanonicalString(registry.NormalizedString): + def normalize(self, s): + return canonicalName(s) + +class CanonicalNameSet(utils.NormalizingSet): + def normalize(self, s): + return canonicalName(s) + +class CanonicalNameDict(utils.InsensitivePreservingDict): + def key(self, s): + return canonicalName(s) + +class Disabled(registry.SpaceSeparatedListOf): + sorted = True + Value = CanonicalString + List = CanonicalNameSet + +conf.registerGlobalValue(conf.supybot.commands, 'disabled', + Disabled([], """Determines what commands are currently disabled. Such + commands will not appear in command lists, etc. They will appear not even + to exist.""")) + +class DisabledCommands(object): + def __init__(self): + self.d = CanonicalNameDict() + for name in conf.supybot.commands.disabled(): + if '.' in name: + (plugin, command) = name.split('.', 1) + if command in self.d: + if self.d[command] is not None: + self.d[command].add(plugin) + else: + self.d[command] = CanonicalNameSet([plugin]) + else: + self.d[name] = None + + def disabled(self, command, plugin=None): + if command in self.d: + if self.d[command] is None: + return True + elif plugin in self.d[command]: + return True + return False + + def add(self, command, plugin=None): + if plugin is None: + self.d[command] = None + else: + if command in self.d: + if self.d[command] is not None: + self.d[command].add(plugin) + else: + self.d[command] = CanonicalNameSet([plugin]) + + def remove(self, command, plugin=None): + if plugin is None: + del self.d[command] + else: + if self.d[command] is not None: + self.d[command].remove(plugin) + +class Privmsg(irclib.IrcCallback): + """Base class for all Privmsg handlers.""" + # For awhile, a comment stood here to say, "Eventually callCommand." But + # that's wrong, because we can't do generic error handling in this + # callCommand -- plugins need to be able to override callCommand and do + # error handling there (see the Http plugin for an example). + __firewalled__ = {'isCommand': None,} + # 'invalidCommand': None} # Gotta raise callbacks.Error. + public = True + alwaysCall = () + threaded = False + noIgnore = False + Proxy = IrcObjectProxy + commandArgs = ['self', 'irc', 'msg', 'args'] + # These must be class-scope, so all plugins use the same one. + _mores = ircutils.IrcDict() + _disabled = DisabledCommands() + def isDisabled(self, command): + return self._disabled.disabled(command, self.name()) + + def __init__(self): + self.__parent = super(Privmsg, self) + myName = self.name() + self.log = log.getPluginLogger(myName) + # We can't do this because of the specialness that Owner and Misc do. + # I guess plugin authors will have to get the capitalization right. + # self.callAfter = map(str.lower, self.callAfter) + # self.callBefore = map(str.lower, self.callBefore) + ### Setup the dispatcher command. + canonicalname = canonicalName(myName) + self._original = getattr(self, canonicalname, None) + docstring = """ [ ...] + + Command dispatcher for the %s plugin. Use 'list %s' to see the + commands provided by this plugin. Use 'config list plugins.%s' to see + the configuration values for this plugin. In most cases this dispatcher + command is unnecessary; in cases where more than one plugin defines a + given command, use this command to tell the bot which plugin's command + to use.""" % (myName, myName, myName) + def dispatcher(self, irc, msg, args): + def handleBadArgs(): + if self._original: + self._original(irc, msg, args) + else: + if args: + irc.error('%s is not a valid command in this plugin.' % + args[0]) + else: + irc.error() + if args: + name = canonicalName(args[0]) + if name == canonicalName(self.name()): + handleBadArgs() + elif self.isCommand(name): + cap = checkCommandCapability(msg, self, name) + if cap: + irc.errorNoCapability(cap) + return + del args[0] + method = getattr(self, name) + try: + realname = '%s.%s' % (canonicalname, name) + method(irc, msg, args) + except (getopt.GetoptError, ArgumentError): + irc.reply(formatArgumentError(method, name)) + else: + handleBadArgs() + else: + handleBadArgs() + dispatcher = utils.changeFunctionName(dispatcher, canonicalname) + if self._original: + dispatcher.__doc__ = self._original.__doc__ + dispatcher.isDispatcher = False + else: + dispatcher.__doc__ = docstring + dispatcher.isDispatcher = True + setattr(self.__class__, canonicalname, dispatcher) + + def __call__(self, irc, msg): + # This is for later dynamic scoping. + if msg.command == 'PRIVMSG': + if self.noIgnore or not ircdb.checkIgnored(msg.prefix,msg.args[0]): + self.__parent.__call__(irc, msg) + else: + self.__parent.__call__(irc, msg) + + def isCommand(self, name): + """Returns whether a given method name is a command in this plugin.""" + # This function is ugly, but I don't want users to call methods like + # doPrivmsg or __init__ or whatever, and this is good to stop them. + + # Don't canonicalize this name: consider outFilter(self, irc, msg). + # name = canonicalName(name) + if self.isDisabled(name): + return False + if hasattr(self, name): + method = getattr(self, name) + if inspect.ismethod(method): + code = method.im_func.func_code + return inspect.getargs(code)[0] == self.commandArgs + else: + return False + else: + return False + + def getCommand(self, name): + """Gets the given command from this plugin.""" + name = canonicalName(name) + assert self.isCommand(name), '%s is not a command.' % \ + utils.quoted(name) + return getattr(self, name) + + def callCommand(self, name, irc, msg, *L, **kwargs): + checkCapabilities = kwargs.pop('checkCapabilities', True) + if checkCapabilities: + cap = checkCommandCapability(msg, self, name) + if cap: + irc.errorNoCapability(cap) + return + method = self.getCommand(name) + assert L, 'Odd, nothing in L. This can\'t happen.' + self.log.info('%s.%s called by %s.', self.name(), name, msg.prefix) + self.log.debug('args: %s', L[0]) + start = time.time() + try: + method(irc, msg, *L) + except (getopt.GetoptError, ArgumentError): + irc.reply(formatArgumentError(method, name=name)) + except (SyntaxError, Error), e: + self.log.debug('Error return: %s', utils.exnToString(e)) + irc.error(str(e)) + elapsed = time.time() - start + log.stat('%s took %s seconds', name, elapsed) + + def getCommandHelp(self, name): + name = canonicalName(name) + assert self.isCommand(name), \ + '%s is not a command in %s.' % (name, self.name()) + command = self.getCommand(name) + if hasattr(command, 'isDispatcher') and \ + command.isDispatcher and self.__doc__: + return utils.normalizeWhitespace(self.__doc__) + elif hasattr(command, '__doc__'): + return getHelp(command) + else: + return 'The %s command has no help.' % utils.quoted(name) + + def registryValue(self, name, channel=None, value=True): + plugin = self.name() + group = conf.supybot.plugins.get(plugin) + names = registry.split(name) + for name in names: + group = group.get(name) + if channel is not None: + if ircutils.isChannel(channel): + group = group.get(channel) + else: + self.log.debug('registryValue got channel=%r', channel) + if value: + return group() + else: + return group + + def setRegistryValue(self, name, value, channel=None): + plugin = self.name() + group = conf.supybot.plugins.get(plugin) + names = registry.split(name) + for name in names: + group = group.get(name) + if channel is None: + group.setValue(value) + else: + group.get(channel).setValue(value) + + def userValue(self, name, prefixOrName, default=None): + try: + id = str(ircdb.users.getUserId(prefixOrName)) + except KeyError: + return None + plugin = self.name() + group = conf.users.plugins.get(plugin) + names = registry.split(name) + for name in names: + group = group.get(name) + return group.get(id)() + + def setUserValue(self, name, prefixOrName, value, + ignoreNoUser=True, setValue=True): + try: + id = str(ircdb.users.getUserId(prefixOrName)) + except KeyError: + if ignoreNoUser: + return + else: + raise + plugin = self.name() + group = conf.users.plugins.get(plugin) + names = registry.split(name) + for name in names: + group = group.get(name) + group = group.get(id) + if setValue: + group.setValue(value) + else: + group.set(value) + + +class SimpleProxy(RichReplyMethods): + """This class is a thin wrapper around an irclib.Irc object that gives it + the reply() and error() methods (as well as everything in RichReplyMethods, + based on those two).""" + def __init__(self, irc, msg): + self.irc = irc + self.msg = msg + + def error(self, s, msg=None, **kwargs): + if 'Raise' in kwargs and kwargs['Raise']: + if s: + raise Error, s + else: + raise ArgumentError + if msg is None: + msg = self.msg + m = error(msg, s, **kwargs) + self.irc.queueMsg(m) + return m + + def reply(self, s, msg=None, **kwargs): + if msg is None: + msg = self.msg + assert not isinstance(s, ircmsgs.IrcMsg), \ + 'Old code alert: there is no longer a "msg" argument to reply.' + m = reply(msg, s, **kwargs) + self.irc.queueMsg(m) + return m + + def __getattr__(self, attr): + return getattr(self.irc, attr) + +IrcObjectProxyRegexp = SimpleProxy + +class PrivmsgRegexp(Privmsg): + """A class to allow a person to create regular expression callbacks. + + Much more primitive, but more flexible than the 'normal' method of using + the Privmsg class and its lexer, PrivmsgRegexp allows you to write + callbacks that aren't addressed to the bot, for instance. There are, of + course, several other possibilities. Callbacks are registered with a + string (the regular expression) and a function to be called (with the Irc + object, the IrcMsg object, and the match object) when the regular + expression matches. Callbacks must have the signature (self, irc, msg, + match) to be counted as such. + + A class-level flags attribute is used to determine what regexp flags to + compile the regular expressions with. By default, it's re.I, which means + regular expressions are by default case-insensitive. + + If you have a standard command-type callback, though, Privmsg is a much + better class to use, at the very least for consistency's sake, but also + because it's much more easily coded and maintained. + """ + flags = re.I + Proxy = SimpleProxy + commandArgs = ['self', 'irc', 'msg', 'match'] + def __init__(self): + self.__parent = super(PrivmsgRegexp, self) + self.__parent.__init__() + self.res = [] + #for name, value in self.__class__.__dict__.iteritems(): + for name, value in self.__class__.__dict__.items(): + value = getattr(self, name) + if self.isCommand(name): + try: + r = re.compile(value.__doc__, self.flags) + self.res.append((r, name)) + except re.error, e: + self.log.warning('Invalid regexp: %s (%s)', + utils.quoted(value.__doc__), e) + utils.sortBy(operator.itemgetter(1), self.res) + + def callCommand(self, name, irc, msg, *L, **kwargs): + try: + self.__parent.callCommand(name, irc, msg, *L, **kwargs) + except Exception, e: + # We catch exceptions here because IrcObjectProxy isn't doing our + # dirty work for us anymore. + self.log.exception('Uncaught exception in %s.%s:', + self.name(), name) + if conf.supybot.reply.error.detailed(): + irc.error(utils.exnToString(e)) + else: + irc.replyError() + + def doPrivmsg(self, irc, msg): + if msg.isError: + self.log.info('%s not running due to msg.isError.', self.name()) + return + for (r, name) in self.res: + spans = sets.Set() + for m in r.finditer(msg.args[1]): + # There's a bug in finditer: http://www.python.org/sf/817234 + if m.span() in spans: + break + else: + spans.add(m.span()) + irc = self.Proxy(irc, msg) + self.callCommand(name, irc, msg, m) + + +class PrivmsgCommandAndRegexp(Privmsg): + """Same as Privmsg, except allows the user to also include regexp-based + callbacks. All regexp-based callbacks must be specified in a sets.Set + (or list) attribute "regexps". + """ + flags = re.I + regexps = () + addressedRegexps = () + Proxy = SimpleProxy + def __init__(self): + self.__parent = super(PrivmsgCommandAndRegexp, self) + self.__parent.__init__() + self.res = [] + self.addressedRes = [] + for name in self.regexps: + method = getattr(self, name) + r = re.compile(method.__doc__, self.flags) + self.res.append((r, name)) + for name in self.addressedRegexps: + method = getattr(self, name) + r = re.compile(method.__doc__, self.flags) + self.addressedRes.append((r, name)) + + def isCommand(self, name): + return self.__parent.isCommand(name) or \ + name in self.regexps or \ + name in self.addressedRegexps + + def getCommand(self, name): + try: + return getattr(self, name) # Regexp stuff. + except AttributeError: + return self.__parent.getCommand(name) + + def callCommand(self, name, irc, msg, *L, **kwargs): + try: + self.__parent.callCommand(name, irc, msg, *L, **kwargs) + except Exception, e: + # As annoying as it is, Python doesn't allow *L in addition to + # well-defined keyword arguments. So we have to do this trick. + catchErrors = kwargs.pop('catchErrors', False) + if catchErrors: + self.log.exception('Uncaught exception in callCommand:') + if conf.supybot.reply.error.detailed(): + irc.error(utils.exnToString(e)) + else: + irc.replyError() + else: + raise + + def doPrivmsg(self, irc, msg): + if msg.isError: + self.log.debug('%s not running due to msg.isError.', self.name()) + return + s = addressed(irc.nick, msg) + if s: + for (r, name) in self.addressedRes: + if msg.repliedTo and name not in self.alwaysCall: + continue + for m in r.finditer(s): + proxy = self.Proxy(irc, msg) + self.callCommand(name, proxy, msg, m, catchErrors=True) + for (r, name) in self.res: + for m in r.finditer(msg.args[1]): + proxy = self.Proxy(irc, msg) + self.callCommand(name, proxy, msg, m, catchErrors=True) + + +# vim:set shiftwidth=4 tabstop=8 expandtab textwidth=78: diff --git a/src/cdb.py b/src/cdb.py new file mode 100644 index 000000000..8a82a2995 --- /dev/null +++ b/src/cdb.py @@ -0,0 +1,475 @@ +### +# Copyright (c) 2002-2004, Jeremiah Fincher +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND 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 COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +### + +""" +Database module, similar to dbhash. Uses a format similar to (if not entirely +the same as) DJB's CDB . +""" + + + +import supybot.fix as fix + +import os +import sys +import sets +import struct +import os.path +import cPickle as pickle + +import supybot.utils as utils + +def hash(s): + """DJB's hash function for CDB.""" + h = 5381 + for c in s: + h = ((h + (h << 5)) ^ ord(c)) & 0xFFFFFFFFL + return h + +def unpack2Ints(s): + """Returns two ints unpacked from the binary string s.""" + return struct.unpack('%s\n' % (len(key), len(value), key, value)) + +def open(filename, mode='r', **kwargs): + """Opens a database; used for compatibility with other database modules.""" + if mode == 'r': + return Reader(filename, **kwargs) + elif mode == 'w': + return ReaderWriter(filename, **kwargs) + elif mode == 'c': + if os.path.exists(filename): + return ReaderWriter(filename, **kwargs) + else: + maker = Maker(filename) + maker.finish() + return ReaderWriter(filename, **kwargs) + elif mode == 'n': + maker = Maker(filename) + maker.finish() + return ReaderWriter(filename, **kwargs) + else: + raise ValueError, 'Invalid flag: %s' % mode + +def shelf(filename, *args, **kwargs): + """Opens a new shelf database object.""" + if os.path.exists(filename): + return Shelf(filename, *args, **kwargs) + else: + maker = Maker(filename) + maker.finish() + return Shelf(filename, *args, **kwargs) + +def _readKeyValue(fd): + klen = 0 + dlen = 0 + s = initchar = fd.read(1) + if s == '': + return (None, None, None) + s = fd.read(1) + while s != ',': + klen = 10 * klen + int(s) + s = fd.read(1) + s = fd.read(1) + while s != ':': + dlen = 10 * dlen + int(s) + s = fd.read(1) + key = fd.read(klen) + assert fd.read(2) == '->' + value = fd.read(dlen) + assert fd.read(1) == '\n' + return (initchar, key, value) + +def make(dbFilename, readFilename=None): + """Makes a database from the filename, otherwise uses stdin.""" + if readFilename is None: + readfd = sys.stdin + else: + readfd = file(readFilename, 'r') + maker = Maker(dbFilename) + while 1: + (initchar, key, value) = _readKeyValue(readfd) + if initchar is None: + break + assert initchar == '+' + maker.add(key, value) + readfd.close() + maker.finish() + + +class Maker(object): + """Class for making CDB databases.""" + def __init__(self, filename): + self.fd = utils.transactionalFile(filename) + self.filename = filename + self.fd.seek(2048) + self.hashPointers = [(0, 0)] * 256 + #self.hashes = [[]] * 256 # Can't use this, [] stays the same... + self.hashes = [] + for _ in xrange(256): + self.hashes.append([]) + + def add(self, key, data): + """Adds a key->value pair to the database.""" + h = hash(key) + hashPointer = h % 256 + startPosition = self.fd.tell() + self.fd.write(pack2Ints(len(key), len(data))) + self.fd.write(key) + self.fd.write(data) + self.hashes[hashPointer].append((h, startPosition)) + + def finish(self): + """Finishes the current Maker object. + + Writes the remainder of the database to disk. + """ + for i in xrange(256): + hash = self.hashes[i] + self.hashPointers[i] = (self.fd.tell(), self._serializeHash(hash)) + self._serializeHashPointers() + self.fd.flush() + self.fd.close() + + def _serializeHash(self, hash): + hashLen = len(hash) * 2 + a = [(0, 0)] * hashLen + for (h, pos) in hash: + i = (h / 256) % hashLen + while a[i] != (0, 0): + i = (i + 1) % hashLen + a[i] = (h, pos) + for (h, pos) in a: + self.fd.write(pack2Ints(h, pos)) + return hashLen + + def _serializeHashPointers(self): + self.fd.seek(0) + for (hashPos, hashLen) in self.hashPointers: + self.fd.write(pack2Ints(hashPos, hashLen)) + + +class Reader(utils.IterableMap): + """Class for reading from a CDB database.""" + def __init__(self, filename): + self.filename = filename + self.fd = file(filename, 'r') + self.loop = 0 + self.khash = 0 + self.kpos = 0 + self.hpos = 0 + self.hslots = 0 + self.dpos = 0 + self.dlen = 0 + + def close(self): + self.fd.close() + + def _read(self, len, pos): + self.fd.seek(pos) + return self.fd.read(len) + + def _match(self, key, pos): + return self._read(len(key), pos) == key + + def iteritems(self): + # uses loop/hslots in a strange, non-re-entrant manner. + (self.loop,) = struct.unpack(' self.maxmods: + self.flush() + self.mods = 0 + elif isinstance(self.maxmods, float): + assert 0 <= self.maxmods + if self.mods / max(len(self.cdb), 100) > self.maxmods: + self.flush() + self.mods = 0 + + def __getitem__(self, key): + if key in self.removals: + raise KeyError, key + else: + try: + return self.adds[key] + except KeyError: + return self.cdb[key] # If this raises KeyError, we lack key. + + def __delitem__(self, key): + if key in self.removals: + raise KeyError, key + else: + if key in self.adds and key in self.cdb: + self._journalRemoveKey(key) + del self.adds[key] + self.removals.add(key) + elif key in self.adds: + self._journalRemoveKey(key) + del self.adds[key] + elif key in self.cdb: + self._journalRemoveKey(key) + else: + raise KeyError, key + self.mods += 1 + self._flushIfOverLimit() + + def __setitem__(self, key, value): + if key in self.removals: + self.removals.remove(key) + self._journalAddKey(key, value) + self.adds[key] = value + self.mods += 1 + self._flushIfOverLimit() + + def __contains__(self, key): + if key in self.removals: + return False + else: + return key in self.adds or key in self.cdb + + has_key = __contains__ + + def iteritems(self): + already = sets.Set() + for (key, value) in self.cdb.iteritems(): + if key in self.removals or key in already: + continue + elif key in self.adds: + already.add(key) + yield (key, self.adds[key]) + else: + yield (key, value) + for (key, value) in self.adds.iteritems(): + if key not in already: + yield (key, value) + + def setdefault(self, key, value): + try: + return self[key] + except KeyError: + self[key] = value + return value + + def get(self, key, default=None): + try: + return self[key] + except KeyError: + return default + + +class Shelf(ReaderWriter): + """Uses pickle to mimic the shelf module.""" + def __getitem__(self, key): + return pickle.loads(ReaderWriter.__getitem__(self, key)) + + def __setitem__(self, key, value): + ReaderWriter.__setitem__(self, key, pickle.dumps(value, True)) + + def iteritems(self): + for (key, value) in ReaderWriter.iteritems(self): + yield (key, pickle.loads(value)) + + +if __name__ == '__main__': + if sys.argv[0] == 'cdbdump': + if len(sys.argv) == 2: + fd = file(sys.argv[1], 'r') + else: + fd = sys.stdin + db = Reader(fd) + dump(db) + elif sys.argv[0] == 'cdbmake': + if len(sys.argv) == 2: + make(sys.argv[1]) + else: + make(sys.argv[1], sys.argv[2]) +# vim:set shiftwidth=4 tabstop=8 expandtab textwidth=78: diff --git a/src/commands.py b/src/commands.py new file mode 100644 index 000000000..fe083e419 --- /dev/null +++ b/src/commands.py @@ -0,0 +1,893 @@ +### +# Copyright (c) 2002-2004, Jeremiah Fincher +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND 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 COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +### + +""" +Includes wrappers for commands. +""" + + + +import supybot.fix as fix + +import time +import types +import getopt +import threading + +import supybot.log as log +import supybot.conf as conf +import supybot.utils as utils +import supybot.world as world +import supybot.ircdb as ircdb +import supybot.ircmsgs as ircmsgs +import supybot.ircutils as ircutils +import supybot.webutils as webutils +import supybot.callbacks as callbacks +import supybot.structures as structures + + +### +# Non-arg wrappers -- these just change the behavior of a command without +# changing the arguments given to it. +### + +# Thread has to be a non-arg wrapper because by the time we're parsing and +# validating arguments, we're inside the function we'd want to thread. +def thread(f): + """Makes sure a command spawns a thread when called.""" + def newf(self, irc, msg, args, *L, **kwargs): + if world.isMainThread(): + t = callbacks.CommandThread(target=irc._callCommand, + args=(f.func_name, self), + kwargs=kwargs) + t.start() + else: + f(self, irc, msg, args, *L, **kwargs) + return utils.changeFunctionName(newf, f.func_name, f.__doc__) + +class UrlSnarfThread(world.SupyThread): + def __init__(self, *args, **kwargs): + assert 'url' in kwargs + kwargs['name'] = 'Thread #%s (for snarfing %s)' % \ + (world.threadsSpawned, kwargs.pop('url')) + super(UrlSnarfThread, self).__init__(*args, **kwargs) + self.setDaemon(True) + + def run(self): + try: + super(UrlSnarfThread, self).run() + except webutils.WebError, e: + log.debug('Exception in urlSnarfer: %s' % utils.exnToString(e)) + +class SnarfQueue(ircutils.FloodQueue): + timeout = conf.supybot.snarfThrottle + def key(self, channel): + return channel + +_snarfed = SnarfQueue() + +class SnarfIrc(object): + def __init__(self, irc, channel, url): + self.irc = irc + self.url = url + self.channel = channel + + def __getattr__(self, attr): + return getattr(self.irc, attr) + + def reply(self, *args, **kwargs): + _snarfed.enqueue(self.channel, self.url) + return self.irc.reply(*args, **kwargs) + +# This lock is used to serialize the calls to snarfers, so +# earlier snarfers are guaranteed to beat out later snarfers. +_snarfLock = threading.Lock() +def urlSnarfer(f): + """Protects the snarfer from loops (with other bots) and whatnot.""" + def newf(self, irc, msg, match, *L, **kwargs): + url = match.group(0) + channel = msg.args[0] + if not irc.isChannel(channel): + return + if ircdb.channels.getChannel(channel).lobotomized: + self.log.info('Not snarfing in %s: lobotomized.', channel) + return + if _snarfed.has(channel, url): + self.log.info('Throttling snarf of %s in %s.', url, channel) + return + irc = SnarfIrc(irc, channel, url) + def doSnarf(): + _snarfLock.acquire() + try: + if msg.repliedTo: + self.log.debug('Not snarfing, msg is already repliedTo.') + return + f(self, irc, msg, match, *L, **kwargs) + finally: + _snarfLock.release() + if threading.currentThread() is not world.mainThread: + doSnarf() + else: + L = list(L) + t = UrlSnarfThread(target=doSnarf, url=url) + t.start() + newf = utils.changeFunctionName(newf, f.func_name, f.__doc__) + return newf + + +### +# Converters, which take irc, msg, args, and a state object, and build up the +# validated and converted args for the method in state.args. +### + +# This is just so we can centralize this, since it may change. +def _int(s): + base = 10 + if s.startswith('0x'): + base = 16 + s = s[2:] + elif s.startswith('0b'): + base = 2 + s = s[2:] + elif s.startswith('0') and len(s) > 1: + base = 8 + s = s[1:] + try: + return int(s, base) + except ValueError: + if base == 10: + return int(float(s)) + else: + raise + +def getInt(irc, msg, args, state, type='integer', p=None): + try: + i = _int(args[0]) + if p is not None: + if not p(i): + irc.errorInvalid(type, args[0]) + state.args.append(i) + del args[0] + except ValueError: + irc.errorInvalid(type, args[0]) + +def getNonInt(irc, msg, args, state, type='non-integer value'): + try: + i = _int(args[0]) + irc.errorInvalid(type, args[0]) + except ValueError: + state.args.append(args.pop(0)) + +def getLong(irc, msg, args, state, type='long'): + getInt(irc, msg, args, state, type) + state.args[-1] = long(state.args[-1]) + +def getFloat(irc, msg, args, state, type='floating point number'): + try: + state.args.append(float(args[0])) + del args[0] + except ValueError: + irc.errorInvalid(type, args[0]) + +def getPositiveInt(irc, msg, args, state, *L): + getInt(irc, msg, args, state, + p=lambda i: i>0, type='positive integer', *L) + +def getNonNegativeInt(irc, msg, args, state, *L): + getInt(irc, msg, args, state, + p=lambda i: i>=0, type='non-negative integer', *L) + +def getIndex(irc, msg, args, state): + getInt(irc, msg, args, state, type='index') + if state.args[-1] > 0: + state.args[-1] -= 1 + +def getId(irc, msg, args, state, kind=None): + type = 'id' + if kind is not None and not kind.endswith('id'): + type = kind + ' id' + original = args[0] + try: + args[0] = args[0].lstrip('#') + getInt(irc, msg, args, state, type=type) + except Exception, e: + args[0] = original + raise + +def getExpiry(irc, msg, args, state): + now = int(time.time()) + try: + expires = _int(args[0]) + if expires: + expires += now + state.args.append(expires) + del args[0] + except ValueError: + irc.errorInvalid('number of seconds', args[0]) + +def getBoolean(irc, msg, args, state): + try: + state.args.append(utils.toBool(args[0])) + del args[0] + except ValueError: + irc.errorInvalid('boolean', args[0]) + +def getNetworkIrc(irc, msg, args, state, errorIfNoMatch=False): + if args: + for otherIrc in world.ircs: + if otherIrc.network.lower() == args[0].lower(): + state.args.append(otherIrc) + del args[0] + return + if errorIfNoMatch: + raise callbacks.ArgumentError + else: + state.args.append(irc) + +def getHaveOp(irc, msg, args, state, action='do that'): + if state.channel not in irc.state.channels: + irc.error('I\'m not even in %s.' % state.channel, Raise=True) + if not irc.state.channels[state.channel].isOp(irc.nick): + irc.error('I need to be opped to %s.' % action, Raise=True) + +def validChannel(irc, msg, args, state): + if irc.isChannel(args[0]): + state.args.append(args.pop(0)) + else: + irc.errorInvalid('channel', args[0]) + +def getHostmask(irc, msg, args, state): + if ircutils.isUserHostmask(args[0]): + state.args.append(args.pop(0)) + else: + try: + hostmask = irc.state.nickToHostmask(args[0]) + state.args.append(hostmask) + del args[0] + except KeyError: + irc.errorInvalid('nick or hostmask', args[0]) + +def getBanmask(irc, msg, args, state): + getHostmask(irc, msg, args, state) + # XXX Channel-specific stuff. + state.args[-1] = ircutils.banmask(state.args[-1]) + +def getUser(irc, msg, args, state): + try: + state.args.append(ircdb.users.getUser(msg.prefix)) + except KeyError: + irc.errorNotRegistered(Raise=True) + +def getOtherUser(irc, msg, args, state): + if ircutils.isUserHostmask(args[0]): + irc.errorNoUser(args[0]) + try: + state.args.append(ircdb.users.getUser(args[0])) + del args[0] + except KeyError: + try: + getHostmask(irc, msg, [args[0]], state) + hostmask = state.args.pop() + state.args.append(ircdb.users.getUser(hostmask)) + del args[0] + except (KeyError, callbacks.Error): + irc.errorNoUser(name=args[0]) + +def _getRe(f): + def get(irc, msg, args, state, convert=True): + original = args[:] + s = args.pop(0) + def isRe(s): + try: + _ = f(s) + return True + except ValueError: + return False + try: + while len(s) < 512 and not isRe(s): + s += ' ' + args.pop(0) + if len(s) < 512: + if convert: + state.args.append(f(s)) + else: + state.args.append(s) + else: + irc.errorInvalid('regular expression', s) + except IndexError: + args[:] = original + irc.errorInvalid('regular expression', s) + return get + +getMatcher = _getRe(utils.perlReToPythonRe) +getReplacer = _getRe(utils.perlReToReplacer) + +def getNick(irc, msg, args, state): + if ircutils.isNick(args[0]): + if 'nicklen' in irc.state.supported: + if len(args[0]) > irc.state.supported['nicklen']: + irc.errorInvalid('nick', args[0], + 'That nick is too long for this server.') + state.args.append(args.pop(0)) + else: + irc.errorInvalid('nick', args[0]) + +def getSeenNick(irc, msg, args, state, errmsg=None): + try: + _ = irc.state.nickToHostmask(args[0]) + state.args.append(args.pop(0)) + except KeyError: + if errmsg is None: + errmsg = 'I haven\'t seen %s.' % args[0] + irc.error(errmsg, Raise=True) + +def getChannel(irc, msg, args, state): + if args and irc.isChannel(args[0]): + channel = args.pop(0) + elif irc.isChannel(msg.args[0]): + channel = msg.args[0] + else: + state.log.debug('Raising ArgumentError because there is no channel.') + raise callbacks.ArgumentError + state.channel = channel + state.args.append(channel) + +def getChannelDb(irc, msg, args, state, **kwargs): + channelSpecific = conf.supybot.databases.plugins.channelSpecific + try: + getChannel(irc, msg, args, state, **kwargs) + channel = channelSpecific.getChannelLink(state.channel) + state.channel = channel + state.args[-1] = channel + except (callbacks.ArgumentError, IndexError): + if channelSpecific(): + raise + channel = channelSpecific.link() + if not conf.get(channelSpecific.link.allow, channel): + log.warning('channelSpecific.link is globally set to %s, but ' + '%s disallows linking to its db.' % (channel, channel)) + raise + else: + channel = channelSpecific.getChannelLink(channel) + state.channel = channel + state.args.append(channel) + +def inChannel(irc, msg, args, state): + if not state.channel: + getChannel(irc, msg, args, state) + if state.channel not in irc.state.channels: + irc.error('I\'m not in %s.' % state.channel, Raise=True) + +def onlyInChannel(irc, msg, args, state): + if not (irc.isChannel(msg.args[0]) and msg.args[0] in irc.state.channels): + irc.error('This command may only be given in a channel that I am in.', + Raise=True) + else: + state.channel = msg.args[0] + state.args.append(state.channel) + +def callerInGivenChannel(irc, msg, args, state): + channel = args[0] + if irc.isChannel(channel): + if channel in irc.state.channels: + if msg.nick in irc.state.channels[channel].users: + state.args.append(args.pop(0)) + else: + irc.error('You must be in %s.' % channel, Raise=True) + else: + irc.error('I\'m not in %s.' % channel, Raise=True) + else: + irc.errorInvalid('channel', args[0]) + +def nickInChannel(irc, msg, args, state): + inChannel(irc, msg, args, state) + if args[0] not in irc.state.channels[state.channel].users: + irc.error('%s is not in %s.' % (args[0], state.channel), Raise=True) + state.args.append(args.pop(0)) + +def getChannelOrNone(irc, msg, args, state): + try: + getChannel(irc, msg, args, state) + except callbacks.ArgumentError: + state.args.append(None) + +def checkChannelCapability(irc, msg, args, state, cap): + if not state.channel: + getChannel(irc, msg, args, state) + cap = ircdb.canonicalCapability(cap) + cap = ircdb.makeChannelCapability(state.channel, cap) + if not ircdb.checkCapability(msg.prefix, cap): + irc.errorNoCapability(cap, Raise=True) + +def getLowered(irc, msg, args, state): + state.args.append(ircutils.toLower(args.pop(0))) + +def getSomething(irc, msg, args, state, errorMsg=None, p=None): + if p is None: + p = lambda _: True + if not args[0] or not p(args[0]): + if errorMsg is None: + errorMsg = 'You must not give the empty string as an argument.' + irc.error(errorMsg, Raise=True) + else: + state.args.append(args.pop(0)) + +def getSomethingNoSpaces(irc, msg, args, state, *L): + def p(s): + return len(s.split(None, 1)) == 1 + getSomething(irc, msg, args, state, p=p, *L) + +def private(irc, msg, args, state): + if irc.isChannel(msg.args[0]): + irc.errorRequiresPrivacy(Raise=True) + +def public(irc, msg, args, state, errmsg=None): + if not irc.isChannel(msg.args[0]): + if errmsg is None: + errmsg = 'This message must be sent in a channel.' + irc.error(errmsg, Raise=True) + +def checkCapability(irc, msg, args, state, cap): + cap = ircdb.canonicalCapability(cap) + if not ircdb.checkCapability(msg.prefix, cap): + irc.errorNoCapability(cap, Raise=True) + +def owner(irc, msg, args, state): + checkCapability(irc, msg, args, state, 'owner') + +def admin(irc, msg, args, state): + checkCapability(irc, msg, args, state, 'admin') + +def anything(irc, msg, args, state): + state.args.append(args.pop(0)) + +def getGlob(irc, msg, args, state): + glob = args.pop(0) + if '*' not in glob and '?' not in glob: + glob = '*%s*' % glob + state.args.append(glob) + +def getUrl(irc, msg, args, state): + if webutils.urlRe.match(args[0]): + state.args.append(args.pop(0)) + else: + irc.errorInvalid('url', args[0]) + +def getHttpUrl(irc, msg, args, state): + if webutils.urlRe.match(args[0]) and args[0].startswith('http://'): + state.args.append(args.pop(0)) + else: + irc.errorInvalid('http url', args[0]) + +def getNow(irc, msg, args, state): + state.args.append(int(time.time())) + +def getCommandName(irc, msg, args, state): + if ' ' in args[0]: + irc.errorInvalid('command name', args[0]) + else: + state.args.append(callbacks.canonicalName(args.pop(0))) + +def getIp(irc, msg, args, state): + if utils.isIP(args[0]): + state.args.append(args.pop(0)) + else: + irc.errorInvalid('ip', args[0]) + +def getLetter(irc, msg, args, state): + if len(args[0]) == 1: + state.args.append(args.pop(0)) + else: + irc.errorInvalid('letter', args[0]) + +def getMatch(irc, msg, args, state, regexp, errmsg): + m = regexp.search(args[0]) + if m is not None: + state.args.append(m) + del args[0] + else: + irc.error(errmsg, Raise=True) + +def getLiteral(irc, msg, args, state, literals, errmsg=None): + # ??? Should we allow abbreviations? + if isinstance(literals, basestring): + literals = (literals,) + abbrevs = utils.abbrev(literals) + if args[0] in abbrevs: + state.args.append(abbrevs[args.pop(0)]) + elif errmsg is not None: + irc.error(errmsg, Raise=True) + else: + raise callbacks.ArgumentError + +def getTo(irc, msg, args, state): + if args[0].lower() == 'to': + args.pop(0) + +def getPlugin(irc, msg, args, state, require=True): + cb = irc.getCallback(args[0]) + if cb is not None: + state.args.append(cb) + del args[0] + elif require: + irc.errorInvalid('plugin', args[0]) + else: + state.args.append(None) + +def getIrcColor(irc, msg, args, state): + if args[0] in ircutils.mircColors: + state.args.append(ircutils.mircColors[args.pop(0)]) + else: + irc.errorInvalid('irc color') + +def getText(irc, msg, args, state): + if args: + state.args.append(' '.join(args)) + args[:] = [] + else: + raise IndexError + +wrappers = ircutils.IrcDict({ + 'id': getId, + 'ip': getIp, + 'int': getInt, + 'index': getIndex, + 'color': getIrcColor, + 'now': getNow, + 'url': getUrl, + 'httpUrl': getHttpUrl, + 'long': getLong, + 'float': getFloat, + 'nonInt': getNonInt, + 'positiveInt': getPositiveInt, + 'nonNegativeInt': getNonNegativeInt, + 'letter': getLetter, + 'haveOp': getHaveOp, + 'expiry': getExpiry, + 'literal': getLiteral, + 'to': getTo, + 'nick': getNick, + 'seenNick': getSeenNick, + 'channel': getChannel, + 'inChannel': inChannel, + 'onlyInChannel': onlyInChannel, + 'nickInChannel': nickInChannel, + 'networkIrc': getNetworkIrc, + 'callerInGivenChannel': callerInGivenChannel, + 'plugin': getPlugin, + 'boolean': getBoolean, + 'lowered': getLowered, + 'anything': anything, + 'something': getSomething, + 'filename': getSomething, # XXX Check for validity. + 'commandName': getCommandName, + 'text': getText, + 'glob': getGlob, + 'somethingWithoutSpaces': getSomethingNoSpaces, + 'capability': getSomethingNoSpaces, + 'channelDb': getChannelDb, + 'hostmask': getHostmask, + 'banmask': getBanmask, + 'user': getUser, + 'matches': getMatch, + 'public': public, + 'private': private, + 'otherUser': getOtherUser, + 'regexpMatcher': getMatcher, + 'validChannel': validChannel, + 'regexpReplacer': getReplacer, + 'owner': owner, + 'admin': admin, + 'checkCapability': checkCapability, + 'checkChannelCapability': checkChannelCapability, +}) + +def addConverter(name, wrapper): + wrappers[name] = wrapper + +class UnknownConverter(KeyError): + pass + +def getConverter(name): + try: + return wrappers[name] + except KeyError, e: + raise UnknownConverter, str(e) + +def callConverter(name, irc, msg, args, state, *L): + getConverter(name)(irc, msg, args, state, *L) + +### +# Contexts. These determine what the nature of conversions is; whether they're +# defaulted, or many of them are allowed, etc. Contexts should be reusable; +# i.e., they should not maintain state between calls. +### +def contextify(spec): + if not isinstance(spec, context): + spec = context(spec) + return spec + +def setDefault(state, default): + if callable(default): + state.args.append(default()) + else: + state.args.append(default) + +class context(object): + def __init__(self, spec): + self.args = () + self.spec = spec # for repr + if isinstance(spec, tuple): + assert spec, 'tuple spec must not be empty.' + self.args = spec[1:] + self.converter = getConverter(spec[0]) + elif spec is None: + self.converter = getConverter('anything') + elif isinstance(spec, basestring): + self.args = () + self.converter = getConverter(spec) + else: + assert isinstance(spec, context) + self.converter = spec + + def __call__(self, irc, msg, args, state): + log.debug('args before %r: %r', self, args) + self.converter(irc, msg, args, state, *self.args) + log.debug('args after %r: %r', self, args) + + def __repr__(self): + return '<%s for %s>' % (self.__class__.__name__, self.spec) + +class rest(context): + def __call__(self, irc, msg, args, state): + if args: + original = args[:] + args[:] = [' '.join(args)] + try: + super(rest, self).__call__(irc, msg, args, state) + except Exception, e: + args[:] = original + else: + raise callbacks.ArgumentError + +# additional means: Look for this (and make sure it's of this type). If +# there are no arguments for us to check, then use our default. +class additional(context): + def __init__(self, spec, default=None): + self.__parent = super(additional, self) + self.__parent.__init__(spec) + self.default = default + + def __call__(self, irc, msg, args, state): + try: + self.__parent.__call__(irc, msg, args, state) + except IndexError: + log.debug('Got IndexError, returning default.') + setDefault(state, self.default) + +# optional means: Look for this, but if it's not the type I'm expecting or +# there are no arguments for us to check, then use the default value. +class optional(additional): + def __call__(self, irc, msg, args, state): + try: + super(optional, self).__call__(irc, msg, args, state) + except (callbacks.ArgumentError, callbacks.Error), e: + log.debug('Got %s, returning default.', utils.exnToString(e)) + setDefault(state, self.default) + +class any(context): + def __init__(self, spec, continueOnError=False): + self.__parent = super(any, self) + self.__parent.__init__(spec) + self.continueOnError = continueOnError + + def __call__(self, irc, msg, args, state): + st = state.essence() + try: + while args: + self.__parent.__call__(irc, msg, args, st) + except IndexError: + pass + except (callbacks.ArgumentError, callbacks.Error), e: + if not self.continueOnError: + raise + else: + log.debug('Got %s, returning default.', utils.exnToString(e)) + pass + state.args.append(st.args) + +class many(any): + def __call__(self, irc, msg, args, state): + super(many, self).__call__(irc, msg, args, state) + if not state.args[-1]: + state.args.pop() + raise callbacks.ArgumentError + +class first(context): + def __init__(self, *specs, **kw): + if 'default' in kw: + self.default = kw.pop('default') + assert not kw, 'Bad kwargs for first.__init__' + self.specs = map(contextify, specs) + + def __call__(self, irc, msg, args, state): + for spec in self.specs: + try: + spec(irc, msg, args, state) + return + except Exception, e: + continue + if hasattr(self, 'default'): + state.args.append(self.default) + else: + raise e + +class reverse(context): + def __call__(self, irc, msg, args, state): + args[:] = args[::-1] + super(reverse, self).__call__(irc, msg, args, state) + args[:] = args[::-1] + +class commalist(context): + def __call__(self, irc, msg, args, state): + original = args[:] + st = state.essence() + trailingComma = True + try: + while trailingComma: + arg = args.pop(0) + if not arg.endswith(','): + trailingComma = False + for part in arg.split(','): + if part: # trailing commas + super(commalist, self).__call__(irc, msg, [part], st) + state.args.append(st.args) + except Exception, e: + args[:] = original + raise + +class getopts(context): + """The empty string indicates that no argument is taken; None indicates + that there is no converter for the argument.""" + def __init__(self, getopts): + self.spec = getopts # for repr + self.getopts = {} + self.getoptL = [] + for (name, spec) in getopts.iteritems(): + if spec == '': + self.getoptL.append(name) + self.getopts[name] = None + else: + self.getoptL.append(name + '=') + self.getopts[name] = contextify(spec) + log.debug('getopts: %r', self.getopts) + log.debug('getoptL: %r', self.getoptL) + + def __call__(self, irc, msg, args, state): + log.debug('args before %r: %r', self, args) + (optlist, rest) = getopt.getopt(args, '', self.getoptL) + getopts = [] + for (opt, arg) in optlist: + opt = opt[2:] # Strip -- + log.debug('opt: %r, arg: %r', opt, arg) + context = self.getopts[opt] + if context is not None: + st = state.essence() + context(irc, msg, [arg], st) + assert len(st.args) == 1 + getopts.append((opt, st.args[0])) + else: + getopts.append((opt, True)) + state.args.append(getopts) + args[:] = rest + log.debug('args after %r: %r', self, args) + +### +# This is our state object, passed to converters along with irc, msg, and args. +### + +class State(object): + log = log + def __init__(self, types): + self.args = [] + self.kwargs = {} + self.types = types + self.channel = None + + def essence(self): + st = State(self.types) + for (attr, value) in self.__dict__.iteritems(): + if attr not in ('args', 'kwargs'): + setattr(st, attr, value) + return st + + def __repr__(self): + return '%s(args=%r, kwargs=%r, channel=%r)' % (self.__class__.__name__, + self.args, self.kwargs, + self.channel) + + +### +# This is a compiled Spec object. +### +class Spec(object): + def _state(self, types, attrs={}): + st = State(types) + st.__dict__.update(attrs) + st.allowExtra = self.allowExtra + return st + + def __init__(self, types, allowExtra=False): + self.types = types + self.allowExtra = allowExtra + utils.mapinto(contextify, self.types) + + def __call__(self, irc, msg, args, stateAttrs={}): + state = self._state(self.types[:], stateAttrs) + while state.types: + context = state.types.pop(0) + try: + context(irc, msg, args, state) + except IndexError: + raise callbacks.ArgumentError + if args and not state.allowExtra: + log.debug('args and not self.allowExtra: %r', args) + raise callbacks.ArgumentError + return state + +def wrap(f, specList=[], **kw): + spec = Spec(specList, **kw) + def newf(self, irc, msg, args, **kwargs): + state = spec(irc, msg, args, stateAttrs={'cb': self, 'log': self.log}) + self.log.debug('State before call: %s' % state) + f(self, irc, msg, args, *state.args, **state.kwargs) + return utils.changeFunctionName(newf, f.func_name, f.__doc__) + + +__all__ = [ + # Contexts. + 'any', 'many', + 'optional', 'additional', + 'rest', 'getopts', + 'first', 'reverse', + 'commalist', + # Converter helpers. + 'getConverter', 'addConverter', 'callConverter', + # Decorators. + 'urlSnarfer', 'thread', + # Functions. + 'wrap', + # Stuff for testing. + 'Spec', +] + +# This doesn't work. Suck. +## if world.testing: +## __all__.append('Spec') + +# vim:set shiftwidth=4 tabstop=8 expandtab textwidth=78: diff --git a/src/conf.py b/src/conf.py new file mode 100644 index 000000000..75d4492c4 --- /dev/null +++ b/src/conf.py @@ -0,0 +1,995 @@ +### +# Copyright (c) 2002-2004, Jeremiah Fincher +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND 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 COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +### + +import supybot.fix as fix + +import os +import sys +import time +import socket +import string + +import supybot.cdb as cdb +import supybot.utils as utils +import supybot.registry as registry +import supybot.ircutils as ircutils + +installDir = os.path.dirname(sys.modules[__name__].__file__) +_srcDir = installDir +_pluginsDir = os.path.join(installDir, 'plugins') + +### +# version: This should be pretty obvious. +### +version = '0.80.0' + +### +# *** The following variables are affected by command-line options. They are +# not registry variables for a specific reason. Do *not* change these to +# registry variables without first consulting people smarter than yourself. +### + +### +# daemonized: This determines whether or not the bot has been daemonized +# (i.e., set to run in the background). Obviously, this defaults +# to False. A command-line option for obvious reasons. +### +daemonized = False + +### +# allowDefaultOwner: True if supybot.capabilities is allowed not to include +# '-owner' -- that is, if all users should be automatically +# recognized as owners. That would suck, hence we require a +# command-line option to allow this stupidity. +### +allowDefaultOwner = False + +### +# The standard registry. +### +supybot = registry.Group() +supybot.setName('supybot') + +def registerGroup(Group, name, group=None, **kwargs): + if kwargs: + group = registry.Group(**kwargs) + return Group.register(name, group) + +def registerGlobalValue(group, name, value): + value.channelValue = False + return group.register(name, value) + +def registerChannelValue(group, name, value): + value._supplyDefault = True + value.channelValue = True + g = group.register(name, value) + gname = g._name.lower() + for name in registry._cache.iterkeys(): + if name.lower().startswith(gname) and len(gname) < len(name): + name = name[len(gname)+1:] # +1 for . + parts = registry.split(name) + if len(parts) == 1 and parts[0] and ircutils.isChannel(parts[0]): + # This gets the channel values so they always persist. + g.get(parts[0])() + +def registerPlugin(name, currentValue=None, public=True): + group = registerGlobalValue(supybot.plugins, name, + registry.Boolean(False, """Determines whether this plugin is loaded by + default.""", showDefault=False)) + supybot.plugins().add(name) + registerGlobalValue(group, 'public', + registry.Boolean(public, """Determines whether this plugin is + publicly visible.""")) + if currentValue is not None: + supybot.plugins.get(name).setValue(currentValue) + registerGroup(users.plugins, name) + return group + +def get(group, channel=None): + if group.channelValue and \ + channel is not None and ircutils.isChannel(channel): + return group.get(channel)() + else: + return group() + +### +# The user info registry. +### +users = registry.Group() +users.setName('users') +registerGroup(users, 'plugins', orderAlphabetically=True) + +def registerUserValue(group, name, value): + assert group._name.startswith('users') + value._supplyDefault = True + group.register(name, value) + +class ValidNick(registry.String): + """Value must be a valid IRC nick.""" + def setValue(self, v): + if not ircutils.isNick(v): + self.error() + else: + registry.String.setValue(self, v) + +class ValidNicks(registry.SpaceSeparatedListOf): + Value = ValidNick + +class ValidNickAllowingPercentS(ValidNick): + """Value must be a valid IRC nick, with the possible exception of a %s + in it.""" + def setValue(self, v): + # If this works, it's a valid nick, aside from the %s. + try: + ValidNick.setValue(self, v.replace('%s', '')) + # It's valid aside from the %s, we'll let it through. + registry.String.setValue(self, v) + except registry.InvalidRegistryValue: + self.error() + +class ValidNicksAllowingPercentS(ValidNicks): + Value = ValidNickAllowingPercentS + +class ValidChannel(registry.String): + """Value must be a valid IRC channel name.""" + def setValue(self, v): + if ',' in v: + # To prevent stupid users from: a) trying to add a channel key + # with a comma in it, b) trying to add channels separated by + # commas instead of spaces + try: + (channel, _) = v.split(',') + except ValueError: + self.error() + else: + channel = v + if not ircutils.isChannel(channel): + self.error() + else: + registry.String.setValue(self, v) + +registerGlobalValue(supybot, 'nick', + ValidNick('supybot', """Determines the bot's default nick.""")) + +registerGlobalValue(supybot.nick, 'alternates', + ValidNicksAllowingPercentS(['%s`', '%s_'], """Determines what alternative + nicks will be used if the primary nick (supybot.nick) isn't available. A + %s in this nick is replaced by the value of supybot.nick when used. If no + alternates are given, or if all are used, the supybot.nick will be perturbed + appropriately until an unused nick is found.""")) + +registerGlobalValue(supybot, 'ident', + ValidNick('supybot', """Determines the bot's ident string, if the server + doesn't provide one by default.""")) + +class VersionIfEmpty(registry.String): + def __call__(self): + ret = registry.String.__call__(self) + if not ret: + ret = 'Supybot %s' % version + return ret + +registerGlobalValue(supybot, 'user', + VersionIfEmpty('', """Determines the user the bot sends to the server. + A standard user using the current version of the bot will be generated if + this is left empty.""")) + +class Networks(registry.SpaceSeparatedSetOfStrings): + List = ircutils.IrcSet + +registerGlobalValue(supybot, 'networks', + Networks([], """Determines what networks the bot will connect to.""", + orderAlphabetically=True)) + +class Servers(registry.SpaceSeparatedListOfStrings): + def normalize(self, s): + if ':' not in s: + s += ':6667' + return s + + def convert(self, s): + s = self.normalize(s) + (server, port) = s.split(':') + port = int(port) + return (server, port) + + def __call__(self): + L = registry.SpaceSeparatedListOfStrings.__call__(self) + return map(self.convert, L) + + def __str__(self): + return ' '.join(registry.SpaceSeparatedListOfStrings.__call__(self)) + + def append(self, s): + L = registry.SpaceSeparatedListOfStrings.__call__(self) + L.append(s) + +class SpaceSeparatedSetOfChannels(registry.SpaceSeparatedListOf): + sorted = True + List = ircutils.IrcSet + Value = ValidChannel + def join(self, channel): + import ircmsgs # Don't put this globally! It's recursive. + key = self.key.get(channel)() + if key: + return ircmsgs.join(channel, key) + else: + return ircmsgs.join(channel) + +def registerNetwork(name, password=''): + network = registerGroup(supybot.networks, name) + registerGlobalValue(network, 'password', registry.String(password, + """Determines what password will be used on %s. Yes, we know that + technically passwords are server-specific and not network-specific, + but this is the best we can do right now.""" % name, private=True)) + registryServers = registerGlobalValue(network, 'servers', Servers([], + """Determines what servers the bot will connect to for %s. Each will + be tried in order, wrapping back to the first when the cycle is + completed.""" % name)) + registerGlobalValue(network, 'channels', SpaceSeparatedSetOfChannels([], + """Determines what channels the bot will join only on %s.""" % name)) + registerChannelValue(network.channels, 'key', registry.String('', + """Determines what key (if any) will be used to join the channel.""")) + return network + +# Let's fill our networks. +for (name, s) in registry._cache.iteritems(): + if name.startswith('supybot.networks.'): + parts = name.split('.') + name = parts[2] + if name != 'default': + registerNetwork(name) + + +### +# Reply/error tweaking. +### +registerGroup(supybot, 'reply') + +registerGroup(supybot.reply, 'format') +registerChannelValue(supybot.reply.format, 'time', + registry.String('%I:%M %p, %B %d, %Y', """Determines how timestamps printed + for human reading should be formatted. Refer to the Python documentation + for the time module to see valid formatting characters for time + formats.""")) + +registerGroup(supybot.reply.format.time, 'elapsed') +registerChannelValue(supybot.reply.format.time.elapsed, 'short', + registry.Boolean(False, """Determines whether elapsed times will be given + as "1 day, 2 hours, 3 minutes, and 15 seconds" or as "1d 2h 3m 15s".""")) + +originalTimeElapsed = utils.timeElapsed +def timeElapsed(*args, **kwargs): + kwargs['short'] = supybot.reply.format.time.elapsed.short() + return originalTimeElapsed(*args, **kwargs) +utils.timeElapsed = timeElapsed + +registerGlobalValue(supybot.reply, 'maximumLength', + registry.Integer(512*256, """Determines the absolute maximum length of the + bot's reply -- no reply will be passed through the bot with a length + greater than this.""")) + +registerChannelValue(supybot.reply, 'mores', + registry.Boolean(True, """Determines whether the bot will break up long + messages into chunks and allow users to use the 'more' command to get the + remaining chunks.""")) + +registerChannelValue(supybot.reply.mores, 'maximum', + registry.PositiveInteger(50, """Determines what the maximum number of + chunks (for use with the 'more' command) will be.""")) + +registerChannelValue(supybot.reply.mores, 'length', + registry.NonNegativeInteger(0, """Determines how long individual chunks + will be. If set to 0, uses our super-tweaked, + get-the-most-out-of-an-individual-message default.""")) + +registerChannelValue(supybot.reply.mores, 'instant', + registry.PositiveInteger(1, """Determines how many mores will be sent + instantly (i.e., without the use of the more command, immediately when + they are formed). Defaults to 1, which means that a more command will be + required for all but the first chunk.""")) + +registerGlobalValue(supybot.reply, 'oneToOne', + registry.Boolean(True, """Determines whether the bot will send + multi-message replies in a single message or in multiple messages. For + safety purposes (so the bot is less likely to flood) it will normally send + everything in a single message, using mores if necessary.""")) + +registerChannelValue(supybot.reply, 'whenNotCommand', + registry.Boolean(True, """Determines whether the bot will reply with an + error message when it is addressed but not given a valid command. If this + value is False, the bot will remain silent, as long as no other plugins + override the normal behavior.""")) + +registerGroup(supybot.reply, 'error') +registerGlobalValue(supybot.reply.error, 'detailed', + registry.Boolean(False, """Determines whether error messages that result + from bugs in the bot will show a detailed error message (the uncaught + exception) or a generic error message.""")) +registerChannelValue(supybot.reply.error, 'inPrivate', + registry.Boolean(False, """Determines whether the bot will send error + messages to users in private. You might want to do this in order to keep + channel traffic to minimum. This can be used in combination with + supybot.reply.errorWithNotice.""")) +registerChannelValue(supybot.reply.error, 'withNotice', + registry.Boolean(False, """Determines whether the bot will send error + messages to users via NOTICE instead of PRIVMSG. You might want to do this + so users can ignore NOTICEs from the bot and not have to see error + messages; or you might want to use it in combination with + supybot.reply.errorInPrivate so private errors don't open a query window + in most IRC clients.""")) +registerChannelValue(supybot.reply.error, 'noCapability', + registry.Boolean(False, """Determines whether the bot will send an error + message to users who attempt to call a command for which they do not have + the necessary capability. You may wish to make this True if you don't want + users to understand the underlying security system preventing them from + running certain commands.""")) + +registerChannelValue(supybot.reply, 'inPrivate', + registry.Boolean(False, """Determines whether the bot will reply privately + when replying in a channel, rather than replying to the whole channel.""")) + +registerChannelValue(supybot.reply, 'withNotice', + registry.Boolean(False, """Determines whether the bot will reply with a + notice when replying in a channel, rather than replying with a privmsg as + normal.""")) + +# XXX: User value. +registerGlobalValue(supybot.reply, 'withNoticeWhenPrivate', + registry.Boolean(False, """Determines whether the bot will reply with a + notice when it is sending a private message, in order not to open a /query + window in clients. This can be overridden by individual users via the user + configuration variable reply.withNoticeWhenPrivate.""")) + +registerChannelValue(supybot.reply, 'withNickPrefix', + registry.Boolean(True, """Determines whether the bot will always prefix the + user's nick to its reply to that user's command.""")) + +registerChannelValue(supybot.reply, 'whenNotAddressed', + registry.Boolean(False, """Determines whether the bot should attempt to + reply to all messages even if they don't address it (either via its nick + or a prefix character). If you set this to True, you almost certainly want + to set supybot.reply.whenNotCommand to False.""")) + +registerChannelValue(supybot.reply, 'requireChannelCommandsToBeSentInChannel', + registry.Boolean(False, """Determines whether the bot will allow you to + send channel-related commands outside of that channel. Sometimes people + find it confusing if a channel-related command (like Filter.outfilter) + changes the behavior of the channel but was sent outside the channel + itself.""")) + +registerGlobalValue(supybot, 'followIdentificationThroughNickChanges', + registry.Boolean(False, """Determines whether the bot will unidentify + someone when that person changes his or her nick. Setting this to True + will cause the bot to track such changes. It defaults to False for a + little greater security.""")) + +registerGlobalValue(supybot, 'alwaysJoinOnInvite', + registry.Boolean(False, """Determines whether the bot will always join a + channel when it's invited. If this value is False, the bot will only join + a channel if the user inviting it has the 'admin' capability (or if it's + explicitly told to join the channel using the Admin.join command)""")) + +registerChannelValue(supybot.reply, 'showSimpleSyntax', + registry.Boolean(False, """Supybot normally replies with the full help + whenever a user misuses a command. If this value is set to True, the bot + will only reply with the syntax of the command (the first line of the + help) rather than the full help.""")) + +class ValidPrefixChars(registry.String): + """Value must contain only ~!@#$%^&*()_-+=[{}]\\|'\";:,<.>/?""" + def setValue(self, v): + if v.translate(string.ascii, '`~!@#$%^&*()_-+=[{}]\\|\'";:,<.>/?'): + self.error() + registry.String.setValue(self, v) + +registerGroup(supybot.reply, 'whenAddressedBy') +registerChannelValue(supybot.reply.whenAddressedBy, 'chars', + ValidPrefixChars('', """Determines what prefix characters the bot will + reply to. A prefix character is a single character that the bot will use + to determine what messages are addressed to it; when there are no prefix + characters set, it just uses its nick. Each character in this string is + interpreted individually; you can have multiple prefix chars + simultaneously, and if any one of them is used as a prefix the bot will + assume it is being addressed.""")) + +registerChannelValue(supybot.reply.whenAddressedBy, 'strings', + registry.SpaceSeparatedSetOfStrings([], """Determines what strings the bot + will reply to when they are at the beginning of the message. Whereas + prefix.chars can only be one character (although there can be many of + them), this variable is a space-separated list of strings, so you can + set something like '@@ ??' and the bot will reply when a message is + prefixed by either @@ or ??.""")) +registerChannelValue(supybot.reply.whenAddressedBy, 'nick', + registry.Boolean(True, """Determines whether the bot will reply when people + address it by its nick, rather than with a prefix character.""")) +registerChannelValue(supybot.reply.whenAddressedBy.nick, 'atEnd', + registry.Boolean(False, """Determines whether the bot will reply when + people address it by its nick at the end of the message, rather than at + the beginning.""")) +registerChannelValue(supybot.reply.whenAddressedBy, 'nicks', + registry.SpaceSeparatedSetOfStrings([], """Determines what extra nicks the + bot will always respond to when addressed by, even if its current nick is + something else.""")) + +### +# Replies +### +registerGroup(supybot, 'replies') + +registerChannelValue(supybot.replies, 'success', + registry.NormalizedString("""The operation succeeded.""", """Determines + what message the bot replies with when a command succeeded. If this + configuration variable is empty, no success message will be sent.""")) + +registerChannelValue(supybot.replies, 'error', + registry.NormalizedString("""An error has occurred and has been logged. + Please contact this bot's administrator for more information.""", """ + Determines what error message the bot gives when it wants to be + ambiguous.""")) + +registerChannelValue(supybot.replies, 'incorrectAuthentication', + registry.NormalizedString("""Your hostmask doesn't match or your password + is wrong.""", """Determines what message the bot replies with when someone + tries to use a command that requires being identified or having a password + and neither credential is correct.""")) + +# XXX: This should eventually check that there's one and only one %s here. +registerChannelValue(supybot.replies, 'noUser', + registry.NormalizedString("""I can't find %s in my user + database. If you didn't give a user name, then I might not know what your + user is, and you'll need to identify before this command might work.""", + """Determines what error message the bot replies with when someone tries + to accessing some information on a user the bot doesn't know about.""")) + +registerChannelValue(supybot.replies, 'notRegistered', + registry.NormalizedString("""You must be registered to use this command. + If you are already registered, you must either identify (using the identify + command) or add a hostmask matching your current hostmask (using the + addhostmask command).""", """Determines what error message the bot replies + with when someone tries to do something that requires them to be registered + but they're not currently recognized.""")) + +registerChannelValue(supybot.replies, 'noCapability', + registry.NormalizedString("""You don't have the %s capability. If you + think that you should have this capability, be sure that you are identified + before trying again. The 'whoami' command can tell you if you're + identified.""", """Determines what error message is given when the bot is + telling someone they aren't cool enough to use the command they tried to + use.""")) + +registerChannelValue(supybot.replies, 'genericNoCapability', + registry.NormalizedString("""You're missing some capability you need. + This could be because you actually possess the anti-capability for the + capability that's required of you, or because the channel provides that + anti-capability by default, or because the global capabilities include + that anti-capability. Or, it could be because the channel or + supybot.capabilities.default is set to False, meaning that no commands are + allowed unless explicitly in your capabilities. Either way, you can't do + what you want to do.""", + """Determines what generic error message is given when the bot is telling + someone that they aren't cool enough to use the command they tried to use, + and the author of the code calling errorNoCapability didn't provide an + explicit capability for whatever reason.""")) + +registerChannelValue(supybot.replies, 'requiresPrivacy', + registry.NormalizedString("""That operation cannot be done in a + channel.""", """Determines what error messages the bot sends to people who + try to do things in a channel that really should be done in private.""")) + +registerChannelValue(supybot.replies, 'possibleBug', + registry.NormalizedString("""This may + be a bug. If you think it is, please file a bug report at + .""", + """Determines what message the bot sends when it thinks you've encountered + a bug that the developers don't know about.""")) +### +# End supybot.replies. +### + +registerGlobalValue(supybot, 'snarfThrottle', + registry.Float(10.0, """A floating point number of seconds to throttle + snarfed URLs, in order to prevent loops between two bots snarfing the same + URLs and having the snarfed URL in the output of the snarf message.""")) + +registerGlobalValue(supybot, 'upkeepInterval', + registry.PositiveInteger(3600, """Determines the number of seconds between + running the upkeep function that flushes (commits) open databases, collects + garbage, and records some useful statistics at the debugging level.""")) + +registerGlobalValue(supybot, 'flush', + registry.Boolean(True, """Determines whether the bot will periodically + flush data and configuration files to disk. Generally, the only time + you'll want to set this to False is when you want to modify those + configuration files by hand and don't want the bot to flush its current + version over your modifications. Do note that if you change this to False + inside the bot, your changes won't be flushed. To make this change + permanent, you must edit the registry yourself.""")) + + +### +# supybot.commands. For stuff relating to commands. +### +registerGroup(supybot, 'commands') + +class ValidQuotes(registry.Value): + """Value must consist solely of \", ', and ` characters.""" + def setValue(self, v): + if [c for c in v if c not in '"`\'']: + self.error() + super(ValidQuotes, self).setValue(v) + + def __str__(self): + return str(self.value) + +registerChannelValue(supybot.commands, 'quotes', + ValidQuotes('"', """Determines what characters are valid for quoting + arguments to commands in order to prevent them from being tokenized. + """)) +# This is a GlobalValue because bot owners should be able to say, "There will +# be no nesting at all on this bot." Individual channels can just set their +# brackets to the empty string. +registerGlobalValue(supybot.commands, 'nested', + registry.Boolean(True, """Determines whether the bot will allow nested + commands, which rule. You definitely should keep this on.""")) +registerGlobalValue(supybot.commands.nested, 'maximum', + registry.PositiveInteger(10, """Determines what the maximum number of + nested commands will be; users will receive an error if they attempt + commands more nested than this.""")) + +class ValidBrackets(registry.OnlySomeStrings): + validStrings = ('', '[]', '<>', '{}', '()') + +registerChannelValue(supybot.commands.nested, 'brackets', + ValidBrackets('[]', """Supybot allows you to specify what brackets are used + for your nested commands. Valid sets of brackets include [], <>, and {} + (). [] has strong historical motivation, as well as being the brackets + that don't require shift. <> or () might be slightly superior because they + cannot occur in a nick. If this string is empty, nested commands will + not be allowed in this channel.""")) +registerChannelValue(supybot.commands.nested, 'pipeSyntax', + registry.Boolean(False, """Supybot allows nested commands. Enabling this + option will allow nested commands with a syntax similar to UNIX pipes, for + example: 'bot: foo | bar'.""")) + +registerGroup(supybot.commands, 'defaultPlugins', + orderAlphabetically=True, help=utils.normalizeWhitespace("""Determines + what commands have default plugins set, and which plugins are set to + be the default for each of those commands.""")) +registerGlobalValue(supybot.commands.defaultPlugins, 'importantPlugins', + registry.SpaceSeparatedSetOfStrings( + ['Admin', 'Channel', 'Config', 'Misc', 'Owner', 'User'], + """Determines what plugins automatically get precedence over all other + plugins when selecting a default plugin for a command. By default, + this includes the standard loaded plugins. You probably shouldn't + change this if you don't know what you're doing; if you do know what + you're doing, then also know that this set is case-sensitive.""")) + +# supybot.commands.disabled moved to callbacks for canonicalName. + +### +# supybot.abuse. For stuff relating to abuse of the bot. +### +registerGroup(supybot, 'abuse') +registerGroup(supybot.abuse, 'flood') +registerGlobalValue(supybot.abuse.flood, 'command', + registry.Boolean(True, """Determines whether the bot will defend itself + against command-flooding.""")) +registerGlobalValue(supybot.abuse.flood.command, 'maximum', + registry.PositiveInteger(12, """Determines how many commands users are + allowed per minute. If a user sends more than this many commands in any + 60 second period, he or she will be ignored for + supybot.abuse.flood.command.punishment seconds.""")) +registerGlobalValue(supybot.abuse.flood.command, 'punishment', + registry.PositiveInteger(300, """Determines how many seconds the bot + will ignore users who flood it with commands.""")) + +registerGlobalValue(supybot.abuse.flood.command, 'invalid', + registry.Boolean(True, """Determines whether the bot will defend itself + against invalid command-flooding.""")) +registerGlobalValue(supybot.abuse.flood.command.invalid, 'maximum', + registry.PositiveInteger(5, """Determines how many invalid commands users + are allowed per minute. If a user sends more than this many invalid + commands in any 60 second period, he or she will be ignored for + supybot.abuse.flood.command.invalid.punishment seconds. Typically, this + value is lower than supybot.abuse.flood.command.maximum, since it's far + less likely (and far more annoying) for users to flood with invalid + commands than for them to flood with valid commands.""")) +registerGlobalValue(supybot.abuse.flood.command.invalid, 'punishment', + registry.PositiveInteger(600, """Determines how many seconds the bot + will ignore users who flood it with invalid commands. Typically, this + value is higher than supybot.abuse.flood.command.punishment, since it's far + less likely (and far more annoying) for users to flood witih invalid + commands than for them to flood with valid commands.""")) + + +### +# supybot.drivers. For stuff relating to Supybot's drivers (duh!) +### +registerGroup(supybot, 'drivers') +registerGlobalValue(supybot.drivers, 'poll', + registry.PositiveFloat(1.0, """Determines the default length of time a + driver should block waiting for input.""")) + +class ValidDriverModule(registry.OnlySomeStrings): + validStrings = ('default', 'Socket', 'Twisted') + +registerGlobalValue(supybot.drivers, 'module', + ValidDriverModule('default', """Determines what driver module the bot will + use. socketDrivers, a simple driver based on timeout sockets, is used by + default because it's simple and stable. asyncoreDrivers is a bit older + (and less well-maintained) but allows you to integrate with asyncore-based + applications. twistedDrivers is very stable and simple, and if you've got + Twisted installed, is probably your best bet.""")) + +### +# supybot.directories, for stuff relating to directories. +### + +# XXX This shouldn't make directories willy-nilly. As it is now, if it's +# configured, it'll still make the default directories, I think. +class Directory(registry.String): + def __call__(self): + # ??? Should we perhaps always return an absolute path here? + v = super(Directory, self).__call__() + if not os.path.exists(v): + os.mkdir(v) + return v + + def dirize(self, filename): + myself = self() + if os.path.isabs(filename): + filename = os.path.abspath(filename) + selfAbs = os.path.abspath(myself) + commonPrefix = os.path.commonprefix([selfAbs, filename]) + filename = filename[len(commonPrefix):] + elif not os.path.isabs(myself): + if filename.startswith(myself): + filename = filename[len(myself):] + filename = filename.lstrip(os.path.sep) # Stupid os.path.join! + return os.path.join(myself, filename) + +class DataFilename(registry.String): + def __call__(self): + v = super(DataFilename, self).__call__() + dataDir = supybot.directories.data() + if not v.startswith(dataDir): + v = os.path.basename(v) + v = os.path.join(dataDir, v) + self.setValue(v) + return v + +class DataFilenameDirectory(DataFilename, Directory): + def __call__(self): + v = DataFilename.__call__(self) + v = Directory.__call__(self) + return v + +registerGroup(supybot, 'directories') +registerGlobalValue(supybot.directories, 'conf', + Directory('conf', """Determines what directory configuration data is + put into.""")) +registerGlobalValue(supybot.directories, 'data', + Directory('data', """Determines what directory data is put into.""")) +registerGlobalValue(supybot.directories, 'backup', + Directory('backup', """Determines what directory backup data is put + into.""")) +registerGlobalValue(supybot.directories.data, 'tmp', + DataFilenameDirectory('tmp', """Determines what directory temporary files + are put into.""")) + +# Remember, we're *meant* to replace this nice little wrapper. +def transactionalFile(*args, **kwargs): + kwargs['tmpDir'] = supybot.directories.data.tmp() + kwargs['backupDir'] = supybot.directories.backup() + return utils.AtomicFile(*args, **kwargs) +utils.transactionalFile = transactionalFile + +class PluginDirectories(registry.CommaSeparatedListOfStrings): + def __call__(self): + v = registry.CommaSeparatedListOfStrings.__call__(self) + if _pluginsDir not in v: + v.append(_pluginsDir) + return v + +registerGlobalValue(supybot.directories, 'plugins', + PluginDirectories([], """Determines what directories the bot will + look for plugins in. Accepts a comma-separated list of strings. This + means that to add another directory, you can nest the former value and add + a new one. E.g. you can say: bot: 'config supybot.directories.plugins + [config supybot.directories.plugins], newPluginDirectory'.""")) + +registerGlobalValue(supybot, 'plugins', + registry.SpaceSeparatedSetOfStrings([], """Determines what plugins will + be loaded.""", orderAlphabetically=True)) +registerGlobalValue(supybot.plugins, 'alwaysLoadImportant', + registry.Boolean(True, """Determines whether the bot will always load + important plugins (Admin, Channel, Config, Misc, Owner, and User) + regardless of what their configured state is. Generally, if these plugins + are configured not to load, you didn't do it on purpose, and you still + want them to load. Users who don't want to load these plugins are smart + enough to change the value of this variable appropriately :)""")) + +### +# supybot.databases. For stuff relating to Supybot's databases (duh!) +### +class Databases(registry.SpaceSeparatedListOfStrings): + def __call__(self): + v = super(Databases, self).__call__() + if not v: + v = ['anydbm', 'cdb', 'flat', 'pickle'] + if 'sqlite' in sys.modules: + v.insert(0, 'sqlite') + return v + + def serialize(self): + return ' '.join(self.value) + +registerGlobalValue(supybot, 'databases', + Databases([], """Determines what databases are available for use. If this + value is not configured (that is, if its value is empty) then sane defaults + will be provided.""")) + +registerGroup(supybot.databases, 'users') +registerGlobalValue(supybot.databases.users, 'filename', + registry.String('users.conf', """Determines what filename will be used for + the users database. This file will go into the directory specified by the + supybot.directories.conf variable.""")) +registerGlobalValue(supybot.databases.users, 'timeoutIdentification', + registry.Integer(0, """Determines how long it takes identification to time + out. If the value is less than or equal to zero, identification never + times out.""")) +registerGlobalValue(supybot.databases.users, 'allowUnregistration', + registry.Boolean(False, """Determines whether the bot will allow users to + unregister their users. This can wreak havoc with already-existing + databases, so by default we don't allow it. Enable this at your own risk. + (Do also note that this does not prevent the owner of the bot from using + the unregister command.) + """)) + +registerGroup(supybot.databases, 'ignores') +registerGlobalValue(supybot.databases.ignores, 'filename', + registry.String('ignores.conf', """Determines what filename will be used + for the ignores database. This file will go into the directory specified + by the supybot.directories.conf variable.""")) + +registerGroup(supybot.databases, 'channels') +registerGlobalValue(supybot.databases.channels, 'filename', + registry.String('channels.conf', """Determines what filename will be used + for the channels database. This file will go into the directory specified + by the supybot.directories.conf variable.""")) + +# TODO This will need to do more in the future (such as making sure link.allow +# will let the link occur), but for now let's just leave it as this. +class ChannelSpecific(registry.Boolean): + def getChannelLink(self, channel): + channelSpecific = supybot.databases.plugins.channelSpecific + channels = [channel] + def hasLinkChannel(channel): + if not get(channelSpecific, channel): + lchannel = get(channelSpecific.link, channel) + if not get(channelSpecific.link.allow, lchannel): + return False + return channel != lchannel + return False + lchannel = channel + while hasLinkChannel(lchannel): + lchannel = get(channelSpecific.link, lchannel) + if lchannel not in channels: + channels.append(lchannel) + else: + # Found a cyclic link. We'll just use the current channel + lchannel = channel + break + return lchannel + +registerGroup(supybot.databases, 'plugins') +registerChannelValue(supybot.databases.plugins, 'channelSpecific', + ChannelSpecific(True, """Determines whether database-based plugins that + can be channel-specific will be so. This can be overridden by individual + channels. Do note that the bot needs to be restarted immediately after + changing this variable or your db plugins may not work for your channel; + also note that you may wish to set + supybot.databases.plugins.channelSpecific.link appropriately if you wish + to share a certain channel's databases globally.""")) +registerChannelValue(supybot.databases.plugins.channelSpecific, 'link', + ValidChannel('#', """Determines what channel global (non-channel-specific) + databases will be considered a part of. This is helpful if you've been + running channel-specific for awhile and want to turn the databases for + your primary channel into global databases. If + supybot.databases.plugins.channelSpecific.link.allow prevents linking, the + current channel will be used. Do note that the bot needs to be restarted + immediately after changing this variable or your db plugins may not work + for your channel.""")) +registerChannelValue(supybot.databases.plugins.channelSpecific.link, 'allow', + registry.Boolean(True, """Determines whether another channel's global + (non-channel-specific) databases will be allowed to link to this channel's + databases. Do note that the bot needs to be restarted immediately after + changing this variable or your db plugins may not work for your channel. + """)) + + +class CDB(registry.Boolean): + def connect(self, filename): + basename = os.path.basename(filename) + journalName = supybot.directories.data.tmp.dirize(basename+'.journal') + return cdb.open(filename, 'c', + journalName=journalName, + maxmods=self.maximumModifications()) + +registerGroup(supybot.databases, 'types') +registerGlobalValue(supybot.databases.types, 'cdb', CDB(True, """Determines + whether CDB databases will be allowed as a database implementation.""")) +registerGlobalValue(supybot.databases.types.cdb, 'maximumModifications', + registry.Float(0.5, """Determines how often CDB databases will have their + modifications flushed to disk. When the number of modified records is + greater than this part of the number of unmodified records, the database + will be entirely flushed to disk.""")) + +# XXX Configuration variables for dbi, sqlite, flat, mysql, etc. + +### +# Protocol information. +### +originalIsNick = ircutils.isNick +def isNick(s, strictRfc=None, **kw): + if strictRfc is None: + strictRfc = supybot.protocols.irc.strictRfc() + return originalIsNick(s, strictRfc=strictRfc, **kw) +ircutils.isNick = isNick + +### +# supybot.protocols +### +registerGroup(supybot, 'protocols') + +### +# supybot.protocols.irc +### +registerGroup(supybot.protocols, 'irc') +registerGlobalValue(supybot.protocols.irc, 'strictRfc', + registry.Boolean(False, """Determines whether the bot will strictly follow + the RFC; currently this only affects what strings are considered to be + nicks. If you're using a server or a network that requires you to message + a nick such as services@this.network.server then you you should set this to + False.""")) + +registerGlobalValue(supybot.protocols.irc, 'umodes', + registry.String('', """Determines what user modes the bot will request from + the server when it first connects. Many people might choose +i; some + networks allow +x, which indicates to the auth services on those networks + that you should be given a fake host.""")) + +registerGlobalValue(supybot.protocols.irc, 'vhost', + registry.String('', """Determines what vhost the bot will bind to before + connecting to the IRC server.""")) + +registerGlobalValue(supybot.protocols.irc, 'maxHistoryLength', + registry.Integer(1000, """Determines how many old messages the bot will + keep around in its history. Changing this variable will not take effect + until the bot is restarted.""")) + +registerGlobalValue(supybot.protocols.irc, 'throttleTime', + registry.Float(1.0, """A floating point number of seconds to throttle + queued messages -- that is, messages will not be sent faster than once per + throttleTime seconds.""")) + +registerGlobalValue(supybot.protocols.irc, 'ping', + registry.Boolean(True, """Determines whether the bot will send PINGs to the + server it's connected to in order to keep the connection alive and discover + earlier when it breaks. Really, this option only exists for debugging + purposes: you always should make it True unless you're testing some strange + server issues.""")) + +registerGlobalValue(supybot.protocols.irc.ping, 'interval', + registry.Integer(120, """Determines the number of seconds between sending + pings to the server, if pings are being sent to the server.""")) + +registerGlobalValue(supybot.protocols.irc, 'queueDuplicateMessages', + registry.Boolean(False, """Determines whether the bot will allow duplicate + messages to be queued for delivery to the server. This is a safety + mechanism put in place to prevent plugins from sending the same message + multiple times; most of the time it doesn't matter, but when it does, + you'll probably want it to disallowed.""")) + +### +# supybot.protocols.http +### +registerGroup(supybot.protocols, 'http') +registerGlobalValue(supybot.protocols.http, 'peekSize', + registry.PositiveInteger(4096, """Determines how many bytes the bot will + 'peek' at when looking through a URL for a doctype or title or something + similar. It'll give up after it reads this many bytes, even if it hasn't + found what it was looking for.""")) + +registerGlobalValue(supybot.protocols.http, 'proxy', + registry.String('', """Determines what proxy all HTTP requests should go + through. The value should be of the form 'host:port'.""")) + + +### +# Especially boring stuff. +### +registerGlobalValue(supybot, 'defaultIgnore', + registry.Boolean(False, """Determines whether the bot will ignore + unregistered users by default. Of course, that'll make it particularly + hard for those users to register or identify with the bot, but that's your + problem to solve.""")) + +class IP(registry.String): + """Value must be a valid IP.""" + def setValue(self, v): + if v and not (utils.isIP(v) or utils.isIPV6(v)): + self.error() + else: + registry.String.setValue(self, v) + +registerGlobalValue(supybot, 'externalIP', + IP('', """A string that is the external IP of the bot. If this is the empty + string, the bot will attempt to find out its IP dynamically (though + sometimes that doesn't work, hence this variable).""")) + +class SocketTimeout(registry.PositiveInteger): + """Value must be an integer greater than supybot.drivers.poll and must be + greater than or equal to 1.""" + def setValue(self, v): + if v < supybot.drivers.poll() or v < 1: + self.error() + registry.PositiveInteger.setValue(self, v) + socket.setdefaulttimeout(self.value) + +registerGlobalValue(supybot, 'defaultSocketTimeout', + SocketTimeout(10, """Determines what the default timeout for socket objects + will be. This means that *all* sockets will timeout when this many seconds + has gone by (unless otherwise modified by the author of the code that uses + the sockets).""")) + +registerGlobalValue(supybot, 'pidFile', + registry.String('', """Determines what file the bot should write its PID + (Process ID) to, so you can kill it more easily. If it's left unset (as is + the default) then no PID file will be written. A restart is required for + changes to this variable to take effect.""")) + +### +# Debugging options. +### +registerGroup(supybot, 'debug') +registerGlobalValue(supybot.debug, 'threadAllCommands', + registry.Boolean(False, """Determines whether the bot will automatically + thread all commands.""")) +registerGlobalValue(supybot.debug, 'flushVeryOften', + registry.Boolean(False, """Determines whether the bot will automatically + flush all flushers *very* often. Useful for debugging when you don't know + what's breaking or when, but think that it might be logged.""")) +registerGlobalValue(supybot.debug, 'generated', + registry.String('$Id: conf.py,v 1.237 2005/01/17 04:54:17 jamessan Exp $; %s' % time.ctime(), """Determines when this + configuration file was generated; it should be modified by + supybot-wizard""")) + +# vim:set shiftwidth=4 tabstop=8 expandtab textwidth=78: diff --git a/src/dbi.py b/src/dbi.py new file mode 100644 index 000000000..0af3fd3b9 --- /dev/null +++ b/src/dbi.py @@ -0,0 +1,440 @@ +### +# Copyright (c) 2002-2004, Jeremiah Fincher +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND 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 COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +### + +""" +Module for some slight database-independence for simple databases. +""" + + + +import supybot.fix as fix + +import csv +import math +import sets +import random +from itertools import ilen + +import supybot.cdb as cdb +import supybot.utils as utils + +class Error(Exception): + """General error for this module.""" + +class NoRecordError(KeyError): + pass + +class InvalidDBError(Exception): + pass + +class MappingInterface(object): + """This is a class to represent the underlying representation of a map + from integer keys to strings.""" + def __init__(self, filename, **kwargs): + """Feel free to ignore the filename.""" + raise NotImplementedError + + def get(id): + """Gets the record matching id. Raises NoRecordError otherwise.""" + raise NotImplementedError + + def set(id, s): + """Sets the record matching id to s.""" + raise NotImplementedError + + def add(self, s): + """Adds a new record, returning a new id for it.""" + raise NotImplementedError + + def remove(self, id): + "Returns and removes the record with the given id from the database." + raise NotImplementedError + + def __iter__(self): + "Return an iterator over (id, s) pairs. Not required to be ordered." + raise NotImplementedError + + def flush(self): + """Flushes current state to disk.""" + raise NotImplementedError + + def close(self): + """Flushes current state to disk and invalidates the Mapping.""" + raise NotImplementedError + + def vacuum(self): + "Cleans up in the database, if possible. Not required to do anything." + pass + + +class DirMapping(MappingInterface): + def __init__(self, filename, **kwargs): + self.dirname = filename + if not os.path.exists(self.dirname): + os.mkdir(self.dirname) + if not os.path.exists(os.path.join(self.dirname, 'max')): + self._setMax(1) + + def _setMax(self, id): + fd = file(os.path.join(self.dirname, 'max'), 'w') + try: + fd.write(str(id)) + finally: + fd.close() + + def _getMax(self): + fd = file(os.path.join(self.dirname, 'max')) + try: + i = int(fd.read()) + return i + finally: + fd.close() + + def _makeFilename(self, id): + return os.path.join(self.dirname, str(id)) + + def get(id): + try: + fd = file(self._makeFilename(id)) + return fd.read() + except EnvironmentError, e: + exn = NoRecordError(id) + exn.realException = e + raise exn + + def set(id, s): + fd = file(self._makeFilename(id), 'w') + fd.write(s) + fd.close() + + def add(self, s): + id = self._getMax() + fd = file(self._makeFilename(id), 'w') + try: + fd.write(s) + return id + finally: + fd.close() + + def remove(self, id): + try: + os.remove(self._makeFilename(id)) + except EnvironmentError, e: + raise NoRecordError, id + +class FlatfileMapping(MappingInterface): + def __init__(self, filename, maxSize=10**6): + self.filename = filename + try: + fd = file(self.filename) + strId = fd.readline().rstrip() + self.maxSize = len(strId) + try: + self.currentId = int(strId) + except ValueError: + raise Error, 'Invalid file for FlatfileMapping: %s' % filename + except EnvironmentError, e: + # File couldn't be opened. + self.maxSize = int(math.log10(maxSize)) + self.currentId = 0 + self._incrementCurrentId() + + def _canonicalId(self, id): + if id is not None: + return str(id).zfill(self.maxSize) + else: + return '-'*self.maxSize + + def _incrementCurrentId(self, fd=None): + fdWasNone = fd is None + if fdWasNone: + fd = file(self.filename, 'a') + fd.seek(0) + self.currentId += 1 + fd.write(self._canonicalId(self.currentId)) + fd.write('\n') + if fdWasNone: + fd.close() + + def _splitLine(self, line): + line = line.rstrip('\r\n') + (id, s) = line.split(':', 1) + return (id, s) + + def _joinLine(self, id, s): + return '%s:%s\n' % (self._canonicalId(id), s) + + def add(self, s): + line = self._joinLine(self.currentId, s) + fd = file(self.filename, 'r+') + try: + fd.seek(0, 2) # End. + fd.write(line) + return self.currentId + finally: + self._incrementCurrentId(fd) + fd.close() + + def get(self, id): + strId = self._canonicalId(id) + try: + fd = file(self.filename) + fd.readline() # First line, nextId. + for line in fd: + (lineId, s) = self._splitLine(line) + if lineId == strId: + return s + raise NoRecordError, id + finally: + fd.close() + + # XXX This assumes it's not been given out. We should make sure that our + # maximum id remains accurate if this is some value we've never given + # out -- i.e., self.maxid = max(self.maxid, id) or something. + def set(self, id, s): + strLine = self._joinLine(id, s) + try: + fd = file(self.filename, 'r+') + self.remove(id, fd) + fd.seek(0, 2) # End. + fd.write(strLine) + finally: + fd.close() + + def remove(self, id, fd=None): + fdWasNone = fd is None + strId = self._canonicalId(id) + try: + if fdWasNone: + fd = file(self.filename, 'r+') + fd.seek(0) + fd.readline() # First line, nextId + pos = fd.tell() + line = fd.readline() + while line: + (lineId, _) = self._splitLine(line) + if lineId == strId: + fd.seek(pos) + fd.write(self._canonicalId(None)) + fd.seek(pos) + fd.readline() # Same line we just rewrote the id for. + pos = fd.tell() + line = fd.readline() + # We should be at the end. + finally: + if fdWasNone: + fd.close() + + def __iter__(self): + fd = file(self.filename) + fd.readline() # First line, nextId. + for line in fd: + (id, s) = self._splitLine(line) + if not id.startswith('-'): + yield (int(id), s) + fd.close() + + def vacuum(self): + infd = file(self.filename) + outfd = utils.transactionalFile(self.filename, + makeBackupIfSmaller=False) + outfd.write(infd.readline()) # First line, nextId. + for line in infd: + if not line.startswith('-'): + outfd.write(line) + infd.close() + outfd.close() + + def flush(self): + pass # No-op, we maintain no open files. + + def close(self): + self.vacuum() # Should we do this? It should be fine. + + +class CdbMapping(MappingInterface): + def __init__(self, filename, **kwargs): + self.filename = filename + self._openCdb() # So it can be overridden later. + if 'nextId' not in self.db: + self.db['nextId'] = '1' + + def _openCdb(self, *args, **kwargs): + self.db = cdb.open(filename, 'c', **kwargs) + + def _getNextId(self): + i = int(self.db['nextId']) + self.db['nextId'] = str(i+1) + return i + + def get(self, id): + try: + return self.db[str(id)] + except KeyError: + raise NoRecordError, id + + # XXX Same as above. + def set(self, id, s): + self.db[str(id)] = s + + def add(self, s): + id = self._getNextId() + self.set(id, s) + return id + + def remove(self, id): + del self.db[str(id)] + + def __iter__(self): + for (id, s) in self.db.iteritems(): + if id != 'nextId': + yield (int(id), s) + + def flush(self): + self.db.flush() + + def close(self): + self.db.close() + + +class DB(object): + Mapping = 'flat' # This is a good, sane default. + Record = None + def __init__(self, filename, Mapping=None, Record=None): + if Record is not None: + self.Record = Record + if Mapping is not None: + self.Mapping = Mapping + if isinstance(self.Mapping, basestring): + self.Mapping = Mappings[self.Mapping] + self.map = self.Mapping(filename) + + def _newRecord(self, id, s): + record = self.Record(id=id) + record.deserialize(s) + return record + + def get(self, id): + s = self.map.get(id) + return self._newRecord(id, s) + + def set(self, id, record): + s = record.serialize() + self.map.set(id, s) + + def add(self, record): + s = record.serialize() + id = self.map.add(s) + record.id = id + return id + + def remove(self, id): + self.map.remove(id) + + def __iter__(self): + for (id, s) in self.map: + # We don't need to yield the id because it's in the record. + yield self._newRecord(id, s) + + def select(self, p): + for record in self: + if p(record): + yield record + + def random(self): + try: + return self._newRecord(*random.choice(self.map)) + except IndexError: + return None + + def size(self): + return ilen(self.map) + + def flush(self): + self.map.flush() + + def vacuum(self): + self.map.vacuum() + + def close(self): + self.map.close() + +Mappings = { + 'cdb': CdbMapping, + 'flat': FlatfileMapping, + } + + +class Record(object): + def __init__(self, id=None, **kwargs): + if id is not None: + assert isinstance(id, int), 'id must be an integer.' + self.id = id + self.fields = [] + self.defaults = {} + self.converters = {} + for name in self.__fields__: + if isinstance(name, tuple): + (name, spec) = name + else: + spec = utils.safeEval + assert name != 'id' + self.fields.append(name) + if isinstance(spec, tuple): + (converter, default) = spec + else: + converter = spec + default = None + self.defaults[name] = default + self.converters[name] = converter + seen = sets.Set() + for (name, value) in kwargs.iteritems(): + assert name in self.fields, 'name must be a record value.' + seen.add(name) + setattr(self, name, value) + for name in self.fields: + if name not in seen: + default = self.defaults[name] + if callable(default): + default = default() + setattr(self, name, default) + + def serialize(self): + return csv.join([repr(getattr(self, name)) for name in self.fields]) + + def deserialize(self, s): + unseenRecords = sets.Set(self.fields) + for (name, strValue) in zip(self.fields, csv.split(s)): + setattr(self, name, self.converters[name](strValue)) + unseenRecords.remove(name) + for name in unseenRecords: + setattr(self, name, self.defaults[name]) + + +# vim:set shiftwidth=4 tabstop=8 expandtab textwidth=78: diff --git a/src/drivers/Socket.py b/src/drivers/Socket.py new file mode 100644 index 000000000..2758c2b51 --- /dev/null +++ b/src/drivers/Socket.py @@ -0,0 +1,218 @@ +### +# Copyright (c) 2002-2004, Jeremiah Fincher +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND 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 COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +### + +""" +Contains simple socket drivers. Asyncore bugged (haha, pun!) me. +""" + +from __future__ import division + + + +import supybot.fix as fix + +import time +import select +import socket +from itertools import imap + +import supybot.log as log +import supybot.conf as conf +import supybot.utils as utils +import supybot.world as world +import supybot.drivers as drivers +import supybot.schedule as schedule + +# XXX Shouldn't the reconnect wait (at least the last one) be configurable? +reconnectWaits = [0, 60, 300] + +class SocketDriver(drivers.IrcDriver, drivers.ServersMixin): + def __init__(self, irc): + self.irc = irc + self.__parent = super(SocketDriver, self) + self.__parent.__init__(irc) + self.conn = None + self.servers = () + self.eagains = 0 + self.inbuffer = '' + self.outbuffer = '' + self.zombie = False + self.scheduled = None + self.connected = False + self.reconnectWaitsIndex = 0 + self.reconnectWaits = reconnectWaits + self.connect() + + def _getNextServer(self): + oldServer = getattr(self, 'currentServer', None) + server = self.__parent._getNextServer() + if self.currentServer != oldServer: + self.reconnectWaitsIndex = 0 + return server + + def _handleSocketError(self, e): + # (11, 'Resource temporarily unavailable') raised if connect + # hasn't finished yet. We'll keep track of how many we get. + if e.args[0] != 11 and self.eagains > 120: + drivers.log.disconnect(self.currentServer, e) + self.reconnect(wait=True) + else: + log.debug('Got EAGAIN, current count: %s.', self.eagains) + self.eagains += 1 + + def _sendIfMsgs(self): + if not self.zombie: + msgs = [self.irc.takeMsg()] + while msgs[-1] is not None: + msgs.append(self.irc.takeMsg()) + del msgs[-1] + self.outbuffer += ''.join(imap(str, msgs)) + if self.outbuffer: + try: + sent = self.conn.send(self.outbuffer) + self.outbuffer = self.outbuffer[sent:] + self.eagains = 0 + except socket.error, e: + self._handleSocketError(e) + if self.zombie and not self.outbuffer: + self._reallyDie() + + def run(self): + if not self.connected: + # We sleep here because otherwise, if we're the only driver, we'll + # spin at 100% CPU while we're disconnected. + time.sleep(conf.supybot.drivers.poll()) + return + self._sendIfMsgs() + try: + self.inbuffer += self.conn.recv(1024) + self.eagains = 0 + lines = self.inbuffer.split('\n') + self.inbuffer = lines.pop() + for line in lines: + msg = drivers.parseMsg(line) + if msg is not None: + self.irc.feedMsg(msg) + except socket.timeout: + pass + except socket.error, e: + self._handleSocketError(e) + return + if not self.irc.zombie: + self._sendIfMsgs() + + def connect(self, **kwargs): + self.reconnect(reset=False, **kwargs) + + def reconnect(self, wait=False, reset=True): + self.scheduled = False + if self.connected: + drivers.log.reconnect(self.irc.network) + self.conn.close() + self.connected = False + if reset: + drivers.log.debug('Resetting %s.', self.irc) + self.irc.reset() + else: + drivers.log.debug('Not resetting %s.', self.irc) + if wait: + self._scheduleReconnect() + return + server = self._getNextServer() + drivers.log.connect(self.currentServer) + try: + self.conn = utils.getSocket(server[0]) + vhost = conf.supybot.protocols.irc.vhost() + self.conn.bind((vhost, 0)) + except socket.error, e: + drivers.log.connectError(self.currentServer, e) + if self.reconnectWaitsIndex < len(self.reconnectWaits)-1: + self.reconnectWaitsIndex += 1 + self.reconnect(wait=True) + return + # We allow more time for the connect here, since it might take longer. + # At least 10 seconds. + self.conn.settimeout(max(10, conf.supybot.drivers.poll()*10)) + if self.reconnectWaitsIndex < len(self.reconnectWaits)-1: + self.reconnectWaitsIndex += 1 + try: + self.conn.connect(server) + self.conn.settimeout(conf.supybot.drivers.poll()) + except socket.error, e: + if e.args[0] == 115: + now = time.time() + when = now + 60 + whenS = log.timestamp(when) + drivers.log.debug('Connection in progress, scheduling ' + 'connectedness check for %s', whenS) + schedule.addEvent(self._checkAndWriteOrReconnect, when) + else: + drivers.log.connectError(self.currentServer, e) + self.reconnect(wait=True) + return + self.connected = True + self.reconnectWaitPeriodsIndex = 0 + + def _checkAndWriteOrReconnect(self): + drivers.log.debug('Checking whether we are connected.') + (_, w, _) = select.select([], [self.conn], [], 0) + if w: + drivers.log.debug('Socket is writable, it might be connected.') + self.connected = True + self.reconnectWaitPeriodsIndex = 0 + else: + drivers.log.connectError(self.currentServer, 'Timed out') + self.reconnect() + + def _scheduleReconnect(self): + when = time.time() + self.reconnectWaits[self.reconnectWaitsIndex] + if not world.dying: + drivers.log.reconnect(self.irc.network, when) + self.scheduled = schedule.addEvent(self.reconnect, when) + + def die(self): + self.zombie = True + if self.scheduled: + schedule.removeEvent(self.scheduled) + drivers.log.die(self.irc) + + def _reallyDie(self): + if self.conn is not None: + self.conn.close() + drivers.IrcDriver.die(self) + # self.irc.die() Kill off the ircs yourself, jerk! + + def name(self): + return '%s(%s)' % (self.__class__.__name__, self.irc) + + +Driver = SocketDriver + +# vim:set shiftwidth=4 tabstop=8 expandtab textwidth=78: + diff --git a/src/drivers/Twisted.py b/src/drivers/Twisted.py new file mode 100644 index 000000000..01afb3f77 --- /dev/null +++ b/src/drivers/Twisted.py @@ -0,0 +1,141 @@ +### +# Copyright (c) 2002-2004, Jeremiah Fincher +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND 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 COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +### + + + +import supybot.fix as fix + +import time + +import supybot.log as log +import supybot.conf as conf +import supybot.ircdb as ircdb +import supybot.drivers as drivers +import supybot.ircmsgs as ircmsgs + +from twisted.internet import reactor, error +from twisted.protocols.basic import LineReceiver +from twisted.internet.protocol import ReconnectingClientFactory + +class TwistedRunnerDriver(drivers.IrcDriver): + def name(self): + return self.__class__.__name__ + + def run(self): + try: + reactor.iterate(conf.supybot.drivers.poll()) + except: + drivers.log.exception('Uncaught exception outside reactor:') + +class SupyIrcProtocol(LineReceiver): + delimiter = '\n' + MAX_LENGTH = 1024 + def __init__(self): + self.mostRecentCall = reactor.callLater(1, self.checkIrcForMsgs) + + def lineReceived(self, line): + start = time.time() + msg = drivers.parseMsg(line) + if msg is not None: + self.irc.feedMsg(msg) + + def checkIrcForMsgs(self): + if self.connected: + msg = self.irc.takeMsg() + if msg: + self.transport.write(str(msg)) + self.mostRecentCall = reactor.callLater(1, self.checkIrcForMsgs) + + def connectionLost(self, r): + self.mostRecentCall.cancel() + if r.check(error.ConnectionDone): + drivers.log.disconnect(self.factory.currentServer) + else: + drivers.log.disconnect(self.factory.currentServer, errorMsg(r)) + if self.irc.zombie: + self.factory.continueTrying = False + while self.irc.takeMsg(): + continue + else: + self.irc.reset() + + def connectionMade(self): + self.factory.resetDelay() + self.irc.driver = self + + def die(self): + drivers.log.die(self.irc) + self.factory.continueTrying = False + self.transport.loseConnection() + + def reconnect(self, wait=None): + # We ignore wait here, because we handled our own waiting. + drivers.log.reconnect(self.irc.network) + self.transport.loseConnection() + +def errorMsg(reason): + return reason.getErrorMessage() + +class SupyReconnectingFactory(ReconnectingClientFactory, drivers.ServersMixin): + # XXX Shouldn't the maxDelay be configurable? + maxDelay = 300 + protocol = SupyIrcProtocol + def __init__(self, irc): + self.irc = irc + drivers.ServersMixin.__init__(self, irc) + (server, port) = self._getNextServer() + vhost = conf.supybot.protocols.irc.vhost() + reactor.connectTCP(server, port, self, bindAddress=(vhost, 0)) + + def clientConnectionFailed(self, connector, r): + drivers.log.connectError(self.currentServer, errorMsg(r)) + (connector.host, connector.port) = self._getNextServer() + if not r.check(error.TimeoutError): + ReconnectingClientFactory.clientConnectionFailed(self, connector,r) + + def clientConnectionLost(self, connector, r): + (connector.host, connector.port) = self._getNextServer() + ReconnectingClientFactory.clientConnectionLost(self, connector, r) + + def startedConnecting(self, connector): + drivers.log.connect(self.currentServer) + + def buildProtocol(self, addr): + protocol = ReconnectingClientFactory.buildProtocol(self, addr) + protocol.irc = self.irc + return protocol + + +Driver = SupyReconnectingFactory + +try: + ignore(poller) +except NameError: + poller = TwistedRunnerDriver() +# vim:set shiftwidth=4 tabstop=8 expandtab textwidth=78: diff --git a/src/drivers/__init__.py b/src/drivers/__init__.py new file mode 100644 index 000000000..be17cf68f --- /dev/null +++ b/src/drivers/__init__.py @@ -0,0 +1,220 @@ +### +# Copyright (c) 2002-2004, Jeremiah Fincher +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND 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 COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +### + +""" +Contains various drivers (network, file, and otherwise) for using IRC objects. +""" + + + +import supybot.fix as fix + +import sys +import time +import socket + +import supybot.log as supylog +import supybot.conf as conf +import supybot.utils as utils +import supybot.ircmsgs as ircmsgs + +_drivers = {} +_deadDrivers = [] +_newDrivers = [] + +class IrcDriver(object): + """Base class for drivers.""" + def __init__(self, *args, **kwargs): + add(self.name(), self) + super(IrcDriver, self).__init__(*args, **kwargs) + + def run(self): + raise NotImplementedError + + def die(self): + # The end of any overrided die method should be + # "super(Class, self).die()", in order to make + # sure this (and anything else later added) is done. + remove(self.name()) + + def reconnect(self, wait=False): + raise NotImplementedError + + def name(self): + return repr(self) + +class ServersMixin(object): + def __init__(self, irc, servers=()): + self.networkGroup = conf.supybot.networks.get(irc.network) + self.servers = servers + super(ServersMixin, self).__init__(irc) + + def _getServers(self): + # We do this, rather than itertools.cycle the servers in __init__, + # because otherwise registry updates given as setValues or sets + # wouldn't be visible until a restart. + return self.networkGroup.servers()[:] # Be sure to copy! + + def _getNextServer(self): + if not self.servers: + self.servers = self._getServers() + assert self.servers, 'Servers value for %s is empty.' % \ + self.networkGroup._name + server = self.servers.pop(0) + self.currentServer = '%s:%s' % server + return server + + +def empty(): + """Returns whether or not the driver loop is empty.""" + return (len(_drivers) + len(_newDrivers)) == 0 + +def add(name, driver): + """Adds a given driver the loop with the given name.""" + _newDrivers.append((name, driver)) + +def remove(name): + """Removes the driver with the given name from the loop.""" + _deadDrivers.append(name) + +def run(): + """Runs the whole driver loop.""" + for (name, driver) in _drivers.iteritems(): + try: + if name not in _deadDrivers: + driver.run() + except: + log.exception('Uncaught exception in in drivers.run:') + _deadDrivers.append(name) + for name in _deadDrivers: + try: + driver = _drivers[name] + if hasattr(driver, 'irc') and driver.irc is not None: + # The Schedule driver has no irc object, or it's None. + driver.irc.driver = None + driver.irc = None + log.info('Removing driver %s.', name) + del _drivers[name] + except KeyError: + pass + while _newDrivers: + (name, driver) = _newDrivers.pop() + log.debug('Adding new driver %s.', name) + if name in _drivers: + log.warning('Driver %s already added, killing it.', name) + _drivers[name].die() + del _drivers[name] + _drivers[name] = driver + +class Log(object): + """This is used to have a nice, consistent interface for drivers to use.""" + def connect(self, server): + self.info('Connecting to %s.', server) + + def connectError(self, server, e): + if isinstance(e, Exception): + if isinstance(e, socket.gaierror): + e = e.args[1] + else: + e = utils.exnToString(e) + self.warning('Error connecting to %s: %s', server, e) + + def disconnect(self, server, e=None): + if e: + if isinstance(e, Exception): + e = utils.exnToString(e) + else: + e = str(e) + if not e.endswith('.'): + e += '.' + self.warning('Disconnect from %s: %s', server, e) + else: + self.info('Disconnect from %s.', server) + + def reconnect(self, network, when=None): + s = 'Reconnecting to %s' % network + if when is not None: + if not isinstance(when, basestring): + when = self.timestamp(when) + s += ' at %s.' % when + else: + s += '.' + self.info(s) + + def die(self, irc): + self.info('Driver for %s dying.', irc) + + debug = staticmethod(supylog.debug) + info = staticmethod(supylog.info) + warning = staticmethod(supylog.warning) + error = staticmethod(supylog.warning) + critical = staticmethod(supylog.critical) + timestamp = staticmethod(supylog.timestamp) + exception = staticmethod(supylog.exception) + stat = staticmethod(supylog.stat) + +log = Log() + +def newDriver(irc, moduleName=None): + """Returns a new driver for the given server using the irc given and using + conf.supybot.driverModule to determine what driver to pick.""" + # XXX Eventually this should be made to load the drivers from a + # configurable directory in addition to the installed one. + if moduleName is None: + moduleName = conf.supybot.drivers.module() + if moduleName == 'default': + try: + import supybot.drivers.Twisted + moduleName = 'supybot.drivers.Twisted' + except ImportError: + # We formerly used 'del' here, but 2.4 fixes the bug that we added + # the 'del' for, so we need to make sure we don't complain if the + # module is cleaned up already. + sys.modules.pop('supybot.drivers.Twisted', None) + moduleName = 'supybot.drivers.Socket' + elif not moduleName.startswith('supybot.drivers.'): + moduleName = 'supybot.drivers.' + moduleName + driverModule = __import__(moduleName, {}, {}, ['not empty']) + log.debug('Creating new driver (%s) for %s.', moduleName, irc) + driver = driverModule.Driver(irc) + irc.driver = driver + return driver + +def parseMsg(s): + start = time.time() + s = s.strip() + if s: + msg = ircmsgs.IrcMsg(s) + log.stat('Time to parse IrcMsg: %s', time.time()-start) + msg.tag('receivedAt', start) + return msg + else: + return None + +# vim:set shiftwidth=4 tabstop=8 expandtab textwidth=78: diff --git a/src/fix.py b/src/fix.py new file mode 100644 index 000000000..75d7a33a9 --- /dev/null +++ b/src/fix.py @@ -0,0 +1,239 @@ +### +# Copyright (c) 2002-2004, Jeremiah Fincher +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND 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 COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +### + +""" +Fixes stuff that Python should have but doesn't. +""" + +from __future__ import division + +__all__ = [] + +exported = ['ignore', 'window', 'group', 'partition', 'set', 'frozenset', + 'any', 'all', 'rsplit', 'dynamic'] + +import sys +import new +import atexit +import string +string.ascii = string.maketrans('', '') + +import random +_choice = random.choice +def choice(iterable): + if isinstance(iterable, (list, tuple)): + return _choice(iterable) + else: + n = 1 + m = new.module('') # Guaranteed unique value. + ret = m + for x in iterable: + if random.random() < 1/n: + ret = x + n += 1 + if ret is m: + raise IndexError + return ret +random.choice = choice + +def ignore(*args, **kwargs): + """Simply ignore the arguments sent to it.""" + pass + +class DynamicScope(object): + def _getLocals(self, name): + f = sys._getframe().f_back.f_back # _getLocals <- __[gs]etattr__ <- ... + while f: + if name in f.f_locals: + return f.f_locals + f = f.f_back + raise NameError, name + + def __getattr__(self, name): + try: + return self._getLocals(name)[name] + except (NameError, KeyError): + return None + + def __setattr__(self, name, value): + self._getLocals(name)[name] = value +dynamic = DynamicScope() + + +if sys.version_info < (2, 4, 0): + def reversed(L): + """Iterates through a sequence in reverse.""" + for i in xrange(len(L) - 1, -1, -1): + yield L[i] + exported.append('reversed') + +def window(L, size): + """Returns a sliding 'window' through the list L of size size.""" + assert not isinstance(L, int), 'Argument order swapped: window(L, size)' + if size < 1: + raise ValueError, 'size <= 0 disallowed.' + for i in xrange(len(L) - (size-1)): + yield L[i:i+size] + +import itertools +def ilen(iterable): + """Returns the length of an iterator.""" + i = 0 + for _ in iterable: + i += 1 + return i + +def trueCycle(iterable): + while 1: + yielded = False + for x in iterable: + yield x + yielded = True + if not yielded: + raise StopIteration + +itertools.trueCycle = trueCycle +itertools.ilen = ilen + +def groupby(key, iterable): + if key is None: + key = lambda x: x + it = iter(iterable) + value = it.next() # If there are no items, this takes an early exit + oldkey = key(value) + group = [value] + for value in it: + newkey = key(value) + if newkey != oldkey: + yield group + group = [] + oldkey = newkey + group.append(value) + yield group +itertools.groupby = groupby + +def group(seq, groupSize, noneFill=True): + """Groups a given sequence into sublists of length groupSize.""" + ret = [] + L = [] + i = groupSize + for elt in seq: + if i > 0: + L.append(elt) + else: + ret.append(L) + i = groupSize + L = [] + L.append(elt) + i -= 1 + if L: + if noneFill: + while len(L) < groupSize: + L.append(None) + ret.append(L) + return ret + +def partition(p, L): + """Partitions a list L based on a predicate p. Returns a (yes,no) tuple""" + no = [] + yes = [] + for elt in L: + if p(elt): + yes.append(elt) + else: + no.append(elt) + return (yes, no) + +def any(p, seq): + """Returns true if any element in seq satisfies predicate p.""" + for elt in itertools.ifilter(p, seq): + return True + else: + return False + +def all(p, seq): + """Returns true if all elements in seq satisfy predicate p.""" + for elt in itertools.ifilterfalse(p, seq): + return False + else: + return True + +def rsplit(s, sep=None, maxsplit=-1): + """Equivalent to str.split, except splitting from the right.""" + if sys.version_info < (2, 4, 0): + if sep is not None: + sep = sep[::-1] + L = s[::-1].split(sep, maxsplit) + L.reverse() + return [s[::-1] for s in L] + else: + return s.rsplit(sep, maxsplit) + +if sys.version_info < (2, 4, 0): + import operator + def itemgetter(i): + return lambda x: x[i] + + def attrgetter(attr): + return lambda x: getattr(x, attr) + operator.itemgetter = itemgetter + operator.attrgetter = attrgetter + +import csv +import cStringIO as StringIO +def join(L): + fd = StringIO.StringIO() + writer = csv.writer(fd) + writer.writerow(L) + return fd.getvalue().rstrip('\r\n') + +def split(s): + fd = StringIO.StringIO(s) + reader = csv.reader(fd) + return reader.next() +csv.join = join +csv.split = split + +import sets +set = sets.Set +frozenset = sets.ImmutableSet + +import socket +# Some socket modules don't have sslerror, so we'll just make it an error. +if not hasattr(socket, 'sslerror'): + socket.sslerror = socket.error + +g = globals() +for name in exported: + __builtins__[name] = g[name] + + + +# vim:set shiftwidth=4 tabstop=8 expandtab textwidth=78: + diff --git a/src/ircdb.py b/src/ircdb.py new file mode 100644 index 000000000..3e62e00ac --- /dev/null +++ b/src/ircdb.py @@ -0,0 +1,1105 @@ +### +# Copyright (c) 2002-2004, Jeremiah Fincher +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND 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 COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +### + + + +from __future__ import division + +import supybot.fix as fix + +import os +import sets +import time +import string +import operator +from itertools import imap, ilen, ifilter + +import supybot.log as log +import supybot.conf as conf +import supybot.utils as utils +import supybot.world as world +import supybot.ircutils as ircutils +import supybot.registry as registry +import supybot.unpreserve as unpreserve + +def isCapability(capability): + return len(capability.split(None, 1)) == 1 + +def fromChannelCapability(capability): + """Returns a (channel, capability) tuple from a channel capability.""" + assert isChannelCapability(capability), 'got %s' % capability + return capability.split(',', 1) + +def isChannelCapability(capability): + """Returns True if capability is a channel capability; False otherwise.""" + if ',' in capability: + (channel, capability) = capability.split(',', 1) + return ircutils.isChannel(channel) and isCapability(capability) + else: + return False + +def makeChannelCapability(channel, capability): + """Makes a channel capability given a channel and a capability.""" + assert isCapability(capability), 'got %s' % capability + assert ircutils.isChannel(channel), 'got %s' % channel + return '%s,%s' % (channel, capability) + +def isAntiCapability(capability): + """Returns True if capability is an anticapability; False otherwise.""" + if isChannelCapability(capability): + (_, capability) = fromChannelCapability(capability) + return isCapability(capability) and capability[0] == '-' + +def makeAntiCapability(capability): + """Returns the anticapability of a given capability.""" + assert isCapability(capability), 'got %s' % capability + assert not isAntiCapability(capability), \ + 'makeAntiCapability does not work on anticapabilities. ' \ + 'You probably want invertCapability; got %s.' % capability + if isChannelCapability(capability): + (channel, capability) = fromChannelCapability(capability) + return makeChannelCapability(channel, '-' + capability) + else: + return '-' + capability + +def unAntiCapability(capability): + """Takes an anticapability and returns the non-anti form.""" + assert isCapability(capability), 'got %s' % capability + if not isAntiCapability(capability): + raise ValueError, '%s is not an anti capability' % capability + if isChannelCapability(capability): + (channel, capability) = fromChannelCapability(capability) + return ','.join((channel, capability[1:])) + else: + return capability[1:] + +def invertCapability(capability): + """Make a capability into an anticapability and vice versa.""" + assert isCapability(capability), 'got %s' % capability + if isAntiCapability(capability): + return unAntiCapability(capability) + else: + return makeAntiCapability(capability) + +def canonicalCapability(capability): + if callable(capability): + capability = capability() + assert isCapability(capability), 'got %s' % capability + return capability.lower() + +def unWildcardHostmask(hostmask): + return hostmask.translate(string.ascii, '!@*?') + +_invert = invertCapability +class CapabilitySet(sets.Set): + """A subclass of set handling basic capability stuff.""" + def __init__(self, capabilities=()): + self.__parent = super(CapabilitySet, self) + self.__parent.__init__() + for capability in capabilities: + self.add(capability) + + def add(self, capability): + """Adds a capability to the set.""" + capability = ircutils.toLower(capability) + inverted = _invert(capability) + if self.__parent.__contains__(inverted): + self.__parent.remove(inverted) + self.__parent.add(capability) + + def remove(self, capability): + """Removes a capability from the set.""" + capability = ircutils.toLower(capability) + self.__parent.remove(capability) + + def __contains__(self, capability): + capability = ircutils.toLower(capability) + if self.__parent.__contains__(capability): + return True + if self.__parent.__contains__(_invert(capability)): + return True + else: + return False + + def check(self, capability): + """Returns the appropriate boolean for whether a given capability is + 'allowed' given its (or its anticapability's) presence in the set. + """ + capability = ircutils.toLower(capability) + if self.__parent.__contains__(capability): + return True + elif self.__parent.__contains__(_invert(capability)): + return False + else: + raise KeyError, capability + + def __repr__(self): + return '%s([%s])' % (self.__class__.__name__, + ', '.join(imap(repr, self))) + +antiOwner = makeAntiCapability('owner') +class UserCapabilitySet(CapabilitySet): + """A subclass of CapabilitySet to handle the owner capability correctly.""" + def __init__(self, *args, **kwargs): + self.__parent = super(UserCapabilitySet, self) + self.__parent.__init__(*args, **kwargs) + + def __contains__(self, capability): + capability = ircutils.toLower(capability) + if capability == 'owner' or capability == antiOwner: + return True + elif self.__parent.__contains__('owner'): + return True + else: + return self.__parent.__contains__(capability) + + def check(self, capability): + """Returns the appropriate boolean for whether a given capability is + 'allowed' given its (or its anticapability's) presence in the set. + Differs from CapabilitySet in that it handles the 'owner' capability + appropriately. + """ + capability = ircutils.toLower(capability) + if capability == 'owner' or capability == antiOwner: + if self.__parent.__contains__('owner'): + return not isAntiCapability(capability) + else: + return isAntiCapability(capability) + elif self.__parent.__contains__('owner'): + if isAntiCapability(capability): + return False + else: + return True + else: + return self.__parent.check(capability) + + def add(self, capability): + """Adds a capability to the set. Just make sure it's not -owner.""" + capability = ircutils.toLower(capability) + assert capability != '-owner', '"-owner" disallowed.' + self.__parent.add(capability) + +class IrcUser(object): + """This class holds the capabilities and authentications for a user.""" + def __init__(self, ignore=False, password='', name='', + capabilities=(), hostmasks=None, secure=False, hashed=False): + self.id = None + self.auth = [] # The (time, hostmask) list of auth crap. + self.name = name # The name of the user. + self.ignore = ignore # A boolean deciding if the person is ignored. + self.secure = secure # A boolean describing if hostmasks *must* match. + self.hashed = hashed # True if the password is hashed on disk. + self.password = password # password (plaintext? hashed?) + self.capabilities = UserCapabilitySet() + for capability in capabilities: + self.capabilities.add(capability) + if hostmasks is None: + self.hostmasks = ircutils.IrcSet() # hostmasks used for recognition + else: + self.hostmasks = hostmasks + + def __repr__(self): + return '%s(id=%s, ignore=%s, password="", name=%s, hashed=%r, ' \ + 'capabilities=%r, hostmasks=[], secure=%r)\n' % \ + (self.__class__.__name__, self.id, self.ignore, + utils.quoted(self.name), self.hashed, self.capabilities, + self.secure) + + def __hash__(self): + return hash(self.id) + + def addCapability(self, capability): + """Gives the user the given capability.""" + self.capabilities.add(capability) + + def removeCapability(self, capability): + """Takes from the user the given capability.""" + self.capabilities.remove(capability) + + def _checkCapability(self, capability): + """Checks the user for a given capability.""" + if self.ignore: + if isAntiCapability(capability): + return True + else: + return False + else: + return self.capabilities.check(capability) + + def setPassword(self, password, hashed=False): + """Sets the user's password.""" + if hashed or self.hashed: + self.hashed = True + self.password = utils.saltHash(password) + else: + self.password = password + + def checkPassword(self, password): + """Checks the user's password.""" + if self.hashed: + (salt, _) = self.password.split('|') + return (self.password == utils.saltHash(password, salt=salt)) + else: + return (self.password == password) + + def checkHostmask(self, hostmask, useAuth=True): + """Checks a given hostmask against the user's hostmasks or current + authentication. If useAuth is False, only checks against the user's + hostmasks. + """ + if useAuth: + timeout = conf.supybot.databases.users.timeoutIdentification() + removals = [] + try: + for (when, authmask) in self.auth: + if timeout and when+timeout < time.time(): + removals.append((when, authmask)) + elif hostmask == authmask: + return True + finally: + while removals: + self.auth.remove(removals.pop()) + for pat in self.hostmasks: + if ircutils.hostmaskPatternEqual(pat, hostmask): + return pat + return False + + def addHostmask(self, hostmask): + """Adds a hostmask to the user's hostmasks.""" + assert ircutils.isUserHostmask(hostmask), 'got %s' % hostmask + if len(unWildcardHostmask(hostmask)) < 8: + raise ValueError, \ + 'Hostmask must contain at least 8 non-wildcard characters.' + self.hostmasks.add(hostmask) + + def removeHostmask(self, hostmask): + """Removes a hostmask from the user's hostmasks.""" + self.hostmasks.remove(hostmask) + + def addAuth(self, hostmask): + """Sets a user's authenticated hostmask. This times out in 1 hour.""" + if self.checkHostmask(hostmask, useAuth=False) or not self.secure: + self.auth.append((time.time(), hostmask)) + else: + raise ValueError, 'secure flag set, unmatched hostmask' + + def clearAuth(self): + """Unsets a user's authenticated hostmask.""" + for (when, hostmask) in self.auth: + users.invalidateCache(hostmask=hostmask) + self.auth = [] + + def preserve(self, fd, indent=''): + def write(s): + fd.write(indent) + fd.write(s) + fd.write(os.linesep) + write('name %s' % self.name) + write('ignore %s' % self.ignore) + write('secure %s' % self.secure) + write('hashed %s' % self.hashed) + write('password %s' % self.password) + for capability in self.capabilities: + write('capability %s' % capability) + for hostmask in self.hostmasks: + write('hostmask %s' % hostmask) + fd.write(os.linesep) + + +class IrcChannel(object): + """This class holds the capabilities, bans, and ignores of a channel.""" + defaultOff = ('op', 'halfop', 'voice', 'protected') + def __init__(self, bans=None, silences=None, exceptions=None, ignores=None, + capabilities=None, lobotomized=False, defaultAllow=True): + self.defaultAllow = defaultAllow + self.expiredBans = [] + self.bans = bans or {} + self.ignores = ignores or {} + self.silences = silences or [] + self.exceptions = exceptions or [] + self.capabilities = capabilities or CapabilitySet() + for capability in self.defaultOff: + if capability not in self.capabilities: + self.capabilities.add(makeAntiCapability(capability)) + self.lobotomized = lobotomized + + def __repr__(self): + return '%s(bans=%r, ignores=%r, capabilities=%r, ' \ + 'lobotomized=%r, defaultAllow=%s, ' \ + 'silences=%r, exceptions=%r)\n' % \ + (self.__class__.__name__, self.bans, self.ignores, + self.capabilities, self.lobotomized, + self.defaultAllow, self.silences, self.exceptions) + + def addBan(self, hostmask, expiration=0): + """Adds a ban to the channel banlist.""" + assert ircutils.isUserHostmask(hostmask), 'got %s' % hostmask + self.bans[hostmask] = int(expiration) + + def removeBan(self, hostmask): + """Removes a ban from the channel banlist.""" + assert ircutils.isUserHostmask(hostmask), 'got %s' % hostmask + return self.bans.pop(hostmask) + + def checkBan(self, hostmask): + """Checks whether a given hostmask is banned by the channel banlist.""" + assert ircutils.isUserHostmask(hostmask), 'got %s' % hostmask + now = time.time() + for (pattern, expiration) in self.bans.items(): + if now < expiration or not expiration: + if ircutils.hostmaskPatternEqual(pattern, hostmask): + return True + else: + self.expiredBans.append((pattern, expiration)) + del self.bans[pattern] + return False + + def addIgnore(self, hostmask, expiration=0): + """Adds an ignore to the channel ignore list.""" + assert ircutils.isUserHostmask(hostmask), 'got %s' % hostmask + self.ignores[hostmask] = int(expiration) + + def removeIgnore(self, hostmask): + """Removes an ignore from the channel ignore list.""" + assert ircutils.isUserHostmask(hostmask), 'got %s' % hostmask + return self.ignores.pop(hostmask) + + def addCapability(self, capability): + """Adds a capability to the channel's default capabilities.""" + assert isCapability(capability), 'got %s' % capability + self.capabilities.add(capability) + + def removeCapability(self, capability): + """Removes a capability from the channel's default capabilities.""" + assert isCapability(capability), 'got %s' % capability + self.capabilities.remove(capability) + + def setDefaultCapability(self, b): + """Sets the default capability in the channel.""" + self.defaultAllow = b + + def _checkCapability(self, capability): + """Checks whether a certain capability is allowed by the channel.""" + assert isCapability(capability), 'got %s' % capability + if capability in self.capabilities: + return self.capabilities.check(capability) + else: + if isAntiCapability(capability): + return not self.defaultAllow + else: + return self.defaultAllow + + def checkIgnored(self, hostmask): + """Checks whether a given hostmask is to be ignored by the channel.""" + if self.lobotomized: + return True + if world.testing: + return False + assert ircutils.isUserHostmask(hostmask), 'got %s' % hostmask + if self.checkBan(hostmask): + return True + now = time.time() + for (pattern, expiration) in self.ignores.items(): + if now < expiration or not expiration: + if ircutils.hostmaskPatternEqual(pattern, hostmask): + return True + else: + del self.ignores[pattern] + # Later we may wish to keep expiredIgnores, but not now. + return False + + def preserve(self, fd, indent=''): + def write(s): + fd.write(indent) + fd.write(s) + fd.write(os.linesep) + write('lobotomized %s' % self.lobotomized) + write('defaultAllow %s' % self.defaultAllow) + for capability in self.capabilities: + write('capability ' + capability) + bans = self.bans.items() + utils.sortBy(operator.itemgetter(1), bans) + for (ban, expiration) in bans: + write('ban %s %d' % (ban, expiration)) + ignores = self.ignores.items() + utils.sortBy(operator.itemgetter(1), ignores) + for (ignore, expiration) in ignores: + write('ignore %s %d' % (ignore, expiration)) + fd.write(os.linesep) + + +class Creator(object): + def badCommand(self, command, rest, lineno): + raise ValueError, 'Invalid command on line %s: %s' % (lineno, command) + +class IrcUserCreator(Creator): + u = None + def __init__(self, users): + if self.u is None: + IrcUserCreator.u = IrcUser() + self.users = users + + def user(self, rest, lineno): + if self.u.id is not None: + raise ValueError, 'Unexpected user command on line %s.' % lineno + self.u.id = int(rest) + + def _checkId(self): + if self.u.id is None: + raise ValueError, 'Unexpected user description without user.' + + def name(self, rest, lineno): + self._checkId() + self.u.name = rest + + def ignore(self, rest, lineno): + self._checkId() + self.u.ignore = bool(eval(rest)) + + def secure(self, rest, lineno): + self._checkId() + self.u.secure = bool(eval(rest)) + + def hashed(self, rest, lineno): + self._checkId() + self.u.hashed = bool(eval(rest)) + + def password(self, rest, lineno): + self._checkId() + self.u.password = rest + + def hostmask(self, rest, lineno): + self._checkId() + self.u.hostmasks.add(rest) + + def capability(self, rest, lineno): + self._checkId() + self.u.capabilities.add(rest) + + def finish(self): + if self.u.name: + try: + self.users.setUser(self.u) + except DuplicateHostmask: + log.error('Hostmasks for %s collided with another user\'s. ' + 'Resetting hostmasks for %s.', self.u.name) + # Some might argue that this is arbitrary, and perhaps it is. + # But we've got to do *something*, so we'll show some deference + # to our lower-numbered users. + self.u.hostmasks.clear() + self.users.setUser(self.u) + IrcUserCreator.u = None + +class IrcChannelCreator(Creator): + name = None + def __init__(self, channels): + self.c = IrcChannel() + self.channels = channels + self.hadChannel = bool(self.name) + + def channel(self, rest, lineno): + if self.name is not None: + raise ValueError, 'Unexpected channel command on line %s' % lineno + IrcChannelCreator.name = rest + + def _checkId(self): + if self.name is None: + raise ValueError, 'Unexpected channel description without channel.' + + def lobotomized(self, rest, lineno): + self._checkId() + self.c.lobotomized = bool(eval(rest)) + + def defaultallow(self, rest, lineno): + self._checkId() + self.c.defaultAllow = bool(eval(rest)) + + def capability(self, rest, lineno): + self._checkId() + self.c.capabilities.add(rest) + + def ban(self, rest, lineno): + self._checkId() + (pattern, expiration) = rest + self.c.bans[pattern] = int(float(expiration)) + + def ignore(self, rest, lineno): + self._checkId() + (pattern, expiration) = rest.split() + self.c.ignores[pattern] = int(float(expiration)) + + def finish(self): + if self.hadChannel: + self.channels.setChannel(self.name, self.c) + IrcChannelCreator.name = None + + +class DuplicateHostmask(ValueError): + pass + +class UsersDictionary(utils.IterableMap): + """A simple serialized-to-file User Database.""" + def __init__(self): + self.noFlush = False + self.filename = None + self.users = {} + self.nextId = 0 + self._nameCache = {} + self._hostmaskCache = {} + + # This is separate because the Creator has to access our instance. + def open(self, filename): + self.filename = filename + reader = unpreserve.Reader(IrcUserCreator, self) + try: + self.noFlush = True + try: + reader.readFile(filename) + self.noFlush = False + self.flush() + except EnvironmentError, e: + log.error('Invalid user dictionary file, resetting to empty.') + log.error('Exact error: %s', utils.exnToString(e)) + except Exception, e: + log.exception('Exact error:') + finally: + self.noFlush = False + + def reload(self): + """Reloads the database from its file.""" + self.nextId = 0 + self.users.clear() + self._nameCache.clear() + self._hostmaskCache.clear() + if self.filename is not None: + try: + self.open(self.filename) + except EnvironmentError, e: + log.warning('UsersDictionary.reload failed: %s', e) + else: + log.error('UsersDictionary.reload called with no filename.') + + def flush(self): + """Flushes the database to its file.""" + if not self.noFlush: + if self.filename is not None: + L = self.users.items() + L.sort() + fd = utils.transactionalFile(self.filename) + for (id, u) in L: + fd.write('user %s' % id) + fd.write(os.linesep) + u.preserve(fd, indent=' ') + fd.close() + else: + log.error('UsersDictionary.flush called with no filename.') + else: + log.debug('Not flushing UsersDictionary becuase of noFlush.') + + def close(self): + self.flush() + if self.flush in world.flushers: + world.flushers.remove(self.flush) + self.users.clear() + + def iteritems(self): + return self.users.iteritems() + + def getUserId(self, s): + """Returns the user ID of a given name or hostmask.""" + if ircutils.isUserHostmask(s): + try: + return self._hostmaskCache[s] + except KeyError: + ids = {} + for (id, user) in self.users.iteritems(): + x = user.checkHostmask(s) + if x: + ids[id] = x + if len(ids) == 1: + id = ids.keys()[0] + self._hostmaskCache[s] = id + try: + self._hostmaskCache[id].add(s) + except KeyError: + self._hostmaskCache[id] = sets.Set([s]) + return id + elif len(ids) == 0: + raise KeyError, s + else: + log.error('Multiple matches found in user database. ' + 'Removing the offending hostmasks.') + for (id, hostmask) in ids.iteritems(): + log.error('Removing %s from user %s.', + utils.quoted(hostmask), id) + self.users[id].removeHostmask(hostmask) + raise DuplicateHostmask, 'Ids %r matched.' % ids + else: # Not a hostmask, must be a name. + s = s.lower() + try: + return self._nameCache[s] + except KeyError: + for (id, user) in self.users.items(): + if s == user.name.lower(): + self._nameCache[s] = id + self._nameCache[id] = s + return id + else: + raise KeyError, s + + def getUser(self, id): + """Returns a user given its id, name, or hostmask.""" + if not isinstance(id, int): + # Must be a string. Get the UserId first. + id = self.getUserId(id) + u = self.users[id] + while isinstance(u, int): + id = u + u = self.users[id] + u.id = id + return u + + def hasUser(self, id): + """Returns the database has a user given its id, name, or hostmask.""" + try: + self.getUser(id) + return True + except KeyError: + return False + + def numUsers(self): + return len(self.users) + + def invalidateCache(self, id=None, hostmask=None, name=None): + if hostmask is not None: + if hostmask in self._hostmaskCache: + id = self._hostmaskCache.pop(hostmask) + self._hostmaskCache[id].remove(hostmask) + if not self._hostmaskCache[id]: + del self._hostmaskCache[id] + if name is not None: + del self._nameCache[self._nameCache[id]] + del self._nameCache[id] + if id is not None: + if id in self._nameCache: + del self._nameCache[self._nameCache[id]] + del self._nameCache[id] + if id in self._hostmaskCache: + for hostmask in self._hostmaskCache[id]: + del self._hostmaskCache[hostmask] + del self._hostmaskCache[id] + + def setUser(self, user): + """Sets a user (given its id) to the IrcUser given it.""" + self.nextId = max(self.nextId, user.id) + try: + if self.getUserId(user.name) != user.id: + raise DuplicateHostmask, hostmask + except KeyError: + pass + for hostmask in user.hostmasks: + for (i, u) in self.iteritems(): + if i == user.id: + continue + elif u.checkHostmask(hostmask): + # We used to remove the hostmask here, but it's not + # appropriate for us both to remove the hostmask and to + # raise an exception. So instead, we'll raise an + # exception, but be nice and give the offending hostmask + # back at the same time. + raise DuplicateHostmask, hostmask + for otherHostmask in u.hostmasks: + if ircutils.hostmaskPatternEqual(hostmask, otherHostmask): + raise DuplicateHostmask, hostmask + self.invalidateCache(user.id) + self.users[user.id] = user + self.flush() + + def delUser(self, id): + """Removes a user from the database.""" + del self.users[id] + if id in self._nameCache: + del self._nameCache[self._nameCache[id]] + del self._nameCache[id] + if id in self._hostmaskCache: + for hostmask in self._hostmaskCache[id]: + del self._hostmaskCache[hostmask] + del self._hostmaskCache[id] + self.flush() + + def newUser(self): + """Allocates a new user in the database and returns it and its id.""" + user = IrcUser(hashed=True) + self.nextId += 1 + id = self.nextId + self.users[id] = user + self.flush() + user.id = id + return user + + +class ChannelsDictionary(utils.IterableMap): + def __init__(self): + self.noFlush = False + self.filename = None + self.channels = ircutils.IrcDict() + + def open(self, filename): + self.noFlush = True + try: + self.filename = filename + reader = unpreserve.Reader(IrcChannelCreator, self) + try: + reader.readFile(filename) + self.noFlush = False + self.flush() + except Exception, e: + log.error('Invalid channel database, resetting to empty.') + log.error('Exact error: %s', utils.exnToString(e)) + finally: + self.noFlush = False + + def flush(self): + """Flushes the channel database to its file.""" + if not self.noFlush: + if self.filename is not None: + fd = utils.transactionalFile(self.filename) + for (channel, c) in self.channels.iteritems(): + fd.write('channel %s' % channel) + fd.write(os.linesep) + c.preserve(fd, indent=' ') + fd.close() + else: + log.warning('ChannelsDictionary.flush without self.filename.') + else: + log.debug('Not flushing ChannelsDictionary because of noFlush.') + + def close(self): + self.flush() + if self.flush in world.flushers: + world.flushers.remove(self.flush) + self.channels.clear() + + def reload(self): + """Reloads the channel database from its file.""" + if self.filename is not None: + self.channels.clear() + try: + self.open(self.filename) + except EnvironmentError, e: + log.warning('ChannelsDictionary.reload failed: %s', e) + else: + log.warning('ChannelsDictionary.reload without self.filename.') + + def getChannel(self, channel): + """Returns an IrcChannel object for the given channel.""" + channel = channel.lower() + if channel in self.channels: + return self.channels[channel] + else: + c = IrcChannel() + self.channels[channel] = c + return c + + def setChannel(self, channel, ircChannel): + """Sets a given channel to the IrcChannel object given.""" + channel = channel.lower() + self.channels[channel] = ircChannel + self.flush() + + def iteritems(self): + return self.channels.iteritems() + + +class IgnoresDB(object): + def __init__(self): + self.filename = None + self.hostmasks = {} + + def open(self, filename): + self.filename = filename + fd = file(self.filename) + for line in utils.nonCommentNonEmptyLines(fd): + try: + line = line.rstrip('\r\n') + L = line.split() + hostmask = L.pop(0) + if L: + expiration = int(float(L.pop(0))) + else: + expiration = 0 + self.add(hostmask, expiration) + except Exception, e: + log.error('Invalid line in ignores database: %s', + utils.quoted(line)) + fd.close() + + def flush(self): + if self.filename is not None: + fd = utils.transactionalFile(self.filename) + now = time.time() + for (hostmask, expiration) in self.hostmasks.items(): + if now < expiration or not expiration: + fd.write('%s %s' % (hostmask, expiration)) + fd.write(os.linesep) + fd.close() + else: + log.warning('IgnoresDB.flush called without self.filename.') + + def close(self): + if self.flush in world.flushers: + world.flushers.remove(self.flush) + self.flush() + self.hostmasks.clear() + + def reload(self): + if self.filename is not None: + oldhostmasks = self.hostmasks.copy() + self.hostmasks.clear() + try: + self.open(self.filename) + except EnvironmentError, e: + log.warning('IgnoresDB.reload failed: %s', e) + # Let's be somewhat transactional. + self.hostmasks.update(oldhostmasks) + else: + log.warning('IgnoresDB.reload called without self.filename.') + + def checkIgnored(self, prefix): + now = time.time() + for (hostmask, expiration) in self.hostmasks.items(): + if expiration and now > expiration: + del self.hostmasks[hostmask] + else: + if ircutils.hostmaskPatternEqual(hostmask, prefix): + return True + return False + + def add(self, hostmask, expiration=0): + assert ircutils.isUserHostmask(hostmask), 'got %s' % hostmask + self.hostmasks[hostmask] = expiration + + def remove(self, hostmask): + del self.hostmasks[hostmask] + + +confDir = conf.supybot.directories.conf() +try: + userFile = os.path.join(confDir, conf.supybot.databases.users.filename()) + users = UsersDictionary() + users.open(userFile) +except EnvironmentError, e: + log.warning('Couldn\'t open user database: %s', e) + +try: + channelFile = os.path.join(confDir, + conf.supybot.databases.channels.filename()) + channels = ChannelsDictionary() + channels.open(channelFile) +except EnvironmentError, e: + log.warning('Couldn\'t open channel database: %s', e) + +try: + ignoreFile = os.path.join(confDir, + conf.supybot.databases.ignores.filename()) + ignores = IgnoresDB() + ignores.open(ignoreFile) +except EnvironmentError, e: + log.warning('Couldn\'t open ignore database: %s', e) + + +world.flushers.append(users.flush) +world.flushers.append(ignores.flush) +world.flushers.append(channels.flush) + + +### +# Useful functions for checking credentials. +### +def checkIgnored(hostmask, recipient='', users=users, channels=channels): + """checkIgnored(hostmask, recipient='') -> True/False + + Checks if the user is ignored by the recipient of the message. + """ + if ignores.checkIgnored(hostmask): + log.debug('Ignoring %s due to ignore database.', hostmask) + return True + try: + id = users.getUserId(hostmask) + user = users.getUser(id) + except KeyError: + # If there's no user... + if ircutils.isChannel(recipient): + channel = channels.getChannel(recipient) + if channel.checkIgnored(hostmask): + log.debug('Ignoring %s due to the channel ignores.', hostmask) + return True + else: + return False + else: + if conf.supybot.defaultIgnore(): + log.debug('Ignoring %s due to conf.supybot.defaultIgnore', + hostmask) + return True + else: + return False + if user._checkCapability('owner'): + # Owners shouldn't ever be ignored. + return False + elif user.ignore: + log.debug('Ignoring %s due to his IrcUser ignore flag.', hostmask) + return True + elif recipient: + if ircutils.isChannel(recipient): + channel = channels.getChannel(recipient) + if channel.checkIgnored(hostmask): + log.debug('Ignoring %s due to the channel ignores.', hostmask) + return True + else: + return False + else: + return False + else: + return False + +def _x(capability, ret): + if isAntiCapability(capability): + return not ret + else: + return ret + +def _checkCapabilityForUnknownUser(capability, users=users, channels=channels): + if isChannelCapability(capability): + (channel, capability) = fromChannelCapability(capability) + try: + c = channels.getChannel(channel) + if capability in c.capabilities: + return c._checkCapability(capability) + else: + return _x(capability, c.defaultAllow) + except KeyError: + pass + defaultCapabilities = conf.supybot.capabilities() + if capability in defaultCapabilities: + return defaultCapabilities.check(capability) + else: + return _x(capability, conf.supybot.capabilities.default()) + +def checkCapability(hostmask, capability, users=users, channels=channels): + """Checks that the user specified by name/hostmask has the capability given. + """ + if world.testing: + return _x(capability, True) + try: + u = users.getUser(hostmask) + if u.secure and not u.checkHostmask(hostmask, useAuth=False): + raise KeyError + except KeyError: + # Raised when no hostmasks match. + return _checkCapabilityForUnknownUser(capability, users=users, + channels=channels) + except ValueError, e: + # Raised when multiple hostmasks match. + log.warning('%s: %s', hostmask, e) + return _checkCapabilityForUnknownUser(capability, users=users, + channels=channels) + if capability in u.capabilities: + return u._checkCapability(capability) + else: + if isChannelCapability(capability): + (channel, capability) = fromChannelCapability(capability) + try: + chanop = makeChannelCapability(channel, 'op') + if u._checkCapability(chanop): + return _x(capability, True) + except KeyError: + pass + c = channels.getChannel(channel) + if capability in c.capabilities: + return c._checkCapability(capability) + else: + return _x(capability, c.defaultAllow) + defaultCapabilities = conf.supybot.capabilities() + if capability in defaultCapabilities: + return defaultCapabilities.check(capability) + else: + return _x(capability, conf.supybot.capabilities.default()) + + +def checkCapabilities(hostmask, capabilities, requireAll=False): + """Checks that a user has capabilities in a list. + + requireAll is the True if *all* capabilities in the list must be had, False + if *any* of the capabilities in the list must be had. + """ + for capability in capabilities: + if requireAll: + if not checkCapability(hostmask, capability): + return False + else: + if checkCapability(hostmask, capability): + return True + if requireAll: + return True + else: + return False + +### +# supybot.capabilities +### + +class DefaultCapabilities(registry.SpaceSeparatedListOfStrings): + List = CapabilitySet + # We use a keyword argument trick here to prevent eval'ing of code that + # changes allowDefaultOwner from affecting this. It's not perfect, but + # it's still an improvement, raising the bar for potential crackers. + def setValue(self, v, allowDefaultOwner=conf.allowDefaultOwner): + registry.SpaceSeparatedListOfStrings.setValue(self, v) + if '-owner' not in self.value and not allowDefaultOwner: + print '*** You must run supybot with the --allow-default-owner' + print '*** option in order to allow a default capability of owner.' + print '*** Don\'t do that, it\'s dumb.' + self.value.add('-owner') + +conf.registerGlobalValue(conf.supybot, 'capabilities', + DefaultCapabilities(['-owner', '-admin', '-trusted'], """These are the + capabilities that are given to everyone by default. If they are normal + capabilities, then the user will have to have the appropriate + anti-capability if you want to override these capabilities; if they are + anti-capabilities, then the user will have to have the actual capability + to override these capabilities. See docs/CAPABILITIES if you don't + understand why these default to what they do.""")) + +conf.registerGlobalValue(conf.supybot.capabilities, 'default', + registry.Boolean(True, """Determines whether the bot by default will allow + users to have a capability. If this is disabled, a user must explicitly + have the capability for whatever command he wishes to run.""")) + + +# vim:set shiftwidth=4 tabstop=8 expandtab textwidth=78: diff --git a/src/irclib.py b/src/irclib.py new file mode 100644 index 000000000..090326e7d --- /dev/null +++ b/src/irclib.py @@ -0,0 +1,991 @@ +### +# Copyright (c) 2002-2004 Jeremiah Fincher +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND 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 COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +### + + + +import supybot.fix as fix + +import copy +import sets +import time +import random +import operator +from itertools import imap, chain, cycle + +import supybot.log as log +import supybot.conf as conf +import supybot.utils as utils +import supybot.world as world +import supybot.ircdb as ircdb +import supybot.ircmsgs as ircmsgs +import supybot.ircutils as ircutils +from supybot.structures import queue, smallqueue, RingBuffer + +### +# The base class for a callback to be registered with an Irc object. Shows +# the required interface for callbacks -- name(), +# inFilter(irc, msg), outFilter(irc, msg), and __call__(irc, msg) [used so +# functions can be used as callbacks conceivable, and so if refactoring ever +# changes the nature of the callbacks from classes to functions, syntactical +# changes elsewhere won't be required. +### + +class IrcCommandDispatcher(object): + """Base class for classes that must dispatch on a command.""" + def dispatchCommand(self, command): + """Given a string 'command', dispatches to doCommand.""" + return getattr(self, 'do' + command.capitalize(), None) + + +class IrcCallback(IrcCommandDispatcher): + """Base class for standard callbacks. + + Callbacks derived from this class should have methods of the form + "doCommand" -- doPrivmsg, doNick, do433, etc. These will be called + on matching messages. + """ + callAfter = () + callBefore = () + __metaclass__ = log.MetaFirewall + __firewalled__ = {'die': None, + 'reset': None, + '__call__': None, + '__lt__': lambda self: 0, + 'inFilter': lambda self, irc, msg: msg, + 'outFilter': lambda self, irc, msg: msg, + 'name': lambda self: self.__class__.__name__,} + + def __repr__(self): + return '<%s %s>' % (self.__class__.__name__, self.name()) + + def name(self): + """Returns the name of the callback.""" + return self.__class__.__name__ + + def callPrecedence(self, irc): + """Returns a pair of (callbacks to call before me, + callbacks to call after me)""" + after = [] + before = [] + for name in self.callBefore: + cb = irc.getCallback(name) + if cb is not None: + after.append(cb) + for name in self.callAfter: + cb = irc.getCallback(name) + if cb is not None: + before.append(cb) + assert self not in after, '%s was in its own after.' % self.name() + assert self not in before, '%s was in its own before.' % self.name() + return (before, after) + + def inFilter(self, irc, msg): + """Used for filtering/modifying messages as they're entering. + + ircmsgs.IrcMsg objects are immutable, so this method is expected to + return another ircmsgs.IrcMsg object. Obviously the same IrcMsg + can be returned. + """ + return msg + + def outFilter(self, irc, msg): + """Used for filtering/modifying messages as they're leaving. + + As with inFilter, an IrcMsg is returned. + """ + return msg + + def __call__(self, irc, msg): + """Used for handling each message.""" + method = self.dispatchCommand(msg.command) + if method is not None: + method(irc, msg) + + def reset(self): + """Resets the callback. Called when reconnecting to the server.""" + pass + + def die(self): + """Makes the callback die. Called when the parent Irc object dies.""" + pass + +### +# Basic queue for IRC messages. It doesn't presently (but should at some +# later point) reorder messages based on priority or penalty calculations. +### +_high = sets.ImmutableSet(['MODE', 'KICK', 'PONG', 'NICK', 'PASS', 'CAPAB']) +_low = sets.ImmutableSet(['PRIVMSG', 'PING', 'WHO', 'NOTICE']) +class IrcMsgQueue(object): + """Class for a queue of IrcMsgs. Eventually, it should be smart. + + Probably smarter than it is now, though it's gotten quite a bit smarter + than it originally was. A method to "score" methods, and a heapq to + maintain a priority queue of the messages would be the ideal way to do + intelligent queuing. + + As it stands, however, we simple keep track of 'high priority' messages, + 'low priority' messages, and normal messages, and just make sure to return + the 'high priority' ones before the normal ones before the 'low priority' + ones. + """ + __slots__ = ('msgs', 'highpriority', 'normal', 'lowpriority') + def __init__(self, iterable=()): + self.reset() + for msg in iterable: + self.enqueue(msg) + + def reset(self): + """Clears the queue.""" + self.highpriority = smallqueue() + self.normal = smallqueue() + self.lowpriority = smallqueue() + self.msgs = sets.Set() + + def enqueue(self, msg): + """Enqueues a given message.""" + if msg in self.msgs and \ + not conf.supybot.protocols.irc.queueDuplicateMessages(): + s = str(msg).strip() + log.info('Not adding message %s to queue, already added.', + utils.quoted(s)) + return False + else: + self.msgs.add(msg) + if msg.command in _high: + self.highpriority.enqueue(msg) + elif msg.command in _low: + self.lowpriority.enqueue(msg) + else: + self.normal.enqueue(msg) + return True + + def dequeue(self): + """Dequeues a given message.""" + msg = None + if self.highpriority: + msg = self.highpriority.dequeue() + elif self.normal: + msg = self.normal.dequeue() + elif self.lowpriority: + msg = self.lowpriority.dequeue() + if msg: + try: + self.msgs.remove(msg) + except KeyError: + s = 'Odd, dequeuing a message that\'s not in self.msgs.' + log.warning(s) + return msg + + def __nonzero__(self): + return bool(self.highpriority or self.normal or self.lowpriority) + + def __len__(self): + return sum(imap(len,[self.highpriority,self.lowpriority,self.normal])) + + def __repr__(self): + name = self.__class__.__name__ + return '%s(%r)' % (name, list(chain(self.highpriority, + self.normal, + self.lowpriority))) + __str__ = __repr__ + + +### +# Maintains the state of IRC connection -- the most recent messages, the +# status of various modes (especially ops/halfops/voices) in channels, etc. +### +class ChannelState(object): + __slots__ = ('users', 'ops', 'halfops', 'bans', + 'voices', 'topic', 'modes', 'created') + def __init__(self): + self.topic = '' + self.created = 0 + self.ops = ircutils.IrcSet() + self.bans = ircutils.IrcSet() + self.users = ircutils.IrcSet() + self.voices = ircutils.IrcSet() + self.halfops = ircutils.IrcSet() + self.modes = ircutils.IrcDict() + + def isOp(self, nick): + return nick in self.ops + def isVoice(self, nick): + return nick in self.voices + def isHalfop(self, nick): + return nick in self.halfops + + def addUser(self, user): + "Adds a given user to the ChannelState. Power prefixes are handled." + nick = user.lstrip('@%+') + if not nick: + return + while user and user[0] in '@%+': + (marker, user) = (user[0], user[1:]) + assert user, 'Looks like my caller is passing chars, not nicks.' + if marker == '@': + self.ops.add(nick) + elif marker == '%': + self.halfops.add(nick) + elif marker == '+': + self.voices.add(nick) + self.users.add(nick) + + def replaceUser(self, oldNick, newNick): + """Changes the user oldNick to newNick; used for NICK changes.""" + # Note that this doesn't have to have the sigil (@%+) that users + # have to have for addUser; it just changes the name of the user + # without changing any of his categories. + for s in (self.users, self.ops, self.halfops, self.voices): + if oldNick in s: + s.remove(oldNick) + s.add(newNick) + + def removeUser(self, user): + """Removes a given user from the channel.""" + self.users.discard(user) + self.ops.discard(user) + self.halfops.discard(user) + self.voices.discard(user) + + def setMode(self, mode, value=None): + assert mode not in 'ovhbeq' + self.modes[mode] = value + + def unsetMode(self, mode): + assert mode not in 'ovhbeq' + if mode in self.modes: + del self.modes[mode] + + def doMode(self, msg): + def getSet(c): + if c == 'o': + set = self.ops + elif c == 'v': + set = self.voices + elif c == 'h': + set = self.halfops + elif c == 'b': + set = self.bans + else: # We don't care yet, so we'll just return an empty set. + set = sets.Set() + return set + for (mode, value) in ircutils.separateModes(msg.args[1:]): + (action, modeChar) = mode + if modeChar in 'ovhbeq': # We don't handle e or q yet. + set = getSet(modeChar) + if action == '-': + set.discard(value) + elif action == '+': + set.add(value) + else: + if action == '+': + self.setMode(modeChar, value) + else: + assert action == '-' + self.unsetMode(modeChar) + + def __getstate__(self): + return [getattr(self, name) for name in self.__slots__] + + def __setstate__(self, t): + for (name, value) in zip(self.__slots__, t): + setattr(self, name, value) + + def __eq__(self, other): + ret = True + for name in self.__slots__: + ret = ret and getattr(self, name) == getattr(other, name) + return ret + + def __ne__(self, other): + # This shouldn't even be necessary, grr... + return not self == other + +class IrcState(IrcCommandDispatcher): + """Maintains state of the Irc connection. Should also become smarter. + """ + __metaclass__ = log.MetaFirewall + __firewalled__ = {'addMsg': None} + def __init__(self, history=None, supported=None, + nicksToHostmasks=None, channels=None): + if history is None: + history = RingBuffer(conf.supybot.protocols.irc.maxHistoryLength()) + if supported is None: + supported = utils.InsensitivePreservingDict() + if nicksToHostmasks is None: + nicksToHostmasks = ircutils.IrcDict() + if channels is None: + channels = ircutils.IrcDict() + self.supported = supported + self.history = history + self.channels = channels + self.nicksToHostmasks = nicksToHostmasks + + def reset(self): + """Resets the state to normal, unconnected state.""" + self.history.reset() + self.channels.clear() + self.supported.clear() + self.nicksToHostmasks.clear() + self.history.resize(conf.supybot.protocols.irc.maxHistoryLength()) + + def __reduce__(self): + return (self.__class__, (self.history, self.supported, + self.nicksToHostmasks, self.channels)) + + def __eq__(self, other): + return self.history == other.history and \ + self.channels == other.channels and \ + self.supported == other.supported and \ + self.nicksToHostmasks == other.nicksToHostmasks + + def __ne__(self, other): + return not self == other + + def copy(self): + ret = self.__class__() + ret.history = copy.deepcopy(self.history) + ret.nicksToHostmasks = copy.deepcopy(self.nicksToHostmasks) + ret.channels = copy.deepcopy(self.channels) + return ret + + def addMsg(self, irc, msg): + """Updates the state based on the irc object and the message.""" + self.history.append(msg) + if ircutils.isUserHostmask(msg.prefix) and not msg.command == 'NICK': + self.nicksToHostmasks[msg.nick] = msg.prefix + method = self.dispatchCommand(msg.command) + if method is not None: + method(irc, msg) + + def getTopic(self, channel): + """Returns the topic for a given channel.""" + return self.channels[channel].topic + + def nickToHostmask(self, nick): + """Returns the hostmask for a given nick.""" + return self.nicksToHostmasks[nick] + + _005converters = utils.InsensitivePreservingDict({ + 'modes': int, + 'keylen': int, + 'maxbans': int, + 'nicklen': int, + 'userlen': int, + 'hostlen': int, + 'kicklen': int, + 'awaylen': int, + 'silence': int, + 'topiclen': int, + 'channellen': int, + 'maxtargets': int, + 'maxnicklen': int, + 'maxchannels': int, + 'watch': int, # DynastyNet, EnterTheGame + }) + def _prefixParser(s): + if ')' in s: + (left, right) = s.split(')') + assert left[0] == '(', 'Odd PREFIX in 005: %s' % s + left = left[1:] + assert len(left) == len(right), 'Odd PREFIX in 005: %s' % s + return dict(zip(left, right)) + else: + return dict(zip('ovh', s)) + _005converters['prefix'] = _prefixParser + del _prefixParser + def do005(self, irc, msg): + for arg in msg.args[1:-1]: # 0 is nick, -1 is "are supported" + if '=' in arg: + (name, value) = arg.split('=', 1) + converter = self._005converters.get(name, lambda x: x) + try: + self.supported[name] = converter(value) + except Exception, e: + log.exception('Uncaught exception in 005 converter:') + log.error('Name: %s, Converter: %s', name, converter) + else: + self.supported[arg] = None + + def do352(self, irc, msg): + # WHO reply. + (nick, user, host) = (msg.args[5], msg.args[2], msg.args[3]) + hostmask = '%s!%s@%s' % (nick, user, host) + self.nicksToHostmasks[nick] = hostmask + + def do353(self, irc, msg): + # NAMES reply. + (_, type, channel, names) = msg.args + if channel not in self.channels: + self.channels[channel] = ChannelState() + c = self.channels[channel] + for name in names.split(): + c.addUser(name) + if type == '@': + c.modes['s'] = None + + def doJoin(self, irc, msg): + for channel in msg.args[0].split(','): + if channel in self.channels: + self.channels[channel].addUser(msg.nick) + elif msg.nick: # It must be us. + chan = ChannelState() + chan.addUser(msg.nick) + self.channels[channel] = chan + # I don't know why this assert was here. + #assert msg.nick == irc.nick, msg + + def doMode(self, irc, msg): + channel = msg.args[0] + if ircutils.isChannel(channel): # There can be user modes, as well. + try: + chan = self.channels[channel] + except KeyError: + chan = ChannelState() + self.channels[channel] = chan + chan.doMode(msg) + + def do324(self, irc, msg): + channel = msg.args[1] + chan = self.channels[channel] + for (mode, value) in ircutils.separateModes(msg.args[2:]): + modeChar = mode[1] + if mode[0] == '+' and mode[1] not in 'ovh': + chan.setMode(modeChar, value) + elif mode[0] == '-' and mode[1] not in 'ovh': + chan.unsetMode(modeChar) + + def do329(self, irc, msg): + # This is the last part of an empty mode. + channel = msg.args[1] + chan = self.channels[channel] + chan.created = int(msg.args[2]) + + def doPart(self, irc, msg): + for channel in msg.args[0].split(','): + try: + chan = self.channels[channel] + except KeyError: + continue + if ircutils.strEqual(msg.nick, irc.nick): + del self.channels[channel] + else: + chan.removeUser(msg.nick) + + def doKick(self, irc, msg): + (channel, users) = msg.args[:2] + chan = self.channels[channel] + for user in users.split(','): + chan.removeUser(user) + + def doQuit(self, irc, msg): + for channel in self.channels.itervalues(): + channel.removeUser(msg.nick) + if msg.nick in self.nicksToHostmasks: + # If we're quitting, it may not be. + del self.nicksToHostmasks[msg.nick] + + def doTopic(self, irc, msg): + if len(msg.args) == 1: + return # Empty TOPIC for information. Does not affect state. + try: + chan = self.channels[msg.args[0]] + chan.topic = msg.args[1] + except KeyError: + pass # We don't have to be in a channel to send a TOPIC. + + def do332(self, irc, msg): + chan = self.channels[msg.args[1]] + chan.topic = msg.args[2] + + def doNick(self, irc, msg): + newNick = msg.args[0] + oldNick = msg.nick + try: + if msg.user and msg.host: + # Nick messages being handed out from the bot itself won't + # have the necessary prefix to make a hostmask. + newHostmask = ircutils.joinHostmask(newNick,msg.user,msg.host) + self.nicksToHostmasks[newNick] = newHostmask + del self.nicksToHostmasks[oldNick] + except KeyError: + pass + for channel in self.channels.itervalues(): + channel.replaceUser(oldNick, newNick) + + + +### +# The basic class for handling a connection to an IRC server. Accepts +# callbacks of the IrcCallback interface. Public attributes include 'driver', +# 'queue', and 'state', in addition to the standard nick/user/ident attributes. +### +_callbacks = [] +class Irc(IrcCommandDispatcher): + """The base class for an IRC connection. + + Handles PING commands already. + """ + __metaclass__ = log.MetaFirewall + __firewalled__ = {'die': None, + 'feedMsg': None, + 'takeMsg': None,} + _nickSetters = sets.Set(['001', '002', '003', '004', '250', '251', '252', + '254', '255', '265', '266', '372', '375', '376', + '333', '353', '332', '366', '005']) + # We specifically want these callbacks to be common between all Ircs, + # that's why we don't do the normal None default with a check. + def __init__(self, network, callbacks=_callbacks): + self.zombie = False + world.ircs.append(self) + self.network = network + self.callbacks = callbacks + self.state = IrcState() + self.queue = IrcMsgQueue() + self.fastqueue = smallqueue() + self.driver = None # The driver should set this later. + self._setNonResettingVariables() + self._queueConnectMessages() + self.startedSync = ircutils.IrcDict() + + def isChannel(self, s): + """Helper function to check whether a given string is a channel on + the network this Irc object is connected to.""" + kw = {} + if 'chantypes' in self.state.supported: + kw['chantypes'] = self.state.supported['chantypes'] + if 'channellen' in self.state.supported: + kw['channellen'] = self.state.supported['channellen'] + return ircutils.isChannel(s, **kw) + + def isNick(self, s): + kw = {} + if 'nicklen' in self.state.supported: + kw['nicklen'] = self.state.supported['nicklen'] + return ircutils.isNick(s, **kw) + + # This *isn't* threadsafe! + def addCallback(self, callback): + """Adds a callback to the callbacks list.""" + self.callbacks.append(callback) + # This is the new list we're building, which will be tsorted. + cbs = [] + # The vertices are self.callbacks itself. Now we make the edges. + edges = sets.Set() + for cb in self.callbacks: + (before, after) = cb.callPrecedence(self) + assert cb not in after, 'cb was in its own after.' + assert cb not in before, 'cb was in its own before.' + for otherCb in before: + edges.add((otherCb, cb)) + for otherCb in after: + edges.add((cb, otherCb)) + def getFirsts(): + firsts = sets.Set(self.callbacks) - sets.Set(cbs) + for (before, after) in edges: + firsts.discard(after) + return firsts + firsts = getFirsts() + while firsts: + # Then we add these to our list of cbs, and remove all edges that + # originate with these cbs. + for cb in firsts: + cbs.append(cb) + edgesToRemove = [] + for edge in edges: + if edge[0] is cb: + edgesToRemove.append(edge) + for edge in edgesToRemove: + edges.remove(edge) + firsts = getFirsts() + assert len(cbs) == len(self.callbacks), \ + 'cbs: %s, self.callbacks: %s' % (cbs, self.callbacks) + self.callbacks[:] = cbs + + def getCallback(self, name): + """Gets a given callback by name.""" + name = name.lower() + for callback in self.callbacks: + if callback.name().lower() == name: + return callback + else: + return None + + def removeCallback(self, name): + """Removes a callback from the callback list.""" + name = name.lower() + def nameMatches(cb): + return cb.name().lower() == name + (bad, good) = partition(nameMatches, self.callbacks) + self.callbacks[:] = good + return bad + + def queueMsg(self, msg): + """Queues a message to be sent to the server.""" + if not self.zombie: + return self.queue.enqueue(msg) + else: + log.warning('Refusing to queue %r; %s is a zombie.', msg, self) + return False + + def sendMsg(self, msg): + """Queues a message to be sent to the server *immediately*""" + if not self.zombie: + self.fastqueue.enqueue(msg) + else: + log.warning('Refusing to send %r; %s is a zombie.', msg, self) + + def takeMsg(self): + """Called by the IrcDriver; takes a message to be sent.""" + if not self.callbacks: + log.critical('No callbacks in %s.', self) + now = time.time() + msg = None + if self.fastqueue: + msg = self.fastqueue.dequeue() + elif self.queue: + if now-self.lastTake <= conf.supybot.protocols.irc.throttleTime(): + log.debug('Irc.takeMsg throttling.') + else: + self.lastTake = now + msg = self.queue.dequeue() + elif self.afterConnect and \ + conf.supybot.protocols.irc.ping() and \ + now > self.lastping + conf.supybot.protocols.irc.ping.interval(): + if self.outstandingPing: + s = 'Ping sent at %s not replied to.' % \ + log.timestamp(self.lastping) + log.warning(s) + # Let's notify the plugins that we're reconnecting. + self.feedMsg(ircmsgs.error(s)) + self.driver.reconnect() + elif not self.zombie: + self.lastping = now + now = str(int(now)) + self.outstandingPing = True + self.queueMsg(ircmsgs.ping(now)) + if msg: + for callback in reversed(self.callbacks): + msg = callback.outFilter(self, msg) + if msg is None: + log.debug('%s.outFilter returned None.' % callback.name()) + return self.takeMsg() + world.debugFlush() + if len(str(msg)) > 512: + # Yes, this violates the contract, but at this point it doesn't + # matter. That's why we gotta go munging in private attributes + # + # I'm changing this to a log.debug to fix a possible loop in + # the LogToIrc plugin. Since users can't do anything about + # this issue, there's no fundamental reason to make it a + # warning. + log.debug('Truncating %r, message is too long.', msg) + msg._str = msg._str[:500] + '\r\n' + msg._len = len(str(msg)) + # I don't think we should do this. Why should it matter? If it's + # something important, then the server will send it back to us, + # and if it's just a privmsg/notice/etc., we don't care. + # On second thought, we need this for testing. + if world.testing: + self.state.addMsg(self, msg) + log.debug('Outgoing message: ' + str(msg).rstrip('\r\n')) + if msg.command == 'JOIN': + channels = msg.args[0].split(',') + for channel in channels: + # Let's make this more accurate. + self.startedSync[channel] = time.time() + return msg + elif self.zombie: + # We kill the driver here so it doesn't continue to try to + # take messages from us. + self.driver.die() + self._reallyDie() + else: + return None + + def feedMsg(self, msg): + """Called by the IrcDriver; feeds a message received.""" + msg.tag('receivedBy', self) + msg.tag('receivedOn', self.network) + if msg.args and self.isChannel(msg.args[0]): + channel = msg.args[0] + else: + channel = None + log.debug('Incoming message: ' + str(msg).rstrip('\r\n')) + + # Yeah, so this is odd. Some networks (oftc) seem to give us certain + # messages with our nick instead of our prefix. We'll fix that here. + if msg.prefix == self.nick: + log.debug('Got one of those odd nick-instead-of-prefix msgs.') + msg = ircmsgs.IrcMsg(prefix=self.prefix, msg=msg) + + # This catches cases where we know our own nick (from sending it to the + # server) but we don't yet know our prefix. + if msg.nick == self.nick and self.prefix != msg.prefix: + self.prefix = msg.prefix + + # This keeps our nick and server attributes updated. + if msg.command in self._nickSetters: + if msg.args[0] != self.nick: + self.nick = msg.args[0] + log.debug('Updating nick attribute to %s.', self.nick) + if msg.prefix != self.server: + self.server = msg.prefix + log.debug('Updating server attribute to %s.') + + # Dispatch to specific handlers for commands. + method = self.dispatchCommand(msg.command) + if method is not None: + method(msg) + + # Now update the IrcState object. + try: + self.state.addMsg(self, msg) + except: + log.exception('Exception in update of IrcState object:') + + # Now call the callbacks. + world.debugFlush() + for callback in self.callbacks: + try: + m = callback.inFilter(self, msg) + if not m: + log.debug('%s.inFilter returned None' % callback.name()) + return + msg = m + except: + log.exception('Uncaught exception in inFilter:') + world.debugFlush() + for callback in self.callbacks: + try: + if callback is not None: + callback(self, msg) + except: + log.exception('Uncaught exception in callback:') + world.debugFlush() + + def die(self): + """Makes the Irc object *promise* to die -- but it won't die (of its + own volition) until all its queues are clear. Isn't that cool?""" + self.zombie = True + if not self.afterConnect: + self._reallyDie() + + # This is useless because it's in world.ircs, so it won't be deleted until + # the program exits. Just figured you might want to know. + #def __del__(self): + # self._reallyDie() + + def reset(self): + """Resets the Irc object. Called when the driver reconnects.""" + self._setNonResettingVariables() + self.state.reset() + self.queue.reset() + self.fastqueue.reset() + self.startedSync.clear() + for callback in self.callbacks: + callback.reset() + self._queueConnectMessages() + + def _setNonResettingVariables(self): + # Configuration stuff. + self.nick = conf.supybot.nick() + self.user = conf.supybot.user() + self.ident = conf.supybot.ident() + self.alternateNicks = conf.supybot.nick.alternates()[:] + self.password = conf.supybot.networks.get(self.network).password() + self.prefix = '%s!%s@%s' % (self.nick, self.ident, 'unset.domain') + # The rest. + self.lastTake = 0 + self.server = 'unset' + self.afterConnect = False + self.lastping = time.time() + self.outstandingPing = False + + def _queueConnectMessages(self): + if self.zombie: + self.driver.die() + self._reallyDie() + else: + if self.password: + log.info('Sending PASS command, not logging the password.') + self.queueMsg(ircmsgs.password(self.password)) + log.debug('Queuing NICK command, nick is %s.', self.nick) + self.queueMsg(ircmsgs.nick(self.nick)) + log.debug('Queuing USER command, ident is %s, user is %s.', + self.ident, self.user) + self.queueMsg(ircmsgs.user(self.ident, self.user)) + + def _getNextNick(self): + if self.alternateNicks: + nick = self.alternateNicks.pop(0) + if '%s' in nick: + nick %= conf.supybot.nick() + return nick + else: + nick = conf.supybot.nick() + ret = nick + L = list(nick) + while len(L) <= 3: + L.append('`') + while ircutils.strEqual(ret, nick): + L[random.randrange(len(L))] = random.choice('0123456789') + ret = ''.join(L) + return ret + + def do002(self, msg): + """Logs the ircd version.""" + (beginning, version) = rsplit(msg.args[-1], maxsplit=1) + log.info('Server %s has version %s', self.server, version) + + def doPing(self, msg): + """Handles PING messages.""" + self.sendMsg(ircmsgs.pong(msg.args[0])) + + def doPong(self, msg): + """Handles PONG messages.""" + self.outstandingPing = False + + def do376(self, msg): + log.info('Got end of MOTD from %s', self.server) + self.afterConnect = True + # Let's reset nicks in case we had to use a weird one. + self.alternateNicks = conf.supybot.nick.alternates()[:] + umodes = conf.supybot.protocols.irc.umodes() + if umodes: + if umodes[0] not in '+-': + umodes = '+' + umodes + log.info('Sending user modes to %s: %s', self.network, umodes) + self.sendMsg(ircmsgs.mode(self.nick, umodes)) + do377 = do422 = do376 + + def do433(self, msg): + """Handles 'nickname already in use' messages.""" + if not self.afterConnect: + newNick = self._getNextNick() + assert newNick != self.nick + log.info('Got 433: %s is in use. Trying %s.', self.nick, newNick) + self.sendMsg(ircmsgs.nick(newNick)) + do432 = do433 # 432: Erroneous nickname. + + def doJoin(self, msg): + if msg.nick == self.nick: + channel = msg.args[0] + self.queueMsg(ircmsgs.who(channel)) # Ends with 315. + self.queueMsg(ircmsgs.mode(channel)) # Ends with 329. + self.startedSync[channel] = time.time() + + def do315(self, msg): + channel = msg.args[1] + popped = False + if channel in self.startedSync: + now = time.time() + started = self.startedSync.pop(channel) + elapsed = now - started + log.info('Join to %s on %s synced in %.2f seconds.', + channel, self.network, elapsed) + popped = True + if popped and not self.startedSync: + log.info('All channels synced on %s.', self.network) + + def doError(self, msg): + """Handles ERROR messages.""" + log.info('Error message from %s: %s', self.network, msg.args[0]) + if not self.zombie: + if msg.args[0].startswith('Closing Link'): + self.driver.reconnect() + elif 'too fast' in msg.args[0]: # Connecting too fast. + self.driver.reconnect(wait=True) + + def doNick(self, msg): + """Handles NICK messages.""" + if msg.nick == self.nick: + newNick = msg.args[0] + self.nick = newNick + (nick, user, domain) = ircutils.splitHostmask(msg.prefix) + self.prefix = ircutils.joinHostmask(self.nick, user, domain) + elif conf.supybot.followIdentificationThroughNickChanges(): + # We use elif here because this means it's someone else's nick + # change, not our own. + try: + id = ircdb.users.getUserId(msg.prefix) + u = ircdb.users.getUser(id) + except KeyError: + return + if u.auth: + (_, user, host) = ircutils.splitHostmask(msg.prefix) + newhostmask = ircutils.joinHostmask(msg.args[0], user, host) + for (i, (when, authmask)) in enumerate(u.auth[:]): + if ircutils.strEqual(msg.prefix, authmask): + log.info('Following identification for %s: %s -> %s', + u.name, authmask, newhostmask) + u.auth[i] = (u.auth[i][0], newhostmask) + ircdb.users.setUser(u) + + def _reallyDie(self): + """Makes the Irc object die. Dead.""" + log.info('Irc object for %s dying.' % self.network) + # XXX This hasattr should be removed, I'm just putting it here because + # we're so close to a release. After 0.80.0 we should remove this + # and fix whatever AttributeErrors arise in the drivers themselves. + if self.driver is not None and hasattr(self.driver, 'die'): + self.driver.die() + if self in world.ircs: + world.ircs.remove(self) + # Only kill the callbacks if we're the last Irc. + if not world.ircs: + for cb in self.callbacks: + cb.die() + # If we shared our list of callbacks, this ensures that + # cb.die() is only called once for each callback. It's + # not really necessary since we already check to make sure + # we're the only Irc object, but a little robustitude never + # hurt anybody. + log.debug('Last Irc, clearing callbacks.') + self.callbacks[:] = [] + else: + log.warning('Irc object killed twice: %s', utils.stackTrace()) + + def __hash__(self): + return id(self) + + def __eq__(self, other): + return id(self) == id(other) + + def __ne__(self, other): + return not (self == other) + + def __str__(self): + return 'Irc object for %s' % self.network + + def __repr__(self): + return '' % self.network + + +# vim:set shiftwidth=4 tabstop=8 expandtab textwidth=78: diff --git a/src/ircmsgs.py b/src/ircmsgs.py new file mode 100644 index 000000000..80541af3f --- /dev/null +++ b/src/ircmsgs.py @@ -0,0 +1,767 @@ +### +# Copyright (c) 2002-2004, Jeremiah Fincher +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND 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 COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +### + +""" +This module provides the basic IrcMsg object used throughout the bot to +represent the actual messages. It also provides several helper functions to +construct such messages in an easier way than the constructor for the IrcMsg +object (which, as you'll read later, is quite...full-featured :)) +""" + + + +import supybot.fix as fix + +import re +import time +import string + +import supybot.conf as conf +import supybot.utils as utils +import supybot.ircutils as ircutils + +### +# IrcMsg class -- used for representing IRC messages acquired from a network. +### + +class MalformedIrcMsg(ValueError): + pass + +class IrcMsg(object): + """Class to represent an IRC message. + + As usual, ignore attributes that begin with an underscore. They simply + don't exist. Instances of this class are *not* to be modified, since they + are hashable. Public attributes of this class are .prefix, .command, + .args, .nick, .user, and .host. + + The constructor for this class is pretty intricate. It's designed to take + any of three major (sets of) arguments. + + Called with no keyword arguments, it takes a single string that is a raw + IRC message (such as one taken straight from the network. + + Called with keyword arguments, it *requires* a command parameter. Args is + optional, but with most commands will be necessary. Prefix is obviously + optional, since clients aren't allowed (well, technically, they are, but + only in a completely useless way) to send prefixes to the server. + + Since this class isn't to be modified, the constructor also accepts a 'msg' + keyword argument representing a message from which to take all the + attributes not provided otherwise as keyword arguments. So, for instance, + if a programmer wanted to take a PRIVMSG he'd gotten and simply redirect it + to a different source, he could do this: + + IrcMsg(prefix='', args=(newSource, otherMsg.args[1]), msg=otherMsg) + """ + # It's too useful to be able to tag IrcMsg objects with extra, unforeseen + # data. Goodbye, __slots__. + # On second thought, let's use methods for tagging. + __slots__ = ('args', 'command', 'host', 'nick', 'prefix', 'user', + '_hash', '_str', '_repr', '_len', 'tags') + def __init__(self, s='', command='', args=(), prefix='', msg=None): + assert not (msg and s), 'IrcMsg.__init__ cannot accept both s and msg' + if not s and not command and not msg: + raise MalformedIrcMsg, 'IRC messages require a command.' + self._str = None + self._repr = None + self._hash = None + self._len = None + self.tags = {} + if s: + originalString = s + try: + if not s.endswith('\n'): + s += '\n' + self._str = s + if s[0] == ':': + self.prefix, s = s[1:].split(None, 1) + else: + self.prefix = '' + if ' :' in s: # Note the space: IPV6 addresses are bad w/o it. + s, last = s.split(' :', 1) + self.args = s.split() + self.args.append(last.rstrip('\r\n')) + else: + self.args = s.split() + self.command = self.args.pop(0) + except (IndexError, ValueError): + raise MalformedIrcMsg, repr(originalString) + else: + if msg is not None: + if prefix: + self.prefix = prefix + else: + self.prefix = msg.prefix + if command: + self.command = command + else: + self.command = msg.command + if args: + self.args = args + else: + self.args = msg.args + self.tags = msg.tags.copy() + else: + self.prefix = prefix + self.command = command + assert all(ircutils.isValidArgument, args) + self.args = args + self.args = tuple(self.args) + if isUserHostmask(self.prefix): + (self.nick,self.user,self.host)=ircutils.splitHostmask(self.prefix) + else: + (self.nick, self.user, self.host) = (self.prefix,)*3 + + def __str__(self): + if self._str is not None: + return self._str + if self.prefix: + if len(self.args) > 1: + self._str = ':%s %s %s :%s\r\n' % \ + (self.prefix, self.command, + ' '.join(self.args[:-1]), self.args[-1]) + else: + if self.args: + self._str = ':%s %s :%s\r\n' % \ + (self.prefix, self.command, self.args[0]) + else: + self._str = ':%s %s\r\n' % (self.prefix, self.command) + else: + if len(self.args) > 1: + self._str = '%s %s :%s\r\n' % \ + (self.command, + ' '.join(self.args[:-1]), self.args[-1]) + else: + if self.args: + self._str = '%s :%s\r\n' % (self.command, self.args[0]) + else: + self._str = '%s\r\n' % self.command + return self._str + + def __len__(self): + return len(str(self)) + + def __eq__(self, other): + return isinstance(other, self.__class__) and \ + hash(self) == hash(other) and \ + self.command == other.command and \ + self.prefix == other.prefix and \ + self.args == other.args + __req__ = __eq__ # I don't know exactly what this does, but it can't hurt. + + def __ne__(self, other): + return not (self == other) + __rne__ = __ne__ # Likewise as above. + + def __hash__(self): + if self._hash is not None: + return self._hash + self._hash = hash(self.command) ^ \ + hash(self.prefix) ^ \ + hash(self.args) + return self._hash + + def __repr__(self): + if self._repr is not None: + return self._repr + self._repr = 'IrcMsg(prefix=%s, command=%s, args=%r)' % \ + (utils.quoted(self.prefix), utils.quoted(self.command), + self.args) + return self._repr + + def __reduce__(self): + return (self.__class__, (str(self),)) + + def tag(self, tag, value=True): + self.tags[tag] = value + + def tagged(self, tag): + return self.tags.get(tag) # Returns None if it's not there. + + def __getattr__(self, attr): + return self.tagged(attr) + + +def isCtcp(msg): + """Returns whether or not msg is a CTCP message.""" + return msg.command in ('PRIVMSG', 'NOTICE') and \ + msg.args[1].startswith('\x01') and \ + msg.args[1].endswith('\x01') and \ + len(msg.args[1]) >= 2 + +def isAction(msg): + """A predicate returning true if the PRIVMSG in question is an ACTION""" + if isCtcp(msg): + s = msg.args[1] + payload = s[1:-1] # Chop off \x01. + command = payload.split(None, 1)[0] + return command == 'ACTION' + else: + return False + +_unactionre = re.compile(r'^\x01ACTION\s+(.*)\x01$') +def unAction(msg): + """Returns the payload (i.e., non-ACTION text) of an ACTION msg.""" + assert isAction(msg) + return _unactionre.match(msg.args[1]).group(1) + +def _escape(s): + s = s.replace('&', '&') + s = s.replace('"', '"') + s = s.replace('<', '<') + s = s.replace('>', '>') + return s + +def toXml(msg, pretty=True, includeTime=True): + assert msg.command == _escape(msg.command) + L = [] + L.append('') + if pretty: + L.append('\n') + for arg in msg.args: + if pretty: + L.append(' ') + L.append('%s' % _escape(arg)) + if pretty: + L.append('\n') + L.append('\n') + return ''.join(L) + +def prettyPrint(msg, addRecipients=False, timestampFormat=None, showNick=True): + """Provides a client-friendly string form for messages. + + IIRC, I copied BitchX's (or was it XChat's?) format for messages. + """ + def nickorprefix(): + return msg.nick or msg.prefix + def nick(): + if addRecipients: + return '%s/%s' % (msg.nick, msg.args[0]) + else: + return msg.nick + if msg.command == 'PRIVMSG': + m = _unactionre.match(msg.args[1]) + if m: + s = '* %s %s' % (nick(), m.group(1)) + else: + if not showNick: + s = '%s' % msg.args[1] + else: + s = '<%s> %s' % (nick(), msg.args[1]) + elif msg.command == 'NOTICE': + if not showNick: + s = '%s' % msg.args[1] + else: + s = '-%s- %s' % (nick(), msg.args[1]) + elif msg.command == 'JOIN': + s = '*** %s has joined %s' % (msg.nick, msg.args[0]) + elif msg.command == 'PART': + if len(msg.args) > 1: + partmsg = ' (%s)' % msg.args[1] + else: + partmsg = '' + s = '*** %s has parted %s%s' % (msg.nick, msg.args[0], partmsg) + elif msg.command == 'KICK': + if len(msg.args) > 2: + kickmsg = ' (%s)' % msg.args[1] + else: + kickmsg = '' + s = '*** %s was kicked by %s%s' % (msg.args[1], msg.nick, kickmsg) + elif msg.command == 'MODE': + s = '*** %s sets mode: %s' % (nickorprefix(), ' '.join(msg.args)) + elif msg.command == 'QUIT': + if msg.args: + quitmsg = ' (%s)' % msg.args[0] + else: + quitmsg = '' + s = '*** %s has quit IRC%s' % (msg.nick, quitmsg) + elif msg.command == 'TOPIC': + s = '*** %s changes topic to %s' % (nickorprefix(), msg.args[1]) + elif msg.command == 'NICK': + s = '*** %s is now known as %s' % (msg.nick, msg.args[0]) + at = getattr(msg, 'receivedAt', None) + if timestampFormat and at: + s = '%s %s' % (time.strftime(timestampFormat, time.localtime(at)), s) + return s + +### +# Various IrcMsg functions +### + +isNick = ircutils.isNick +isChannel = ircutils.isChannel +isUserHostmask = ircutils.isUserHostmask + +def pong(payload, prefix='', msg=None): + """Takes a payload and returns the proper PONG IrcMsg.""" + if conf.supybot.protocols.irc.strictRfc(): + assert payload, 'PONG requires a payload' + if msg and not prefix: + prefix = msg.prefix + return IrcMsg(prefix=prefix, command='PONG', args=(payload,), msg=msg) + +def ping(payload, prefix='', msg=None): + """Takes a payload and returns the proper PING IrcMsg.""" + if conf.supybot.protocols.irc.strictRfc(): + assert payload, 'PING requires a payload' + if msg and not prefix: + prefix = msg.prefix + return IrcMsg(prefix=prefix, command='PING', args=(payload,), msg=msg) + +def op(channel, nick, prefix='', msg=None): + """Returns a MODE to op nick on channel.""" + if conf.supybot.protocols.irc.strictRfc(): + assert isChannel(channel), repr(channel) + assert isNick(nick), repr(nick) + if msg and not prefix: + prefix = msg.prefix + return IrcMsg(prefix=prefix, command='MODE', + args=(channel, '+o', nick), msg=msg) + +def ops(channel, nicks, prefix='', msg=None): + """Returns a MODE to op each of nicks on channel.""" + if conf.supybot.protocols.irc.strictRfc(): + assert isChannel(channel), repr(channel) + assert nicks, 'Nicks must not be empty.' + assert all(isNick, nicks), nicks + if msg and not prefix: + prefix = msg.prefix + return IrcMsg(prefix=prefix, command='MODE', + args=(channel, '+' + ('o'*len(nicks))) + tuple(nicks), + msg=msg) + +def deop(channel, nick, prefix='', msg=None): + """Returns a MODE to deop nick on channel.""" + if conf.supybot.protocols.irc.strictRfc(): + assert isChannel(channel), repr(channel) + assert isNick(nick), repr(nick) + if msg and not prefix: + prefix = msg.prefix + return IrcMsg(prefix=prefix, command='MODE', + args=(channel, '-o', nick), msg=msg) + +def deops(channel, nicks, prefix='', msg=None): + """Returns a MODE to deop each of nicks on channel.""" + if conf.supybot.protocols.irc.strictRfc(): + assert isChannel(channel), repr(channel) + assert nicks, 'Nicks must not be empty.' + assert all(isNick, nicks), nicks + if msg and not prefix: + prefix = msg.prefix + return IrcMsg(prefix=prefix, command='MODE', msg=msg, + args=(channel, '-' + ('o'*len(nicks))) + tuple(nicks)) + +def halfop(channel, nick, prefix='', msg=None): + """Returns a MODE to halfop nick on channel.""" + if conf.supybot.protocols.irc.strictRfc(): + assert isChannel(channel), repr(channel) + assert isNick(nick), repr(nick) + if msg and not prefix: + prefix = msg.prefix + return IrcMsg(prefix=prefix, command='MODE', + args=(channel, '+h', nick), msg=msg) + +def halfops(channel, nicks, prefix='', msg=None): + """Returns a MODE to halfop each of nicks on channel.""" + if conf.supybot.protocols.irc.strictRfc(): + assert isChannel(channel), repr(channel) + assert nicks, 'Nicks must not be empty.' + assert all(isNick, nicks), nicks + if msg and not prefix: + prefix = msg.prefix + return IrcMsg(prefix=prefix, command='MODE', msg=msg, + args=(channel, '+' + ('h'*len(nicks))) + tuple(nicks)) + +def dehalfop(channel, nick, prefix='', msg=None): + """Returns a MODE to dehalfop nick on channel.""" + if conf.supybot.protocols.irc.strictRfc(): + assert isChannel(channel), repr(channel) + assert isNick(nick), repr(nick) + if msg and not prefix: + prefix = msg.prefix + return IrcMsg(prefix=prefix, command='MODE', + args=(channel, '-h', nick), msg=msg) + +def dehalfops(channel, nicks, prefix='', msg=None): + """Returns a MODE to dehalfop each of nicks on channel.""" + if conf.supybot.protocols.irc.strictRfc(): + assert isChannel(channel), repr(channel) + assert nicks, 'Nicks must not be empty.' + assert all(isNick, nicks), nicks + if msg and not prefix: + prefix = msg.prefix + return IrcMsg(prefix=prefix, command='MODE', msg=msg, + args=(channel, '-' + ('h'*len(nicks))) + tuple(nicks)) + +def voice(channel, nick, prefix='', msg=None): + """Returns a MODE to voice nick on channel.""" + if conf.supybot.protocols.irc.strictRfc(): + assert isChannel(channel), repr(channel) + assert isNick(nick), repr(nick) + if msg and not prefix: + prefix = msg.prefix + return IrcMsg(prefix=prefix, command='MODE', + args=(channel, '+v', nick), msg=msg) + +def voices(channel, nicks, prefix='', msg=None): + """Returns a MODE to voice each of nicks on channel.""" + if conf.supybot.protocols.irc.strictRfc(): + assert isChannel(channel), repr(channel) + assert nicks, 'Nicks must not be empty.' + assert all(isNick, nicks) + if msg and not prefix: + prefix = msg.prefix + return IrcMsg(prefix=prefix, command='MODE', msg=msg, + args=(channel, '+' + ('v'*len(nicks))) + tuple(nicks)) + +def devoice(channel, nick, prefix='', msg=None): + """Returns a MODE to devoice nick on channel.""" + if conf.supybot.protocols.irc.strictRfc(): + assert isChannel(channel), repr(channel) + assert isNick(nick), repr(nick) + if msg and not prefix: + prefix = msg.prefix + return IrcMsg(prefix=prefix, command='MODE', + args=(channel, '-v', nick), msg=msg) + +def devoices(channel, nicks, prefix='', msg=None): + """Returns a MODE to devoice each of nicks on channel.""" + if conf.supybot.protocols.irc.strictRfc(): + assert isChannel(channel), repr(channel) + assert nicks, 'Nicks must not be empty.' + assert all(isNick, nicks), nicks + if msg and not prefix: + prefix = msg.prefix + return IrcMsg(prefix=prefix, command='MODE', msg=msg, + args=(channel, '-' + ('v'*len(nicks))) + tuple(nicks)) + +def ban(channel, hostmask, exception='', prefix='', msg=None): + """Returns a MODE to ban nick on channel.""" + if conf.supybot.protocols.irc.strictRfc(): + assert isChannel(channel), repr(channel) + assert isUserHostmask(hostmask), repr(hostmask) + modes = [('+b', hostmask)] + if exception: + modes.append(('+e', exception)) + if msg and not prefix: + prefix = msg.prefix + return IrcMsg(prefix=prefix, command='MODE', + args=[channel] + ircutils.joinModes(modes), msg=msg) + +def bans(channel, hostmasks, exceptions=(), prefix='', msg=None): + """Returns a MODE to ban each of nicks on channel.""" + if conf.supybot.protocols.irc.strictRfc(): + assert isChannel(channel), repr(channel) + assert all(isUserHostmask, hostmasks), hostmasks + modes = [('+b', s) for s in hostmasks] + [('+e', s) for s in exceptions] + if msg and not prefix: + prefix = msg.prefix + return IrcMsg(prefix=prefix, command='MODE', + args=[channel] + ircutils.joinModes(modes), msg=msg) + +def unban(channel, hostmask, prefix='', msg=None): + """Returns a MODE to unban nick on channel.""" + if conf.supybot.protocols.irc.strictRfc(): + assert isChannel(channel), repr(channel) + assert isUserHostmask(hostmask), repr(hostmask) + if msg and not prefix: + prefix = msg.prefix + return IrcMsg(prefix=prefix, command='MODE', + args=(channel, '-b', hostmask), msg=msg) + +def unbans(channel, hostmasks, prefix='', msg=None): + """Returns a MODE to unban each of nicks on channel.""" + if conf.supybot.protocols.irc.strictRfc(): + assert isChannel(channel), repr(channel) + assert all(isUserHostmask, hostmasks), hostmasks + if msg and not prefix: + prefix = msg.prefix + return IrcMsg(prefix=prefix, command='MODE', msg=msg, + args=(channel, '-' + ('b'*len(hostmasks)), hostmasks)) + +def kick(channel, nick, s='', prefix='', msg=None): + """Returns a KICK to kick nick from channel with the message msg.""" + if conf.supybot.protocols.irc.strictRfc(): + assert isChannel(channel), repr(channel) + assert isNick(nick), repr(nick) + if msg and not prefix: + prefix = msg.prefix + if s: + return IrcMsg(prefix=prefix, command='KICK', + args=(channel, nick, s), msg=msg) + else: + return IrcMsg(prefix=prefix, command='KICK', + args=(channel, nick), msg=msg) + +def kicks(channel, nicks, s='', prefix='', msg=None): + """Returns a KICK to kick each of nicks from channel with the message msg. + """ + if conf.supybot.protocols.irc.strictRfc(): + assert isChannel(channel), repr(channel) + assert all(isNick, nicks), nicks + if msg and not prefix: + prefix = msg.prefix + if s: + return IrcMsg(prefix=prefix, command='KICK', + args=(channel, ','.join(nicks), s), msg=msg) + else: + return IrcMsg(prefix=prefix, command='KICK', + args=(channel, ','.join(nicks)), msg=msg) + +def privmsg(recipient, s, prefix='', msg=None): + """Returns a PRIVMSG to recipient with the message msg.""" + if conf.supybot.protocols.irc.strictRfc(): + assert (isChannel(recipient) or isNick(recipient)), repr(recipient) + assert s, 's must not be empty.' + if msg and not prefix: + prefix = msg.prefix + return IrcMsg(prefix=prefix, command='PRIVMSG', + args=(recipient, s), msg=msg) + +def dcc(recipient, kind, *args, **kwargs): + # Stupid Python won't allow (recipient, kind, *args, prefix=''), so we have + # to use the **kwargs form. Blech. + assert isNick(recipient), 'Can\'t DCC a channel.' + kind = kind.upper() + assert kind in ('SEND', 'CHAT', 'RESUME', 'ACCEPT'), 'Invalid DCC command.' + args = (kind,) + args + return IrcMsg(prefix=kwargs.get('prefix', ''), command='PRIVMSG', + args=(recipient, ' '.join(args))) + +def action(recipient, s, prefix='', msg=None): + """Returns a PRIVMSG ACTION to recipient with the message msg.""" + if conf.supybot.protocols.irc.strictRfc(): + assert (isChannel(recipient) or isNick(recipient)), repr(recipient) + if msg and not prefix: + prefix = msg.prefix + return IrcMsg(prefix=prefix, command='PRIVMSG', + args=(recipient, '\x01ACTION %s\x01' % s), msg=msg) + +def notice(recipient, s, prefix='', msg=None): + """Returns a NOTICE to recipient with the message msg.""" + if conf.supybot.protocols.irc.strictRfc(): + assert (isChannel(recipient) or isNick(recipient)), repr(recipient) + assert s, 'msg must not be empty.' + if msg and not prefix: + prefix = msg.prefix + return IrcMsg(prefix=prefix, command='NOTICE', args=(recipient, s), msg=msg) + +def join(channel, key=None, prefix='', msg=None): + """Returns a JOIN to a channel""" + if conf.supybot.protocols.irc.strictRfc(): + assert isChannel(channel), repr(channel) + if msg and not prefix: + prefix = msg.prefix + if key is None: + return IrcMsg(prefix=prefix, command='JOIN', args=(channel,), msg=msg) + else: + if conf.supybot.protocols.irc.strictRfc(): + assert key.translate(string.ascii, string.ascii[128:]) == key and \ + '\x00' not in key and \ + '\r' not in key and \ + '\n' not in key and \ + '\f' not in key and \ + '\t' not in key and \ + '\v' not in key and \ + ' ' not in key + return IrcMsg(prefix=prefix, command='JOIN', + args=(channel, key), msg=msg) + +def joins(channels, keys=None, prefix='', msg=None): + """Returns a JOIN to each of channels.""" + if conf.supybot.protocols.irc.strictRfc(): + assert all(isChannel, channels), channels + if msg and not prefix: + prefix = msg.prefix + if keys is None: + keys = [] + assert len(keys) <= len(channels), 'Got more keys than channels.' + if not keys: + return IrcMsg(prefix=prefix, + command='JOIN', + args=(','.join(channels),), msg=msg) + else: + for key in keys: + if conf.supybot.protocols.irc.strictRfc(): + assert key.translate(string.ascii,string.ascii[128:])==key and\ + '\x00' not in key and \ + '\r' not in key and \ + '\n' not in key and \ + '\f' not in key and \ + '\t' not in key and \ + '\v' not in key and \ + ' ' not in key + return IrcMsg(prefix=prefix, + command='JOIN', + args=(','.join(channels), ','.join(keys)), msg=msg) + +def part(channel, s='', prefix='', msg=None): + """Returns a PART from channel with the message msg.""" + if conf.supybot.protocols.irc.strictRfc(): + assert isChannel(channel), repr(channel) + if msg and not prefix: + prefix = msg.prefix + if s: + return IrcMsg(prefix=prefix, command='PART', + args=(channel, s), msg=msg) + else: + return IrcMsg(prefix=prefix, command='PART', + args=(channel,), msg=msg) + +def parts(channels, s='', prefix='', msg=None): + """Returns a PART from each of channels with the message msg.""" + if conf.supybot.protocols.irc.strictRfc(): + assert all(isChannel, channels), channels + if msg and not prefix: + prefix = msg.prefix + if s: + return IrcMsg(prefix=prefix, command='PART', + args=(','.join(channels), s), msg=msg) + else: + return IrcMsg(prefix=prefix, command='PART', + args=(','.join(channels),), msg=msg) + +def quit(s='', prefix='', msg=None): + """Returns a QUIT with the message msg.""" + if msg and not prefix: + prefix = msg.prefix + if s: + return IrcMsg(prefix=prefix, command='QUIT', args=(s,), msg=msg) + else: + return IrcMsg(prefix=prefix, command='QUIT', msg=msg) + +def topic(channel, topic=None, prefix='', msg=None): + """Returns a TOPIC for channel with the topic topic.""" + if conf.supybot.protocols.irc.strictRfc(): + assert isChannel(channel), repr(channel) + if msg and not prefix: + prefix = msg.prefix + if topic is None: + return IrcMsg(prefix=prefix, command='TOPIC', + args=(channel,), msg=msg) + else: + return IrcMsg(prefix=prefix, command='TOPIC', + args=(channel, topic), msg=msg) + +def nick(nick, prefix='', msg=None): + """Returns a NICK with nick nick.""" + if conf.supybot.protocols.irc.strictRfc(): + assert isNick(nick), repr(nick) + if msg and not prefix: + prefix = msg.prefix + return IrcMsg(prefix=prefix, command='NICK', args=(nick,), msg=msg) + +def user(ident, user, prefix='', msg=None): + """Returns a USER with ident ident and user user.""" + if conf.supybot.protocols.irc.strictRfc(): + assert '\x00' not in ident and \ + '\r' not in ident and \ + '\n' not in ident and \ + ' ' not in ident and \ + '@' not in ident + if msg and not prefix: + prefix = msg.prefix + return IrcMsg(prefix=prefix, command='USER', + args=(ident, '0', '*', user), msg=msg) + +def who(hostmaskOrChannel, prefix='', msg=None): + """Returns a WHO for the hostmask or channel hostmaskOrChannel.""" + if conf.supybot.protocols.irc.strictRfc(): + assert isChannel(hostmaskOrChannel) or \ + isUserHostmask(hostmaskOrChannel), repr(hostmaskOrChannel) + if msg and not prefix: + prefix = msg.prefix + return IrcMsg(prefix=prefix, command='WHO', + args=(hostmaskOrChannel,), msg=msg) + +def whois(nick, mask='', prefix='', msg=None): + """Returns a WHOIS for nick.""" + if conf.supybot.protocols.irc.strictRfc(): + assert isNick(nick), repr(nick) + if msg and not prefix: + prefix = msg.prefix + return IrcMsg(prefix=prefix, command='WHOIS', args=(nick, mask), msg=msg) + +def names(channel=None, prefix='', msg=None): + if conf.supybot.protocols.irc.strictRfc(): + assert isChannel(channel) + if msg and not prefix: + prefix = msg.prefix + if channel is not None: + return IrcMsg(prefix=prefix, command='NAMES', args=(channel,), msg=msg) + else: + return IrcMsg(prefix=prefix, command='NAMES', msg=msg) + +def mode(channel, args=(), prefix='', msg=None): + if msg and not prefix: + prefix = msg.prefix + if isinstance(args, basestring): + args = (args,) + else: + args = tuple(map(str, args)) + return IrcMsg(prefix=prefix, command='MODE', args=(channel,)+args, msg=msg) + +def limit(channel, limit, prefix='', msg=None): + return mode(channel, ['+l', limit], prefix=prefix, msg=msg) + +def unlimit(channel, limit, prefix='', msg=None): + return mode(channel, ['-l', limit], prefix=prefix, msg=msg) + +def invite(nick, channel, prefix='', msg=None): + """Returns an INVITE for nick.""" + if conf.supybot.protocols.irc.strictRfc(): + assert isNick(nick), repr(nick) + if msg and not prefix: + prefix = msg.prefix + return IrcMsg(prefix=prefix, command='INVITE', + args=(nick, channel), msg=msg) + +def password(password, prefix='', msg=None): + """Returns a PASS command for accessing a server.""" + if conf.supybot.protocols.irc.strictRfc(): + assert password, 'password must not be empty.' + if msg and not prefix: + prefix = msg.prefix + return IrcMsg(prefix=prefix, command='PASS', args=(password,), msg=msg) + +def ison(nick, prefix='', msg=None): + if conf.supybot.protocols.irc.strictRfc(): + assert isNick(nick), repr(nick) + if msg and not prefix: + prefix = msg.prefix + return IrcMsg(prefix=prefix, command='ISON', args=(nick,), msg=msg) + +def error(s, msg=None): + return IrcMsg(command='ERROR', args=(s,), msg=msg) + +# vim:set shiftwidth=4 tabstop=8 expandtab textwidth=78: diff --git a/src/ircutils.py b/src/ircutils.py new file mode 100644 index 000000000..865f723dc --- /dev/null +++ b/src/ircutils.py @@ -0,0 +1,671 @@ +### +# Copyright (c) 2002-2004, Jeremiah Fincher +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND 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 COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +### + +""" +Provides a great number of useful utility functions IRC. Things to muck around +with hostmasks, set bold or color on strings, IRC-case-insensitive dicts, a +nick class to handle nicks (so comparisons and hashing and whatnot work in an +IRC-case-insensitive fashion), and numerous other things. +""" + + + +import supybot.fix as fix + +import re +import time +import random +import string +import textwrap +from itertools import imap, ilen +from cStringIO import StringIO as sio + +import supybot.utils as utils +import supybot.structures as structures + +def debug(s, *args): + """Prints a debug string. Most likely replaced by our logging debug.""" + print '***', s % args + +def isUserHostmask(s): + """Returns whether or not the string s is a valid User hostmask.""" + p1 = s.find('!') + p2 = s.find('@') + if p1 < p2-1 and p1 >= 1 and p2 >= 3 and len(s) > p2+1: + return True + else: + return False + +def isServerHostmask(s): + """s => bool + Returns True if s is a valid server hostmask.""" + return not isUserHostmask(s) + +def nickFromHostmask(hostmask): + """hostmask => nick + Returns the nick from a user hostmask.""" + assert isUserHostmask(hostmask) + return hostmask.split('!', 1)[0] + +def userFromHostmask(hostmask): + """hostmask => user + Returns the user from a user hostmask.""" + assert isUserHostmask(hostmask) + return hostmask.split('!', 1)[1].split('@', 1)[0] + +def hostFromHostmask(hostmask): + """hostmask => host + Returns the host from a user hostmask.""" + assert isUserHostmask(hostmask) + return hostmask.split('@', 1)[1] + +def splitHostmask(hostmask): + """hostmask => (nick, user, host) + Returns the nick, user, host of a user hostmask.""" + assert isUserHostmask(hostmask) + nick, rest = hostmask.split('!', 1) + user, host = rest.split('@', 1) + return (nick, user, host) + +def joinHostmask(nick, ident, host): + """nick, user, host => hostmask + Joins the nick, ident, host into a user hostmask.""" + assert nick and ident and host + return '%s!%s@%s' % (nick, ident, host) + +_rfc1459trans = string.maketrans(string.ascii_uppercase + r'\[]~', + string.ascii_lowercase + r'|{}^') +def toLower(s, casemapping=None): + """s => s + Returns the string s lowered according to IRC case rules.""" + if casemapping is None or casemapping == 'rfc1459': + return s.translate(_rfc1459trans) + elif casemapping == 'ascii': # freenode + return s.lower() + else: + raise ValueError, 'Invalid casemapping: %s' % utils.quoted(casemapping) + +def strEqual(nick1, nick2): + """s1, s2 => bool + Returns True if nick1 == nick2 according to IRC case rules.""" + assert isinstance(nick1, basestring) + assert isinstance(nick2, basestring) + return toLower(nick1) == toLower(nick2) + +nickEqual = strEqual + +_nickchars = r'_[]\`^{}|-' +nickRe = re.compile(r'^[A-Za-z%s][0-9A-Za-z%s]*$' + % (re.escape(_nickchars), re.escape(_nickchars))) + +def isNick(s, strictRfc=True, nicklen=None): + """s => bool + Returns True if s is a valid IRC nick.""" + if strictRfc: + ret = bool(nickRe.match(s)) + if ret and nicklen is not None: + ret = len(s) <= nicklen + return ret + else: + return not isChannel(s) and \ + not isUserHostmask(s) and \ + not ' ' in s and not '!' in s + +def isChannel(s, chantypes='#&+!', channellen=50): + """s => bool + Returns True if s is a valid IRC channel name.""" + return s and \ + ',' not in s and \ + '\x07' not in s and \ + s[0] in chantypes and \ + len(s) <= channellen and \ + len(s.split(None, 1)) == 1 + +_patternCache = {} +def _hostmaskPatternEqual(pattern, hostmask): + try: + return _patternCache[pattern](hostmask) is not None + except KeyError: + # We make our own regexps, rather than use fnmatch, because fnmatch's + # case-insensitivity is not IRC's case-insensitity. + fd = sio() + for c in pattern: + if c == '*': + fd.write('.*') + elif c == '?': + fd.write('.') + elif c in '[{': + fd.write('[[{]') + elif c in '}]': + fd.write(r'[}\]]') + elif c in '|\\': + fd.write(r'[|\\]') + elif c in '^~': + fd.write('[~^]') + else: + fd.write(re.escape(c)) + fd.write('$') + f = re.compile(fd.getvalue(), re.I).match + _patternCache[pattern] = f + return f(hostmask) is not None + +_hostmaskPatternEqualCache = {} +def hostmaskPatternEqual(pattern, hostmask): + """pattern, hostmask => bool + Returns True if hostmask matches the hostmask pattern pattern.""" + try: + return _hostmaskPatternEqualCache[(pattern, hostmask)] + except KeyError: + b = _hostmaskPatternEqual(pattern, hostmask) + _hostmaskPatternEqualCache[(pattern, hostmask)] = b + return b + +def banmask(hostmask): + """Returns a properly generic banning hostmask for a hostmask. + + >>> banmask('nick!user@host.domain.tld') + '*!*@*.domain.tld' + + >>> banmask('nick!user@10.0.0.1') + '*!*@10.0.0.*' + """ + assert isUserHostmask(hostmask) + host = hostFromHostmask(hostmask) + if utils.isIP(host): + L = host.split('.') + L[-1] = '*' + return '*!*@' + '.'.join(L) + elif utils.isIPV6(host): + L = host.split(':') + L[-1] = '*' + return '*!*@' + ':'.join(L) + else: + if '.' in host: + return '*!*@*%s' % host[host.find('.'):] + else: + return '*!*@' + host + +_plusRequireArguments = 'ovhblkqe' +_minusRequireArguments = 'ovhbkqe' +def separateModes(args): + """Separates modelines into single mode change tuples. Basically, you + should give it the .args of a MODE IrcMsg. + + Examples: + + >>> separateModes(['+ooo', 'jemfinch', 'StoneTable', 'philmes']) + [('+o', 'jemfinch'), ('+o', 'StoneTable'), ('+o', 'philmes')] + + >>> separateModes(['+o-o', 'jemfinch', 'PeterB']) + [('+o', 'jemfinch'), ('-o', 'PeterB')] + + >>> separateModes(['+s-o', 'test']) + [('+s', None), ('-o', 'test')] + + >>> separateModes(['+sntl', '100']) + [('+s', None), ('+n', None), ('+t', None), ('+l', 100)] + """ + if not args: + return [] + modes = args[0] + assert modes[0] in '+-', 'Invalid args: %r' % args + args = list(args[1:]) + ret = [] + for c in modes: + if c in '+-': + last = c + else: + if last == '+': + requireArguments = _plusRequireArguments + else: + requireArguments = _minusRequireArguments + if c in requireArguments: + arg = args.pop(0) + try: + arg = int(arg) + except ValueError: + pass + ret.append((last + c, arg)) + else: + ret.append((last + c, None)) + return ret + +def joinModes(modes): + """[(mode, targetOrNone), ...] => args + Joins modes of the same form as returned by separateModes.""" + args = [] + modeChars = [] + currentMode = '\x00' + for (mode, arg) in modes: + if arg is not None: + args.append(arg) + if not mode.startswith(currentMode): + currentMode = mode[0] + modeChars.append(mode[0]) + modeChars.append(mode[1]) + args.insert(0, ''.join(modeChars)) + return args + +def bold(s): + """Returns the string s, bolded.""" + return '\x02%s\x02' % s + +def reverse(s): + """Returns the string s, reverse-videoed.""" + return '\x16%s\x16' % s + +def underline(s): + """Returns the string s, underlined.""" + return '\x1F%s\x1F' % s + +# Definition of mircColors dictionary moved below because it became an IrcDict. +def mircColor(s, fg=None, bg=None): + """Returns s with the appropriate mIRC color codes applied.""" + if fg is None and bg is None: + return s + elif bg is None: + fg = mircColors[str(fg)] + return '\x03%s%s\x03' % (fg.zfill(2), s) + elif fg is None: + bg = mircColors[str(bg)] + return '\x03,%s%s\x03' % (bg.zfill(2), s) + else: + fg = mircColors[str(fg)] + bg = mircColors[str(bg)] + # No need to zfill fg because the comma delimits. + return '\x03%s,%s%s\x03' % (fg, bg.zfill(2), s) + +def canonicalColor(s, bg=False, shift=0): + """Assigns an (fg, bg) canonical color pair to a string based on its hash + value. This means it might change between Python versions. This pair can + be used as a *parameter to mircColor. The shift parameter is how much to + right-shift the hash value initially. + """ + h = hash(s) >> shift + fg = h % 14 + 2 # The + 2 is to rule out black and white. + if bg: + bg = (h >> 4) & 3 # The 5th, 6th, and 7th least significant bits. + if fg < 8: + bg += 8 + else: + bg += 2 + return (fg, bg) + else: + return (fg, None) + +def stripBold(s): + """Returns the string s, with bold removed.""" + return s.replace('\x02', '') + +_stripColorRe = re.compile(r'\x03(?:\d{1,2},\d{1,2}|\d{1,2}|,\d{1,2}|)') +def stripColor(s): + """Returns the string s, with color removed.""" + return _stripColorRe.sub('', s) + +def stripReverse(s): + """Returns the string s, with reverse-video removed.""" + return s.replace('\x16', '') + +def stripUnderline(s): + """Returns the string s, with underlining removed.""" + return s.replace('\x1f', '').replace('\x1F', '') + +def stripFormatting(s): + """Returns the string s, with all formatting removed.""" + # stripColor has to go first because of some strings, check the tests. + s = stripColor(s) + s = stripBold(s) + s = stripReverse(s) + s = stripUnderline(s) + return s.replace('\x0f', '').replace('\x0F', '') + +class FormatContext(object): + def __init__(self): + self.reset() + + def reset(self): + self.fg = None + self.bg = None + self.bold = False + self.reverse = False + self.underline = False + + def start(self, s): + """Given a string, starts all the formatters in this context.""" + if self.bold: + s = '\x02' + s + if self.reverse: + s = '\x16' + s + if self.underline: + s = '\x1f' + s + if self.fg is not None or self.bg is not None: + s = mircColor(s, fg=self.fg, bg=self.bg)[:-1] # Remove \x03. + return s + + def end(self, s): + """Given a string, ends all the formatters in this context.""" + if self.bold or self.reverse or \ + self.fg or self.bg or self.underline: + # Should we individually end formatters? + s += '\x0f' + return s + +class FormatParser(object): + def __init__(self, s): + self.fd = sio(s) + self.last = None + + def getChar(self): + if self.last is not None: + c = self.last + self.last = None + return c + else: + return self.fd.read(1) + + def ungetChar(self, c): + self.last = c + + def parse(self): + context = FormatContext() + c = self.getChar() + while c: + if c == '\x02': + context.bold = not context.bold + elif c == '\x16': + context.reverse = not context.reverse + elif c == '\x1f': + context.underline = not context.underline + elif c == '\x0f': + context.reset() + elif c == '\x03': + self.getColor(context) + c = self.getChar() + return context + + def getInt(self): + i = 0 + setI = False + c = self.getChar() + while c.isdigit() and i < 100: + setI = True + i *= 10 + i += int(c) + c = self.getChar() + self.ungetChar(c) + if setI: + return i + else: + return None + + def getColor(self, context): + context.fg = self.getInt() + c = self.getChar() + if c == ',': + context.bg = self.getInt() + +def wrap(s, length): + processed = [] + chunks = textwrap.wrap(s, length) + context = None + for chunk in chunks: + if context is not None: + chunk = context.start(chunk) + context = FormatParser(chunk).parse() + processed.append(context.end(chunk)) + return processed + +def isValidArgument(s): + """Returns whether s is strictly a valid argument for an IRC message.""" + return '\r' not in s and '\n' not in s and '\x00' not in s + +def safeArgument(s): + """If s is unsafe for IRC, returns a safe version.""" + if isinstance(s, unicode): + s = s.encode('utf-8') + elif not isinstance(s, basestring): + debug('Got a non-string in safeArgument: %s', utils.quoted(s)) + s = str(s) + if isValidArgument(s): + return s + else: + return repr(s) + +def replyTo(msg): + """Returns the appropriate target to send responses to msg.""" + if isChannel(msg.args[0]): + return msg.args[0] + else: + return msg.nick + +def dccIP(ip): + """Returns in IP in the proper for DCC.""" + assert utils.isIP(ip), \ + 'argument must be a string ip in xxx.yyy.zzz.www format.' + i = 0 + x = 256**3 + for quad in ip.split('.'): + i += int(quad)*x + x /= 256 + return i + +def unDccIP(i): + """Takes an integer DCC IP and return a normal string IP.""" + assert isinstance(i, (int, long)), '%r is not an number.' % i + L = [] + while len(L) < 4: + L.append(i % 256) + i /= 256 + L.reverse() + return '.'.join(imap(str, L)) + +class IrcString(str): + """This class does case-insensitive comparison and hashing of nicks.""" + def __new__(cls, s=''): + x = super(IrcString, cls).__new__(cls, s) + x.lowered = toLower(x) + return x + + def __eq__(self, s): + try: + return toLower(s) == self.lowered + except: + return False + + def __ne__(self, s): + return not (self == s) + + def __hash__(self): + return hash(self.lowered) + + +class IrcDict(utils.InsensitivePreservingDict): + """Subclass of dict to make key comparison IRC-case insensitive.""" + def key(self, s): + if s is not None: + s = toLower(s) + return s + + +class IrcSet(utils.NormalizingSet): + """A sets.Set using IrcStrings instead of regular strings.""" + def normalize(self, s): + return IrcString(s) + + def __reduce__(self): + return (self.__class__, (list(self),)) + + +class FloodQueue(object): + timeout = 0 + def __init__(self, timeout=None, queues=None): + if timeout is not None: + self.timeout = timeout + if queues is None: + queues = IrcDict() + self.queues = queues + + def __repr__(self): + return 'FloodQueue(timeout=%r, queues=%s)' % (self.timeout, + repr(self.queues)) + + def key(self, msg): + return msg.user + '@' + msg.host + + def getTimeout(self): + if callable(self.timeout): + return self.timeout() + else: + return self.timeout + + def _getQueue(self, msg, insert=True): + key = self.key(msg) + try: + return self.queues[key] + except KeyError: + if insert: + # python-- + # instancemethod.__repr__ calls the instance.__repr__, which + # means that our __repr__ calls self.queues.__repr__, which + # calls structures.TimeoutQueue.__repr__, which calls + # getTimeout.__repr__, which calls our __repr__, which calls... + getTimeout = lambda : self.getTimeout() + q = structures.TimeoutQueue(getTimeout) + self.queues[key] = q + return q + else: + return None + + def enqueue(self, msg, what=None): + if what is None: + what = msg + q = self._getQueue(msg) + q.enqueue(what) + + def len(self, msg): + q = self._getQueue(msg, insert=False) + if q is not None: + return len(q) + else: + return 0 + + def has(self, msg, what=None): + q = self._getQueue(msg, insert=False) + if q is not None: + if what is None: + what = msg + for elt in q: + if elt == what: + return True + return False + + +mircColors = IrcDict({ + 'white': '0', + 'black': '1', + 'blue': '2', + 'green': '3', + 'red': '4', + 'brown': '5', + 'purple': '6', + 'orange': '7', + 'yellow': '8', + 'light green': '9', + 'teal': '10', + 'light blue': '11', + 'dark blue': '12', + 'pink': '13', + 'dark grey': '14', + 'light grey': '15', + 'dark gray': '14', + 'light gray': '15', +}) + +# We'll map integers to their string form so mircColor is simpler. +for (k, v) in mircColors.items(): + if k is not None: # Ignore empty string for None. + sv = str(v) + mircColors[sv] = sv + +def standardSubstitute(irc, msg, text, env=None): + """Do the standard set of substitutions on text, and return it""" + if isChannel(msg.args[0]): + channel = msg.args[0] + else: + channel = 'somewhere' + def randInt(): + return str(random.randint(-1000, 1000)) + def randDate(): + t = pow(2,30)*random.random()+time.time()/4.0 + return time.ctime(t) + def randNick(): + if channel != 'somewhere': + L = list(irc.state.channels[channel].users) + if len(L) > 1: + n = msg.nick + while n == msg.nick: + n = random.choice(L) + return n + else: + return msg.nick + else: + return 'someone' + ctime = time.ctime() + localtime = time.localtime() + vars = IrcDict({ + 'who': msg.nick, + 'nick': msg.nick, + 'user': msg.user, + 'host': msg.host, + 'channel': channel, + 'botnick': irc.nick, + 'now': ctime, 'ctime': ctime, + 'randnick': randNick, 'randomnick': randNick, + 'randdate': randDate, 'randomdate': randDate, + 'rand': randInt, 'randint': randInt, 'randomint': randInt, + 'today': time.strftime('%d %b %Y', localtime), + 'year': localtime[0], + 'month': localtime[1], + 'monthname': time.strftime('%b', localtime), + 'date': localtime[2], + 'day': time.strftime('%A', localtime), + 'h': localtime[3], 'hr': localtime[3], 'hour': localtime[3], + 'm': localtime[4], 'min': localtime[4], 'minute': localtime[4], + 's': localtime[5], 'sec': localtime[5], 'second': localtime[5], + 'tz': time.tzname[time.daylight], + }) + if env is not None: + vars.update(env) + return utils.perlVariableSubstitute(vars, text) + + +if __name__ == '__main__': + import sys, doctest + doctest.testmod(sys.modules['__main__']) +# vim:set shiftwidth=4 tabstop=8 expandtab textwidth=78: diff --git a/src/log.py b/src/log.py new file mode 100644 index 000000000..aa474fcfc --- /dev/null +++ b/src/log.py @@ -0,0 +1,405 @@ +### +# Copyright (c) 2002-2004, Jeremiah Fincher +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND 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 COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +### + +import supybot.fix as fix + +import os +import sys +import time +import types +import atexit +import logging +import operator +import textwrap +import traceback + +import supybot.ansi as ansi +import supybot.conf as conf +import supybot.utils as utils +import supybot.registry as registry + +import supybot.ircutils as ircutils + +deadlyExceptions = [KeyboardInterrupt, SystemExit] + +### +# This is for testing, of course. Mostly is just disables the firewall code +# so exceptions can propagate. +### +testing = False + +VERBOSE = 1 +logging.addLevelName(VERBOSE, 'VERBOSE') + +class Formatter(logging.Formatter): + _fmtConf = staticmethod(lambda : conf.supybot.log.format()) + def formatTime(self, record, datefmt=None): + return timestamp(record.created) + + def formatException(self, (E, e, tb)): + for exn in deadlyExceptions: + if issubclass(e.__class__, exn): + raise + return logging.Formatter.formatException(self, (E, e, tb)) + + def format(self, record): + self._fmt = self._fmtConf() + return logging.Formatter.format(self, record) + + +class PluginFormatter(Formatter): + _fmtConf = staticmethod(lambda : conf.supybot.log.plugins.format()) + + +class Logger(logging.Logger): + def exception(self, *args): + (E, e, tb) = sys.exc_info() + tbinfo = traceback.extract_tb(tb) + path = '[%s]' % '|'.join(map(operator.itemgetter(2), tbinfo)) + eStrId = '%s:%s' % (E, path) + eId = hex(hash(eStrId) & 0xFFFFF) + logging.Logger.exception(self, *args) + self.error('Exception id: %s', eId) + # The traceback should be sufficient if we want it. + # self.error('Exception string: %s', eStrId) + + def verbose(self, *args, **kwargs): + self.log(VERBOSE, *args, **kwargs) + + +class StdoutStreamHandler(logging.StreamHandler): + def disable(self): + self.setLevel(sys.maxint) # Just in case. + _logger.removeHandler(self) + logging._acquireLock() + try: + del logging._handlers[self] + finally: + logging._releaseLock() + + def format(self, record): + s = logging.StreamHandler.format(self, record) + if record.levelname != 'ERROR' and conf.supybot.log.stdout.wrap(): + # We check for ERROR there because otherwise, tracebacks (which are + # already wrapped by Python itself) wrap oddly. + if not isinstance(record.levelname, basestring): + print record + print record.levelname + print utils.stackTrace() + prefixLen = len(record.levelname) + 1 # ' ' + s = textwrap.fill(s, width=78, subsequent_indent=' '*prefixLen) + s.rstrip('\r\n') + return s + + def emit(self, record): + if conf.supybot.log.stdout() and not conf.daemonized: + try: + logging.StreamHandler.emit(self, record) + except ValueError, e: # Raised if sys.stdout is closed. + self.disable() + error('Error logging to stdout. Removing stdout handler.') + exception('Uncaught exception in StdoutStreamHandler:') + + +class BetterFileHandler(logging.FileHandler): + def emit(self, record): + msg = self.format(record) + if not hasattr(types, "UnicodeType"): #if no unicode support... + self.stream.write(msg) + self.stream.write(os.linesep) + else: + try: + self.stream.write(msg) + self.stream.write(os.linesep) + except UnicodeError: + self.stream.write(msg.encode("utf8")) + self.stream.write(os.linesep) + self.flush() + + +class ColorizedFormatter(Formatter): + # This was necessary because these variables aren't defined until later. + # The staticmethod is necessary because they get treated like methods. + _fmtConf = staticmethod(lambda : conf.supybot.log.stdout.format()) + def formatException(self, (E, e, tb)): + if conf.supybot.log.stdout.colorized(): + return ''.join([ansi.RED, + Formatter.formatException(self, (E, e, tb)), + ansi.RESET]) + else: + return Formatter.formatException(self, (E, e, tb)) + + def format(self, record, *args, **kwargs): + if conf.supybot.log.stdout.colorized(): + color = '' + if record.levelno == logging.CRITICAL: + color = ansi.WHITE + ansi.BOLD + elif record.levelno == logging.ERROR: + color = ansi.RED + elif record.levelno == logging.WARNING: + color = ansi.YELLOW + if color: + return ''.join([color, + Formatter.format(self, record, *args, **kwargs), + ansi.RESET]) + else: + return Formatter.format(self, record, *args, **kwargs) + else: + return Formatter.format(self, record, *args, **kwargs) + +# These are public. +formatter = Formatter('NEVER SEEN; IF YOU SEE THIS, FILE A BUG!') +pluginFormatter = PluginFormatter('NEVER SEEN; IF YOU SEE THIS, FILE A BUG!') + +# These are not. +logging.setLoggerClass(Logger) +_logger = logging.getLogger('supybot') + +class ValidLogLevel(registry.String): + """Invalid log level.""" + minimumLevel = -1 + def set(self, s): + s = s.upper() + try: + level = logging._levelNames[s] + except KeyError: + try: + level = int(s) + except ValueError: + self.error() + if level < self.minimumLevel: + self.error() + self.setValue(level) + + def __str__(self): + # The str() is necessary here; apparently getLevelName returns an + # integer on occasion. logging-- + level = str(logging.getLevelName(self.value)) + if level.startswith('Level'): + level = level.split()[-1] + return level + +class LogLevel(ValidLogLevel): + """Invalid log level. Value must be either VERBOSE, DEBUG, INFO, WARNING, + ERROR, or CRITICAL.""" + def setValue(self, v): + ValidLogLevel.setValue(self, v) + _logger.setLevel(self.value) # _logger defined later. + +conf.registerGlobalValue(conf.supybot.directories, 'log', + conf.Directory('logs', """Determines what directory the bot will store its + logfiles in.""")) + +conf.registerGroup(conf.supybot, 'log') +conf.registerGlobalValue(conf.supybot.log, 'format', + registry.String('%(levelname)s %(asctime)s %(name)s %(message)s', + """Determines what the bot's logging format will be. The relevant + documentation on the available formattings is Python's documentation on + its logging module.""")) +conf.registerGlobalValue(conf.supybot.log, 'level', + LogLevel(logging.INFO, """Determines what the minimum priority level logged + will be. Valid values are VERBOSE, DEBUG, INFO, WARNING, ERROR, + and CRITICAL, in order of increasing priority.""")) +conf.registerGlobalValue(conf.supybot.log, 'statistics', + ValidLogLevel(-1, """Determines what level statistics reporting + is to be logged at. Mostly, this just includes, for instance, the time it + took to parse a message, process a command, etc. You probably don't care + about this.""")) +conf.registerGlobalValue(conf.supybot.log, 'timestampFormat', + registry.String('%Y-%m-%dT%H:%M:%S', """Determines the format string for + timestamps in logfiles. Refer to the Python documentation for the time + module to see what formats are accepted. If you set this variable to the + empty string, times will be logged in a simple seconds-since-epoch + format.""")) + +class BooleanRequiredFalseOnWindows(registry.Boolean): + def set(self, s): + registry.Boolean.set(self, s) + if self.value and os.name == 'nt': + raise InvalidRegistryValue, 'Value cannot be true on Windows.' + +conf.registerGlobalValue(conf.supybot.log, 'stdout', + registry.Boolean(True, """Determines whether the bot will log to + stdout.""")) +conf.registerGlobalValue(conf.supybot.log.stdout, 'colorized', + BooleanRequiredFalseOnWindows(False, """Determines whether the bot's logs + to stdout (if enabled) will be colorized with ANSI color.""")) +conf.registerGlobalValue(conf.supybot.log.stdout, 'wrap', + registry.Boolean(True, """Determines whether the bot will wrap its logs + when they're output to stdout.""")) +conf.registerGlobalValue(conf.supybot.log.stdout, 'format', + registry.String('%(levelname)s %(asctime)s %(message)s', + """Determines what the bot's logging format will be. The relevant + documentation on the available formattings is Python's documentation on + its logging module.""")) + +conf.registerGroup(conf.supybot.log, 'plugins') +conf.registerGlobalValue(conf.supybot.log.plugins, 'individualLogfiles', + registry.Boolean(False, """Determines whether the bot will separate plugin + logs into their own individual logfiles.""")) +conf.registerGlobalValue(conf.supybot.log.plugins, 'format', + registry.String('%(levelname)s %(asctime)s %(message)s', + """Determines what the bot's logging format will be. The relevant + documentation on the available formattings is Python's documentation on + its logging module.""")) + + +# These just make things easier. +debug = _logger.debug +verbose = _logger.verbose +info = _logger.info +warning = _logger.warning +error = _logger.error +critical = _logger.critical +exception = _logger.exception + +# These were just begging to be replaced. +registry.error = error +registry.exception = exception + +def stat(*args): + level = conf.supybot.log.statistics() + _logger.log(level, *args) + +setLevel = _logger.setLevel + +atexit.register(logging.shutdown) + +# ircutils will work without this, but it's useful. +ircutils.debug = debug + +def getPluginLogger(name): + if not conf.supybot.log.plugins.individualLogfiles(): + return _logger + log = logging.getLogger('supybot.plugins.%s' % name) + if not log.handlers: + filename = os.path.join(pluginLogDir, '%s.log' % name) + handler = BetterFileHandler(filename) + handler.setLevel(-1) + handler.setFormatter(pluginFormatter) + log.addHandler(handler) + if name in sys.modules: + log.info('Starting log for %s.', name) + return log + +def timestamp(when=None): + if when is None: + when = time.time() + format = conf.supybot.log.timestampFormat() + t = time.localtime(when) + if format: + return time.strftime(format, t) + else: + return str(int(time.mktime(t))) + +def firewall(f, errorHandler=None): + def logException(self, s=None): + if s is None: + s = 'Uncaught exception' + if hasattr(self, 'log'): + self.log.exception('%s:', s) + else: + exception('%s in %s.%s:', s, self.__class__.__name__, f.func_name) + def m(self, *args, **kwargs): + try: + return f(self, *args, **kwargs) + except Exception, e: + if testing: + raise + logException(self) + if errorHandler is not None: + try: + errorHandler(self, *args, **kwargs) + except Exception, e: + logException(self, 'Uncaught exception in errorHandler') + + m = utils.changeFunctionName(m, f.func_name, f.__doc__) + return m + +class MetaFirewall(type): + def __new__(cls, name, bases, dict): + firewalled = {} + for base in bases: + if hasattr(base, '__firewalled__'): + firewalled.update(base.__firewalled__) + if '__firewalled__' in dict: + firewalled.update(dict['__firewalled__']) + for attr in firewalled: + if attr in dict: + try: + errorHandler = firewalled[attr] + except: + errorHandler = None + dict[attr] = firewall(dict[attr], errorHandler) + return super(MetaFirewall, cls).__new__(cls, name, bases, dict) + #return type.__new__(cls, name, bases, dict) + + +_logDir = conf.supybot.directories.log() +if not os.path.exists(_logDir): + os.mkdir(_logDir, 0755) + +pluginLogDir = os.path.join(_logDir, 'plugins') + +if not os.path.exists(pluginLogDir): + os.mkdir(pluginLogDir, 0755) + +try: + miscLogFilename = os.path.join(_logDir, 'misc.log') + _handler = BetterFileHandler(miscLogFilename) +except EnvironmentError, e: + raise SystemExit, \ + 'Error opening miscellaneous logfile (%s). ' \ + 'Generally, this is because you are running Supybot in a directory ' \ + 'you don\'t have permissions to add files in, or you\'re running ' \ + 'Supybot as a different user than you normal do. The original ' \ + 'error was: %s' % (miscLogFilename, utils.exnToString(e)) +_handler.setFormatter(formatter) +_handler.setLevel(-1) +class PluginLogFilter(logging.Filter): + def filter(self, record): + if conf.supybot.log.plugins.individualLogfiles(): + if record.name.startswith('supybot.plugins'): + return False + return True +_handler.addFilter(PluginLogFilter()) + +_logger.addHandler(_handler) +_logger.setLevel(conf.supybot.log.level()) + +if not conf.daemonized: + _stdoutHandler = StdoutStreamHandler(sys.stdout) + _stdoutFormatter = ColorizedFormatter('IF YOU SEE THIS, FILE A BUG!') + _stdoutHandler.setFormatter(_stdoutFormatter) + _stdoutHandler.setLevel(-1) + _logger.addHandler(_stdoutHandler) + + +# vim:set shiftwidth=4 tabstop=8 expandtab textwidth=78: + diff --git a/src/privmsgs.py b/src/privmsgs.py new file mode 100644 index 000000000..7e18022c5 --- /dev/null +++ b/src/privmsgs.py @@ -0,0 +1,138 @@ +### +# Copyright (c) 2002-2004, Jeremiah Fincher +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND 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 COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +### + +""" +Includes various accessories for callbacks.Privmsg based callbacks. +""" + + + +import supybot.fix as fix + +import time +import types +import threading + +import supybot.conf as conf +import supybot.utils as utils +import supybot.world as world +import supybot.ircdb as ircdb +import supybot.ircmsgs as ircmsgs +import supybot.ircutils as ircutils +import supybot.callbacks as callbacks +import supybot.structures as structures + +# XXX Deprecated, will be removed in 0.90.0. +def getChannel(msg, args=(), raiseError=True): + """Returns the channel the msg came over or the channel given in args. + + If the channel was given in args, args is modified (the channel is + removed). + """ + if args and ircutils.isChannel(args[0]): + if conf.supybot.reply.requireChannelCommandsToBeSentInChannel(): + if args[0] != msg.args[0]: + s = 'Channel commands must be sent in the channel to which ' \ + 'they apply; if this is not the behavior you desire, ' \ + 'ask the bot\'s administrator to change the registry ' \ + 'variable ' \ + 'supybot.reply.requireChannelCommandsToBeSentInChannel ' \ + 'to False.' + raise callbacks.Error, s + return args.pop(0) + elif ircutils.isChannel(msg.args[0]): + return msg.args[0] + else: + if raiseError: + raise callbacks.Error, 'Command must be sent in a channel or ' \ + 'include a channel in its arguments.' + else: + return None + +# XXX Deprecated, will be removed in 0.90.0. +def getArgs(args, required=1, optional=0): + """Take the required/optional arguments from args. + + Always returns a list of size required + optional, filling it with however + many empty strings is necessary to fill the tuple to the right size. If + there is only one argument, a string containing that argument is returned. + + If there aren't enough args even to satisfy required, raise an error and + let the caller handle sending the help message. + """ + assert not isinstance(args, str), 'args should be a list.' + assert not isinstance(args, ircmsgs.IrcMsg), 'args should be a list.' + if len(args) < required: + raise callbacks.ArgumentError + if len(args) < required + optional: + ret = list(args) + ([''] * (required + optional - len(args))) + elif len(args) >= required + optional: + ret = list(args[:required + optional - 1]) + ret.append(' '.join(args[required + optional - 1:])) + if len(ret) == 1: + return ret[0] + else: + return ret + +# XXX Deprecated, will be removed in 0.90.0. +def checkCapability(f, capability): + """Makes sure a user has a certain capability before a command will run. + capability can be either a string or a callable object which will be called + in order to produce a string for ircdb.checkCapability.""" + def newf(self, irc, msg, args): + cap = capability + if callable(cap): + cap = cap() + if ircdb.checkCapability(msg.prefix, cap): + f(self, irc, msg, args) + else: + self.log.info('%s attempted %s without %s.', + msg.prefix, f.func_name, cap) + irc.errorNoCapability(cap) + return utils.changeFunctionName(newf, f.func_name, f.__doc__) + +class CapabilityCheckingPrivmsg(callbacks.Privmsg): + """A small subclass of callbacks.Privmsg that checks self.capability + before allowing any command to be called. + """ + capability = '' # To satisfy PyChecker + def __init__(self, *args, **kwargs): + self.__parent = super(CapabilityCheckingPrivmsg, self) + self.__parent.__init__(*args, **kwargs) + + def callCommand(self, name, irc, msg, args, *L, **kwargs): + if ircdb.checkCapability(msg.prefix, self.capability): + self.__parent.callCommand(name, irc, msg, args, *L, **kwargs) + else: + self.log.warning('%s tried to call %s without %s.', + msg.prefix, name, self.capability) + irc.errorNoCapability(self.capability) + + +# vim:set shiftwidth=4 tabstop=8 expandtab textwidth=78: diff --git a/src/questions.py b/src/questions.py new file mode 100644 index 000000000..e76234752 --- /dev/null +++ b/src/questions.py @@ -0,0 +1,140 @@ +### +# Copyright (c) 2002-2004, Jeremiah Fincher +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND 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 COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +### + +"""Handles interactive questions; useful for wizards and whatnot.""" + + + +import sys +import textwrap +from getpass import getpass as getPass + +import supybot.ansi as ansi +import supybot.utils as utils + +useBold = False + +def output(s, unformatted=True, fd=sys.stdout): + if unformatted: + s = textwrap.fill(utils.normalizeWhitespace(s), width=65) + print >>fd, s + print >>fd + +def expect(prompt, possibilities, recursed=False, default=None, + acceptEmpty=False, fd=sys.stdout): + """Prompt the user with prompt, allow them to choose from possibilities. + + If possibilities is empty, allow anything. + """ + prompt = utils.normalizeWhitespace(prompt) + originalPrompt = prompt + if recursed: + output('Sorry, that response was not an option.') + if useBold: + choices = '[%s%%s%s]' % (ansi.RESET, ansi.BOLD) + else: + choices = '[%s]' + if possibilities: + prompt = '%s %s' % (originalPrompt, choices % '/'.join(possibilities)) + if len(prompt) > 70: + prompt = '%s %s' % (originalPrompt, choices % '/ '.join(possibilities)) + if default is not None: + prompt = '%s (default: %s)' % (prompt, default) + prompt = textwrap.fill(prompt) + prompt = prompt.replace('/ ', '/') + prompt = prompt.strip() + ' ' + if useBold: + print >>fd, ansi.BOLD, + s = raw_input(prompt) + if useBold: + print >>fd, ansi.RESET + s = s.strip() + print >>fd + if possibilities: + if s in possibilities: + return s + elif not s and default is not None: + return default + elif not s and acceptEmpty: + return s + else: + return expect(originalPrompt, possibilities, recursed=True, + default=default) + else: + if not s and default is not None: + return default + return s.strip() + +def anything(prompt): + """Allow anything from the user.""" + return expect(prompt, []) + +def something(prompt, default=None): + """Allow anything *except* nothing from the user.""" + s = expect(prompt, [], default=default) + while not s: + output('Sorry, you must enter a value.') + s = expect(prompt, [], default=default) + return s + +def yn(prompt, default=None): + """Allow only 'y' or 'n' from the user.""" + if default is not None: + if default: + default = 'y' + else: + default = 'n' + s = expect(prompt, ['y', 'n'], default=default) + if s is 'y': + return True + else: + return False + +def getpass(prompt='Enter password: ', secondPrompt='Re-enter password: '): + """Prompt the user for a password.""" + password = '' + secondPassword = ' ' # Note that this should be different than password. + assert prompt + if not prompt[-1].isspace(): + prompt += ' ' + while True: + if useBold: + sys.stdout.write(ansi.BOLD) + password = getPass(prompt) + secondPassword = getPass(secondPrompt) + if useBold: + print ansi.RESET + if password != secondPassword: + output('Passwords don\'t match.') + else: + break + return password + + +# vim:set shiftwidth=4 tabstop=8 expandtab textwidth=78: diff --git a/src/registry.py b/src/registry.py new file mode 100644 index 000000000..f43978141 --- /dev/null +++ b/src/registry.py @@ -0,0 +1,623 @@ +### +# Copyright (c) 2004, Jeremiah Fincher +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND 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 COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +### + + + +import re +import os +import sets +import time +import string +import textwrap + +import supybot.fix as fix +import supybot.utils as utils + +def error(s): + """Replace me with something better from another module!""" + print '***', s + +def exception(s): + """Ditto!""" + print '***', s, 'A bad exception.' + +class RegistryException(Exception): + pass + +class InvalidRegistryFile(RegistryException): + pass + +class InvalidRegistryName(RegistryException): + pass + +class InvalidRegistryValue(RegistryException): + pass + +class NonExistentRegistryEntry(RegistryException): + pass + +_cache = utils.InsensitivePreservingDict() +_lastModified = 0 +def open(filename, clear=False): + """Initializes the module by loading the registry file into memory.""" + global _lastModified + if clear: + _cache.clear() + _fd = file(filename) + fd = utils.nonCommentNonEmptyLines(_fd) + acc = '' + for line in fd: + line = line.rstrip('\r\n') + # XXX There should be some way to determine whether or not we're + # starting a new variable or not. As it is, if there's a backslash + # at the end of every line in a variable, it won't be read, and + # worse, the error will pass silently. + if line.endswith('\\'): + acc += line[:-1] + continue + else: + acc += line + try: + (key, value) = re.split(r'(?', _cache[name] + self.set(_cache[name]) + if self._supplyDefault: + for (k, v) in _cache.iteritems(): + if k.startswith(self._name): + rest = k[len(self._name)+1:] # +1 is for . + parts = split(rest) + if len(parts) == 1 and parts[0] == name: + try: + self.__makeChild(group, v) + except InvalidRegistryValue: + # It's probably supposed to be registered later. + pass + + def register(self, name, node=None): + if not isValidRegistryName(name): + raise InvalidRegistryName, name + if node is None: + node = Group() + # We tried in any number of horrible ways to make it so that + # re-registering something would work. It doesn't, plain and simple. + # For the longest time, we had an "Is this right?" comment here, but + # from experience, we now know that it most definitely *is* right. + if name not in self._children: + self._children[name] = node + self._added.append(name) + names = split(self._name) + names.append(name) + fullname = join(names) + node.setName(fullname) + else: + # We do this so the return value from here is at least useful; + # otherwise, we're just returning a useless, unattached node + # that's simply a waste of space. + node = self._children[name] + return node + + def unregister(self, name): + try: + node = self._children[name] + del self._children[name] + # We do this because we need to remove case-insensitively. + name = name.lower() + for elt in reversed(self._added): + if elt.lower() == name: + self._added.remove(elt) + if node._name in _cache: + del _cache[node._name] + return node + except KeyError: + self.__nonExistentEntry(name) + + def rename(self, old, new): + node = self.unregister(old) + self.register(new, node) + + def getValues(self, getChildren=False, fullNames=True): + L = [] + if self._orderAlphabetically: + self._added.sort() + for name in self._added: + node = self._children[name] + if hasattr(node, 'value') or hasattr(node, 'help'): + if node.__class__ is not self.X: + L.append((node._name, node)) + if getChildren: + L.extend(node.getValues(getChildren, fullNames)) + if not fullNames: + L = [(split(s)[-1], node) for (s, node) in L] + return L + + +class Value(Group): + """Invalid registry value. If you're getting this message, report it, + because we forgot to put a proper help string here.""" + def __init__(self, default, help, setDefault=True, + showDefault=True, **kwargs): + self.__parent = super(Value, self) + self.__parent.__init__(help, **kwargs) + self._default = default + self._showDefault = showDefault + self._help = utils.normalizeWhitespace(help.strip()) + if setDefault: + self.setValue(default) + + def error(self): + if self.__doc__: + s = self.__doc__ + else: + s = """%s has no docstring. If you're getting this message, + report it, because we forgot to put a proper help string here.""" + e = InvalidRegistryValue(utils.normalizeWhitespace(s % self._name)) + e.value = self + raise e + + def setName(self, *args): + if self._name == 'unset': + self._lastModified = 0 + self.__parent.setName(*args) + self._lastModified = time.time() + + def set(self, s): + """Override this with a function to convert a string to whatever type + you want, and call self.setValue to set the value.""" + self.setValue(s) + + def setValue(self, v): + """Check conditions on the actual value type here. I.e., if you're a + IntegerLessThanOneHundred (all your values must be integers less than + 100) convert to an integer in set() and check that the integer is less + than 100 in this method. You *must* call this parent method in your + own setValue.""" + self._lastModified = time.time() + self.value = v + if self._supplyDefault: + for (name, v) in self._children.items(): + if v.__class__ is self.X: + self.unregister(name) + + def __str__(self): + return repr(self()) + + def serialize(self): + return str(self) + + # We tried many, *many* different syntactic methods here, and this one was + # simply the best -- not very intrusive, easily overridden by subclasses, + # etc. + def __call__(self): + if _lastModified > self._lastModified: + if self._name in _cache: + self.set(_cache[self._name]) + return self.value + +class Boolean(Value): + """Value must be either True or False (or On or Off).""" + def set(self, s): + try: + v = utils.toBool(s) + except ValueError: + if s.strip().lower() == 'toggle': + v = not self.value + else: + self.error() + self.setValue(v) + + def setValue(self, v): + super(Boolean, self).setValue(bool(v)) + +class Integer(Value): + """Value must be an integer.""" + def set(self, s): + try: + self.setValue(int(s)) + except ValueError: + self.error() + +class NonNegativeInteger(Integer): + """Value must be a non-negative integer.""" + def setValue(self, v): + if v < 0: + self.error() + super(NonNegativeInteger, self).setValue(v) + +class PositiveInteger(NonNegativeInteger): + """Value must be positive (non-zero) integer.""" + def setValue(self, v): + if not v: + self.error() + super(PositiveInteger, self).setValue(v) + +class Float(Value): + """Value must be a floating-point number.""" + def set(self, s): + try: + self.setValue(float(s)) + except ValueError: + self.error() + + def setValue(self, v): + try: + super(Float, self).setValue(float(v)) + except ValueError: + self.error() + +class PositiveFloat(Float): + """Value must be a floating-point number greater than zero.""" + def setValue(self, v): + if v <= 0: + self.error() + else: + super(PositiveFloat, self).setValue(v) + +class Probability(Float): + """Value must be a floating point number in the range [0, 1].""" + def __init__(self, *args, **kwargs): + self.__parent = super(Probability, self) + self.__parent.__init__(*args, **kwargs) + + def setValue(self, v): + if 0 <= v <= 1: + self.__parent.setValue(v) + else: + self.error() + +class String(Value): + """Value is not a valid Python string.""" + def set(self, s): + if not s: + s = '""' + elif s[0] != s[-1] or s[0] not in '\'"': + s = repr(s) + try: + v = utils.safeEval(s) + if not isinstance(v, basestring): + raise ValueError + self.setValue(v) + except ValueError: # This catches utils.safeEval(s) errors too. + self.error() + + _printable = string.printable[:-4] + def _needsQuoting(self, s): + return s.translate(string.ascii, self._printable) and s.strip() != s + + def __str__(self): + s = self.value + if self._needsQuoting(s): + s = repr(s) + return s + +class OnlySomeStrings(String): + validStrings = () + def __init__(self, *args, **kwargs): + assert self.validStrings, 'There must be some valid strings. ' \ + 'This is a bug.' + self.__parent = super(OnlySomeStrings, self) + self.__parent.__init__(*args, **kwargs) + self.__doc__ = 'Valid values include %s.' % \ + utils.commaAndify(map(repr, self.validStrings)) + + def help(self): + strings = [s for s in self.validStrings if s] + return '%s Valid strings: %s.' % \ + (self._help, utils.commaAndify(strings)) + + def normalize(self, s): + lowered = s.lower() + L = list(map(str.lower, self.validStrings)) + try: + i = L.index(lowered) + except ValueError: + return s # This is handled in setValue. + return self.validStrings[i] + + def setValue(self, s): + s = self.normalize(s) + if s in self.validStrings: + self.__parent.setValue(s) + else: + self.error() + +class NormalizedString(String): + def __init__(self, default, *args, **kwargs): + default = self.normalize(default) + self.__parent = super(NormalizedString, self) + self.__parent.__init__(default, *args, **kwargs) + self._showDefault = False + + def normalize(self, s): + return utils.normalizeWhitespace(s.strip()) + + def set(self, s): + s = self.normalize(s) + self.__parent.set(s) + + def setValue(self, s): + s = self.normalize(s) + self.__parent.setValue(s) + + def serialize(self): + s = str(self) + prefixLen = len(self._name) + 2 + lines = textwrap.wrap(s, width=76-prefixLen) + last = len(lines)-1 + for (i, line) in enumerate(lines): + if i != 0: + line = ' '*prefixLen + line + if i != last: + line += '\\' + lines[i] = line + ret = os.linesep.join(lines) + return ret + +class StringSurroundedBySpaces(String): + def setValue(self, v): + if v.lstrip() == v: + v= ' ' + v + if v.rstrip() == v: + v += ' ' + super(StringSurroundedBySpaces, self).setValue(v) + +class StringWithSpaceOnRight(String): + def setValue(self, v): + if v.rstrip() == v: + v += ' ' + super(StringWithSpaceOnRight, self).setValue(v) + +class Regexp(Value): + """Value must be a valid regular expression.""" + def __init__(self, *args, **kwargs): + kwargs['setDefault'] = False + self.sr = '' + self.value = None + self.__parent = super(Regexp, self) + self.__parent.__init__(*args, **kwargs) + + def error(self, e): + self.__parent.error('Value must be a regexp of the form %s' % e) + + def set(self, s): + try: + if s: + self.setValue(utils.perlReToPythonRe(s), sr=s) + else: + self.setValue(None) + except ValueError, e: + self.error(e) + + def setValue(self, v, sr=None): + parent = super(Regexp, self) + if v is None: + self.sr = '' + parent.setValue(None) + elif sr is not None: + self.sr = sr + parent.setValue(v) + else: + raise InvalidRegistryValue, \ + 'Can\'t setValue a regexp, there would be an inconsistency '\ + 'between the regexp and the recorded string value.' + + def __str__(self): + self() # Gotta update if we've been reloaded. + return self.sr + +class SeparatedListOf(Value): + List = list + Value = Value + sorted = False + def splitter(self, s): + """Override this with a function that takes a string and returns a list + of strings.""" + raise NotImplementedError + + def joiner(self, L): + """Override this to join the internal list for output.""" + raise NotImplementedError + + def set(self, s): + L = self.splitter(s) + for (i, s) in enumerate(L): + v = self.Value(s, '') + L[i] = v() + self.setValue(L) + + def setValue(self, v): + super(SeparatedListOf, self).setValue(self.List(v)) + + def __str__(self): + values = self() + if self.sorted: + values = sorted(values) + if values: + return self.joiner(values) + else: + # We must return *something* here, otherwise down along the road we + # can run into issues showing users the value if they've disabled + # nick prefixes in any of the numerous ways possible. Since the + # config parser doesn't care about this space, we'll use it :) + return ' ' + +class SpaceSeparatedListOf(SeparatedListOf): + def splitter(self, s): + return s.split() + joiner = ' '.join + +class SpaceSeparatedListOfStrings(SpaceSeparatedListOf): + Value = String + +class SpaceSeparatedSetOfStrings(SpaceSeparatedListOfStrings): + List = sets.Set + +class CommaSeparatedListOfStrings(SeparatedListOf): + Value = String + def splitter(self, s): + return re.split(r'\s*,\s*', s) + joiner = ', '.join + + +# vim:set shiftwidth=4 tabstop=8 expandtab textwidth=78: + diff --git a/src/schedule.py b/src/schedule.py new file mode 100644 index 000000000..274fdb914 --- /dev/null +++ b/src/schedule.py @@ -0,0 +1,150 @@ +### +# Copyright (c) 2002-2004, Jeremiah Fincher +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND 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 COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +### + +""" +Schedule plugin with a subclass of drivers.IrcDriver in order to be run as a +Supybot driver. +""" + + + +import supybot.fix as fix + +import time +import heapq + +import supybot.log as log +import supybot.world as world +import supybot.drivers as drivers + +class mytuple(tuple): + def __cmp__(self, other): + return cmp(self[0], other[0]) + def __le__(self, other): + return self[0] <= other[0] + def __lt__(self, other): + return self[0] < other[0] + def __gt__(self, other): + return self[0] > other[0] + def __ge__(self, other): + return self[0] >= other[0] + +class Schedule(drivers.IrcDriver): + """An IrcDriver to handling scheduling of events. + + Events, in this case, are functions accepting no arguments. + """ + def __init__(self): + drivers.IrcDriver.__init__(self) + self.schedule = [] + self.events = {} + self.counter = 0 + + def reset(self): + self.events.clear() + self.schedule[:] = [] + # We don't reset the counter here because if someone has held an id of + # one of the nuked events, we don't want him removing new events with + # his old id. + + def name(self): + return 'Schedule' + + def addEvent(self, f, t, name=None): + """Schedules an event f to run at time t. + + name must be hashable and not an int. + """ + if name is None: + name = self.counter + self.counter += 1 + assert name not in self.events + self.events[name] = f + heapq.heappush(self.schedule, mytuple((t, name))) + return name + + def removeEvent(self, name): + """Removes the event with the given name from the schedule.""" + f = self.events.pop(name) + self.schedule = [(t, n) for (t, n) in self.schedule if n != name] + # We must heapify here because the heap property may not be preserved + # by the above list comprehension. We could, conceivably, just mark + # the elements of the heap as removed and ignore them when we heappop, + # but that would only save a constant factor (we're already linear for + # the listcomp) so I'm not worried about it right now. + heapq.heapify(self.schedule) + return f + + def rescheduleEvent(self, name, t): + f = self.removeEvent(name) + self.addEvent(f, t, name=name) + + def addPeriodicEvent(self, f, t, name=None, now=True): + """Adds a periodic event that is called every t seconds.""" + def wrapper(): + try: + f() + finally: + # Even if it raises an exception, let's schedule it. + return self.addEvent(wrapper, time.time() + t, name) + if now: + return wrapper() + else: + return self.addEvent(wrapper, time.time() + t, name) + + removePeriodicEvent = removeEvent + + def run(self): + if len(drivers._drivers) == 1 and not world.testing: + log.error('Schedule is the only remaining driver, ' + 'why do we continue to live?') + time.sleep(1) # We're the only driver; let's pause to think. + while self.schedule and self.schedule[0][0] < time.time(): + (t, name) = heapq.heappop(self.schedule) + f = self.events[name] + del self.events[name] + try: + f() + except Exception, e: + log.exception('Uncaught exception in scheduled function:') + +try: + ignore(schedule) +except NameError: + schedule = Schedule() + +addEvent = schedule.addEvent +removeEvent = schedule.removeEvent +rescheduleEvent = schedule.rescheduleEvent +addPeriodicEvent = schedule.addPeriodicEvent +removePeriodicEvent = removeEvent +run = schedule.run + + +# vim:set shiftwidth=4 tabstop=8 expandtab textwidth=78: diff --git a/src/structures.py b/src/structures.py new file mode 100644 index 000000000..4334a8e46 --- /dev/null +++ b/src/structures.py @@ -0,0 +1,419 @@ +### +# Copyright (c) 2002-2004, Jeremiah Fincher +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND 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 COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +### + +""" +Data structures for Python. +""" + + + +import supybot.fix as fix + +import time +import types +from itertools import imap, ilen + +class RingBuffer(object): + """Class to represent a fixed-size ring buffer.""" + __slots__ = ('L', 'i', 'full', 'maxSize') + def __init__(self, maxSize, seq=()): + if maxSize <= 0: + raise ValueError, 'maxSize must be > 0.' + self.maxSize = maxSize + self.reset() + for elt in seq: + self.append(elt) + + def reset(self): + self.full = False + self.L = [] + self.i = 0 + + def resize(self, i): + if self.full: + L = list(self) + self.reset() + self.L = L + self.maxSize = i + + def __len__(self): + return len(self.L) + + def __eq__(self, other): + if self.__class__ == other.__class__ and \ + self.maxSize == other.maxSize and len(self) == len(other): + iterator = iter(other) + for elt in self: + otherelt = iterator.next() + if not elt == otherelt: + return False + return True + return False + + def __nonzero__(self): + return len(self) > 0 + + def __contains__(self, elt): + return elt in self.L + + def append(self, elt): + if self.full: + self.L[self.i] = elt + self.i += 1 + self.i %= len(self.L) + elif len(self) == self.maxSize: + self.full = True + self.append(elt) + else: + self.L.append(elt) + + def extend(self, seq): + for elt in seq: + self.append(elt) + + def __getitem__(self, idx): + if self.full: + oidx = idx + if type(oidx) == types.SliceType: + L = [] + for i in xrange(*slice.indices(oidx, len(self))): + L.append(self[i]) + return L + else: + (m, idx) = divmod(oidx, len(self.L)) + if m and m != -1: + raise IndexError, oidx + idx = (idx + self.i) % len(self.L) + return self.L[idx] + else: + if type(idx) == types.SliceType: + L = [] + for i in xrange(*slice.indices(idx, len(self))): + L.append(self[i]) + return L + else: + return self.L[idx] + + def __setitem__(self, idx, elt): + if self.full: + oidx = idx + if type(oidx) == types.SliceType: + range = xrange(*slice.indices(oidx, len(self))) + if len(range) != len(elt): + raise ValueError, 'seq must be the same length as slice.' + else: + for (i, x) in zip(range, elt): + self[i] = x + else: + (m, idx) = divmod(oidx, len(self.L)) + if m and m != -1: + raise IndexError, oidx + idx = (idx + self.i) % len(self.L) + self.L[idx] = elt + else: + if type(idx) == types.SliceType: + range = xrange(*slice.indices(idx, len(self))) + if len(range) != len(elt): + raise ValueError, 'seq must be the same length as slice.' + else: + for (i, x) in zip(range, elt): + self[i] = x + else: + self.L[idx] = elt + + def __repr__(self): + return 'RingBuffer(%r, %r)' % (self.maxSize, list(self)) + + def __getstate__(self): + return (self.maxSize, self.full, self.i, self.L) + + def __setstate__(self, (maxSize, full, i, L)): + self.maxSize = maxSize + self.full = full + self.i = i + self.L = L + + +class queue(object): + """Queue class for handling large queues. Queues smaller than 1,000 or so + elements are probably better served by the smallqueue class. + """ + __slots__ = ('front', 'back') + def __init__(self, seq=()): + self.back = [] + self.front = [] + for elt in seq: + self.enqueue(elt) + + def reset(self): + self.back[:] = [] + self.front[:] = [] + + def enqueue(self, elt): + self.back.append(elt) + + def dequeue(self): + try: + return self.front.pop() + except IndexError: + self.back.reverse() + self.front = self.back + self.back = [] + return self.front.pop() + + def peek(self): + if self.front: + return self.front[-1] + else: + return self.back[0] + + def __len__(self): + return len(self.front) + len(self.back) + + def __nonzero__(self): + return bool(self.back or self.front) + + def __contains__(self, elt): + return elt in self.front or elt in self.back + + def __iter__(self): + for elt in reversed(self.front): + yield elt + for elt in self.back: + yield elt + + def __eq__(self, other): + if len(self) == len(other): + otheriter = iter(other) + for elt in self: + otherelt = otheriter.next() + if not (elt == otherelt): + return False + return True + else: + return False + + def __repr__(self): + return 'queue([%s])' % ', '.join(imap(repr, self)) + + def __getitem__(self, oidx): + if len(self) == 0: + raise IndexError, 'queue index out of range' + if type(oidx) == types.SliceType: + L = [] + for i in xrange(*slice.indices(oidx, len(self))): + L.append(self[i]) + return L + else: + (m, idx) = divmod(oidx, len(self)) + if m and m != -1: + raise IndexError, oidx + if len(self.front) > idx: + return self.front[-(idx+1)] + else: + return self.back[(idx-len(self.front))] + + def __setitem__(self, oidx, value): + if len(self) == 0: + raise IndexError, 'queue index out of range' + if type(oidx) == types.SliceType: + range = xrange(*slice.indices(oidx, len(self))) + if len(range) != len(value): + raise ValueError, 'seq must be the same length as slice.' + else: + for i in range: + (m, idx) = divmod(oidx, len(self)) + if m and m != -1: + raise IndexError, oidx + for (i, x) in zip(range, value): + self[i] = x + else: + (m, idx) = divmod(oidx, len(self)) + if m and m != -1: + raise IndexError, oidx + if len(self.front) > idx: + self.front[-(idx+1)] = value + else: + self.back[idx-len(self.front)] = value + + def __delitem__(self, oidx): + if type(oidx) == types.SliceType: + range = xrange(*slice.indices(oidx, len(self))) + for i in range: + del self[i] + else: + (m, idx) = divmod(oidx, len(self)) + if m and m != -1: + raise IndexError, oidx + if len(self.front) > idx: + del self.front[-(idx+1)] + else: + del self.back[idx-len(self.front)] + + def __getstate__(self): + return (list(self),) + + def __setstate__(self, (L,)): + L.reverse() + self.front = L + self.back = [] + +class smallqueue(list): + __slots__ = () + def enqueue(self, elt): + self.append(elt) + + def dequeue(self): + return self.pop(0) + + def peek(self): + return self[0] + + def __repr__(self): + return 'smallqueue([%s])' % ', '.join(imap(repr, self)) + + def reset(self): + self[:] = [] + + +class TimeoutQueue(object): + def __init__(self, timeout, queue=None): + if queue is None: + queue = smallqueue() + self.queue = queue + self.timeout = timeout + + def __repr__(self): + return '%s(timeout=%r, queue=%r)' % (self.__class__.__name__, + self.timeout, self.queue) + + def _getTimeout(self): + if callable(self.timeout): + return self.timeout() + else: + return self.timeout + + def _clearOldElements(self): + now = time.time() + while now - self.queue.peek()[0] > self._getTimeout(): + self.queue.dequeue() + + def setTimeout(self, i): + self.timeout = i + + def enqueue(self, elt, at=None): + if at is None: + at = time.time() + self.queue.enqueue((at, elt)) + + def dequeue(self): + self._clearOldElements() + return self.queue.dequeue()[1] + + def __iter__(self): + # We could _clearOldElements here, but what happens if someone stores + # the resulting generator and elements that should've timed out are + # yielded? Hmm? What happens then, smarty-pants? + for (t, elt) in self.queue: + if time.time() - t < self._getTimeout(): + yield elt + + def __len__(self): + return ilen(self) + + +class MaxLengthQueue(queue): + __slots__ = ('length',) + def __init__(self, length, seq=()): + self.length = length + queue.__init__(self, seq) + + def __getstate__(self): + return (self.length, queue.__getstate__(self)) + + def __setstate__(self, (length, q)): + self.length = length + queue.__setstate__(self, q) + + def enqueue(self, elt): + queue.enqueue(self, elt) + if len(self) > self.length: + self.dequeue() + + +class TwoWayDictionary(dict): + __slots__ = () + def __init__(self, seq=(), **kwargs): + if hasattr(seq, 'iteritems'): + seq = seq.iteritems() + elif hasattr(seq, 'items'): + seq = seq.items() + for (key, value) in seq: + self[key] = value + self[value] = key + for (key, value) in kwargs.iteritems(): + self[key] = value + self[value] = key + + def __setitem__(self, key, value): + dict.__setitem__(self, key, value) + dict.__setitem__(self, value, key) + + def __delitem__(self, key): + value = self[key] + dict.__delitem__(self, key) + dict.__delitem__(self, value) + + +class MultiSet(object): + def __init__(self, seq=()): + self.d = {} + for elt in seq: + self.add(elt) + + def add(self, elt): + try: + self.d[elt] += 1 + except KeyError: + self.d[elt] = 1 + + def remove(self, elt): + self.d[elt] -= 1 + if not self.d[elt]: + del self.d[elt] + + def __getitem__(self, elt): + return self.d[elt] + + def __contains__(self, elt): + return elt in self.d + + + +# vim:set shiftwidth=4 tabstop=8 expandtab textwidth=78: diff --git a/src/unpreserve.py b/src/unpreserve.py new file mode 100644 index 000000000..961899021 --- /dev/null +++ b/src/unpreserve.py @@ -0,0 +1,77 @@ +### +# Copyright (c) 2004, Jeremiah Fincher +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND 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 COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +### + + + +class Reader(object): + def __init__(self, Creator, *args, **kwargs): + self.Creator = Creator + self.args = args + self.kwargs = kwargs + self.creator = None + self.modifiedCreator = False + self.indent = None + + def normalizeCommand(self, s): + return s.lower() + + def readFile(self, filename): + self.read(file(filename)) + + def read(self, fd): + lineno = 0 + for line in fd: + lineno += 1 + if not line.strip(): + continue + line = line.rstrip('\r\n') + line = line.expandtabs() + s = line.lstrip(' ') + indent = len(line) - len(s) + if indent != self.indent: + # New indentation level. + if self.creator is not None: + self.creator.finish() + self.creator = self.Creator(*self.args, **self.kwargs) + self.modifiedCreator = False + self.indent = indent + (command, rest) = s.split(None, 1) + command = self.normalizeCommand(command) + self.modifiedCreator = True + if hasattr(self.creator, command): + command = getattr(self.creator, command) + command(rest, lineno) + else: + self.creator.badCommand(command, rest, lineno) + if self.modifiedCreator: + self.creator.finish() + + +# vim:set shiftwidth=4 tabstop=8 expandtab textwidth=78: + diff --git a/src/utils.py b/src/utils.py new file mode 100644 index 000000000..c48d067aa --- /dev/null +++ b/src/utils.py @@ -0,0 +1,875 @@ +### +# Copyright (c) 2002-2004, Jeremiah Fincher +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND 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 COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +### + +""" +Simple utility functions. +""" + + + +import supybot.fix as fix + +import os +import re +import sys +import md5 +import new +import sha +import sets +import time +import types +import random +import shutil +import socket +import string +import sgmllib +import compiler +import textwrap +import UserDict +import itertools +import traceback +import htmlentitydefs +from itertools import imap, ifilter + +from supybot.structures import TwoWayDictionary + +curry = new.instancemethod + +def normalizeWhitespace(s): + """Normalizes the whitespace in a string; \s+ becomes one space.""" + return ' '.join(s.split()) + +class HtmlToText(sgmllib.SGMLParser): + """Taken from some eff-bot code on c.l.p.""" + entitydefs = htmlentitydefs.entitydefs.copy() + entitydefs['nbsp'] = ' ' + def __init__(self, tagReplace=' '): + self.data = [] + self.tagReplace = tagReplace + sgmllib.SGMLParser.__init__(self) + + def unknown_starttag(self, tag, attr): + self.data.append(self.tagReplace) + + def unknown_endtag(self, tag): + self.data.append(self.tagReplace) + + def handle_data(self, data): + self.data.append(data) + + def getText(self): + text = ''.join(self.data).strip() + return normalizeWhitespace(text) + +def htmlToText(s, tagReplace=' '): + """Turns HTML into text. tagReplace is a string to replace HTML tags with. + """ + x = HtmlToText(tagReplace) + x.feed(s) + return x.getText() + +def eachSubstring(s): + """Returns every substring starting at the first index until the last.""" + for i in xrange(1, len(s)+1): + yield s[:i] + +def abbrev(strings, d=None): + """Returns a dictionary mapping unambiguous abbreviations to full forms.""" + if len(strings) != len(set(strings)): + raise ValueError, \ + 'strings given to utils.abbrev have duplicates: %r' % strings + if d is None: + d = {} + for s in strings: + for abbreviation in eachSubstring(s): + if abbreviation not in d: + d[abbreviation] = s + else: + if abbreviation not in strings: + d[abbreviation] = None + removals = [] + for key in d: + if d[key] is None: + removals.append(key) + for key in removals: + del d[key] + return d + +def timeElapsed(elapsed, short=False, leadingZeroes=False, years=True, + weeks=True, days=True, hours=True, minutes=True, seconds=True): + """Given seconds, returns a string with an English description of + how much time as passed. leadingZeroes determines whether 0 days, 0 hours, + etc. will be printed; the others determine what larger time periods should + be used. + """ + ret = [] + def format(s, i): + if i or leadingZeroes or ret: + if short: + ret.append('%s%s' % (i, s[0])) + else: + ret.append(nItems(s, i)) + elapsed = int(elapsed) + assert years or weeks or days or \ + hours or minutes or seconds, 'One flag must be True' + if years: + (yrs, elapsed) = (elapsed // 31536000, elapsed % 31536000) + format('year', yrs) + if weeks: + (wks, elapsed) = (elapsed // 604800, elapsed % 604800) + format('week', wks) + if days: + (ds, elapsed) = (elapsed // 86400, elapsed % 86400) + format('day', ds) + if hours: + (hrs, elapsed) = (elapsed // 3600, elapsed % 3600) + format('hour', hrs) + if minutes or seconds: + (mins, secs) = (elapsed // 60, elapsed % 60) + if leadingZeroes or mins: + format('minute', mins) + if seconds: + leadingZeroes = True + format('second', secs) + if not ret: + raise ValueError, 'Time difference not great enough to be noted.' + if short: + return ' '.join(ret) + else: + return commaAndify(ret) + +def distance(s, t): + """Returns the levenshtein edit distance between two strings.""" + n = len(s) + m = len(t) + if n == 0: + return m + elif m == 0: + return n + d = [] + for i in range(n+1): + d.append([]) + for j in range(m+1): + d[i].append(0) + d[0][j] = j + d[i][0] = i + for i in range(1, n+1): + cs = s[i-1] + for j in range(1, m+1): + ct = t[j-1] + cost = int(cs != ct) + d[i][j] = min(d[i-1][j]+1, d[i][j-1]+1, d[i-1][j-1]+cost) + return d[n][m] + +_soundextrans = string.maketrans(string.ascii_uppercase, + '01230120022455012623010202') +_notUpper = string.ascii.translate(string.ascii, string.ascii_uppercase) +def soundex(s, length=4): + """Returns the soundex hash of a given string.""" + s = s.upper() # Make everything uppercase. + s = s.translate(string.ascii, _notUpper) # Delete non-letters. + if not s: + raise ValueError, 'Invalid string for soundex: %s' + firstChar = s[0] # Save the first character. + s = s.translate(_soundextrans) # Convert to soundex numbers. + s = s.lstrip(s[0]) # Remove all repeated first characters. + L = [firstChar] + for c in s: + if c != L[-1]: + L.append(c) + L = [c for c in L if c != '0'] + (['0']*(length-1)) + s = ''.join(L) + return length and s[:length] or s.rstrip('0') + +def dqrepr(s): + """Returns a repr() of s guaranteed to be in double quotes.""" + # The wankers-that-be decided not to use double-quotes anymore in 2.3. + # return '"' + repr("'\x00" + s)[6:] + return '"%s"' % s.encode('string_escape').replace('"', '\\"') + +def quoted(s): + """Returns a quoted s.""" + return '"%s"' % s + +def _getSep(s): + if len(s) < 2: + raise ValueError, 'string given to _getSep is too short: %r' % s + if s.startswith('m') or s.startswith('s'): + separator = s[1] + else: + separator = s[0] + if separator.isalnum() or separator in '{}[]()<>': + raise ValueError, \ + 'Invalid separator: separator must not be alphanumeric or in ' \ + '"{}[]()<>"' + return separator + +def _getSplitterRe(s): + separator = _getSep(s) + return re.compile(r'(?>> nItems('clock', 1) + '1 clock' + + >>> nItems('clock', 10) + '10 clocks' + + >>> nItems('clock', 10, between='grandfather') + '10 grandfather clocks' + """ + if between is None: + return '%s %s' % (n, pluralize(item, n)) + else: + return '%s %s %s' % (n, between, pluralize(item, n)) + +def be(i): + """Returns the form of the verb 'to be' based on the number i.""" + if i == 1: + return 'is' + else: + return 'are' + +def has(i): + """Returns the form of the verb 'to have' based on the number i.""" + if i == 1: + return 'has' + else: + return 'have' + +def sortBy(f, L): + """Uses the decorate-sort-undecorate pattern to sort L by function f.""" + for (i, elt) in enumerate(L): + L[i] = (f(elt), i, elt) + L.sort() + for (i, elt) in enumerate(L): + L[i] = L[i][2] + +if sys.version_info < (2, 4, 0): + def sorted(iterable, cmp=None, key=None, reversed=False): + L = list(iterable) + if key is not None: + assert cmp is None, 'Can\'t use both cmp and key.' + sortBy(key, L) + else: + L.sort(cmp) + if reversed: + L.reverse() + return L + + __builtins__['sorted'] = sorted + +def mktemp(suffix=''): + """Gives a decent random string, suitable for a filename.""" + r = random.Random() + m = md5.md5(suffix) + r.seed(time.time()) + s = str(r.getstate()) + for x in xrange(0, random.randrange(400), random.randrange(1, 5)): + m.update(str(x)) + m.update(s) + m.update(str(time.time())) + s = m.hexdigest() + return sha.sha(s + str(time.time())).hexdigest() + suffix + +def itersplit(isSeparator, iterable, maxsplit=-1, yieldEmpty=False): + """itersplit(isSeparator, iterable, maxsplit=-1, yieldEmpty=False) + + Splits an iterator based on a predicate isSeparator.""" + if isinstance(isSeparator, basestring): + f = lambda s: s == isSeparator + else: + f = isSeparator + acc = [] + for element in iterable: + if maxsplit == 0 or not f(element): + acc.append(element) + else: + maxsplit -= 1 + if acc or yieldEmpty: + yield acc + acc = [] + if acc or yieldEmpty: + yield acc + +def flatten(seq, strings=False): + """Flattens a list of lists into a single list. See the test for examples. + """ + for elt in seq: + if not strings and type(elt) == str or type(elt) == unicode: + yield elt + else: + try: + for x in flatten(elt): + yield x + except TypeError: + yield elt + +def saltHash(password, salt=None, hash='sha'): + if salt is None: + salt = mktemp()[:8] + if hash == 'sha': + hasher = sha.sha + elif hash == 'md5': + hasher = md5.md5 + return '|'.join([salt, hasher(salt + password).hexdigest()]) + +def safeEval(s, namespace={'True': True, 'False': False, 'None': None}): + """Evaluates s, safely. Useful for turning strings into tuples/lists/etc. + without unsafely using eval().""" + try: + node = compiler.parse(s) + except SyntaxError, e: + raise ValueError, 'Invalid string: %s.' % e + nodes = compiler.parse(s).node.nodes + if not nodes: + if node.__class__ is compiler.ast.Module: + return node.doc + else: + raise ValueError, 'Unsafe string: %s' % quoted(s) + node = nodes[0] + if node.__class__ is not compiler.ast.Discard: + raise ValueError, 'Invalid expression: %s' % quoted(s) + node = node.getChildNodes()[0] + def checkNode(node): + if node.__class__ is compiler.ast.Const: + return True + if node.__class__ in (compiler.ast.List, + compiler.ast.Tuple, + compiler.ast.Dict): + return all(checkNode, node.getChildNodes()) + if node.__class__ is compiler.ast.Name: + if node.name in namespace: + return True + else: + return False + else: + return False + if checkNode(node): + return eval(s, namespace, namespace) + else: + raise ValueError, 'Unsafe string: %s' % quoted(s) + +def exnToString(e): + """Turns a simple exception instance into a string (better than str(e))""" + strE = str(e) + if strE: + return '%s: %s' % (e.__class__.__name__, strE) + else: + return e.__class__.__name__ + +class IterableMap(object): + """Define .iteritems() in a class and subclass this to get the other iters. + """ + def iteritems(self): + raise NotImplementedError + + def iterkeys(self): + for (key, _) in self.iteritems(): + yield key + __iter__ = iterkeys + + def itervalues(self): + for (_, value) in self.iteritems(): + yield value + + def items(self): + return list(self.iteritems()) + + def keys(self): + return list(self.iterkeys()) + + def values(self): + return list(self.itervalues()) + + def __len__(self): + ret = 0 + for _ in self.iteritems(): + ret += 1 + return ret + + def __nonzero__(self): + for _ in self.iteritems(): + return True + return False + + +def nonCommentLines(fd): + for line in fd: + if not line.startswith('#'): + yield line + +def nonEmptyLines(fd): +## for line in fd: +## if line.strip(): +## yield line + return ifilter(str.strip, fd) + +def nonCommentNonEmptyLines(fd): + return nonEmptyLines(nonCommentLines(fd)) + +def changeFunctionName(f, name, doc=None): + if doc is None: + doc = f.__doc__ + newf = types.FunctionType(f.func_code, f.func_globals, name, + f.func_defaults, f.func_closure) + newf.__doc__ = doc + return newf + +def getSocket(host): + """Returns a socket of the correct AF_INET type (v4 or v6) in order to + communicate with host. + """ + host = socket.gethostbyname(host) + if isIP(host): + return socket.socket(socket.AF_INET, socket.SOCK_STREAM) + elif isIPV6(host): + return socket.socket(socket.AF_INET6, socket.SOCK_STREAM) + else: + raise socket.error, 'Something wonky happened.' + +def isIP(s): + """Returns whether or not a given string is an IPV4 address. + + >>> isIP('255.255.255.255') + 1 + + >>> isIP('abc.abc.abc.abc') + 0 + """ + try: + return bool(socket.inet_aton(s)) + except socket.error: + return False + +def bruteIsIPV6(s): + if s.count('::') <= 1: + L = s.split(':') + if len(L) <= 8: + for x in L: + if x: + try: + int(x, 16) + except ValueError: + return False + return True + return False + +def isIPV6(s): + """Returns whether or not a given string is an IPV6 address.""" + try: + if hasattr(socket, 'inet_pton'): + return bool(socket.inet_pton(socket.AF_INET6, s)) + else: + return bruteIsIPV6(s) + except socket.error: + try: + socket.inet_pton(socket.AF_INET6, '::') + except socket.error: + # We gotta fake it. + return bruteIsIPV6(s) + return False + +class InsensitivePreservingDict(UserDict.DictMixin, object): + def key(self, s): + """Override this if you wish.""" + if s is not None: + s = s.lower() + return s + + def __init__(self, dict=None, key=None): + if key is not None: + self.key = key + self.data = {} + if dict is not None: + self.update(dict) + + def __repr__(self): + return '%s(%s)' % (self.__class__.__name__, + super(InsensitivePreservingDict, self).__repr__()) + + def fromkeys(cls, keys, s=None, dict=None, key=None): + d = cls(dict=dict, key=key) + for key in keys: + d[key] = s + return d + fromkeys = classmethod(fromkeys) + + def __getitem__(self, k): + return self.data[self.key(k)][1] + + def __setitem__(self, k, v): + self.data[self.key(k)] = (k, v) + + def __delitem__(self, k): + del self.data[self.key(k)] + + def iteritems(self): + return self.data.itervalues() + + def keys(self): + L = [] + for (k, _) in self.iteritems(): + L.append(k) + return L + + def __reduce__(self): + return (self.__class__, (dict(self.data.values()),)) + + +class NormalizingSet(sets.Set): + def __init__(self, iterable=()): + iterable = itertools.imap(self.normalize, iterable) + super(NormalizingSet, self).__init__(iterable) + + def normalize(self, x): + return x + + def add(self, x): + return super(NormalizingSet, self).add(self.normalize(x)) + + def remove(self, x): + return super(NormalizingSet, self).remove(self.normalize(x)) + + def discard(self, x): + return super(NormalizingSet, self).discard(self.normalize(x)) + + def __contains__(self, x): + return super(NormalizingSet, self).__contains__(self.normalize(x)) + has_key = __contains__ + +def mungeEmailForWeb(s): + s = s.replace('@', ' AT ') + s = s.replace('.', ' DOT ') + return s + +class AtomicFile(file): + """Used for files that need to be atomically written -- i.e., if there's a + failure, the original file remains, unmodified. mode must be 'w' or 'wb'""" + def __init__(self, filename, mode='w', allowEmptyOverwrite=True, + makeBackupIfSmaller=True, tmpDir=None, backupDir=None): + if mode not in ('w', 'wb'): + raise ValueError, 'Invalid mode: %s' % quoted(mode) + self.rolledback = False + self.allowEmptyOverwrite = allowEmptyOverwrite + self.makeBackupIfSmaller = makeBackupIfSmaller + self.filename = filename + self.backupDir = backupDir + if tmpDir is None: + # If not given a tmpDir, we'll just put a random token on the end + # of our filename and put it in the same directory. + self.tempFilename = '%s.%s' % (self.filename, mktemp()) + else: + # If given a tmpDir, we'll get the basename (just the filename, no + # directory), put our random token on the end, and put it in tmpDir + tempFilename = '%s.%s' % (os.path.basename(self.filename), mktemp()) + self.tempFilename = os.path.join(tmpDir, tempFilename) + # This doesn't work because of the uncollectable garbage effect. + # self.__parent = super(AtomicFile, self) + super(AtomicFile, self).__init__(self.tempFilename, mode) + + def rollback(self): + if not self.closed: + super(AtomicFile, self).close() + if os.path.exists(self.tempFilename): + os.remove(self.tempFilename) + self.rolledback = True + + def close(self): + if not self.rolledback: + super(AtomicFile, self).close() + # We don't mind writing an empty file if the file we're overwriting + # doesn't exist. + newSize = os.path.getsize(self.tempFilename) + originalExists = os.path.exists(self.filename) + if newSize or self.allowEmptyOverwrite or not originalExists: + if originalExists: + oldSize = os.path.getsize(self.filename) + if self.makeBackupIfSmaller and newSize < oldSize: + now = int(time.time()) + backupFilename = '%s.backup.%s' % (self.filename, now) + if self.backupDir is not None: + backupFilename = os.path.basename(backupFilename) + backupFilename = os.path.join(self.backupDir, + backupFilename) + shutil.copy(self.filename, backupFilename) + # We use shutil.move here instead of os.rename because + # the latter doesn't work on Windows when self.filename + # (the target) already exists. shutil.move handles those + # intricacies for us. + + # This raises IOError if we can't write to the file. Since + # in *nix, it only takes write perms to the *directory* to + # rename a file (and shutil.move will use os.rename if + # possible), we first check if we have the write permission + # and only then do we write. + fd = file(self.filename, 'a') + fd.close() + shutil.move(self.tempFilename, self.filename) + + else: + raise ValueError, 'AtomicFile.close called after rollback.' + + def __del__(self): + # We rollback because if we're deleted without being explicitly closed, + # that's bad. We really should log this here, but as of yet we've got + # no logging facility in utils. I've got some ideas for this, though. + self.rollback() + +def transactionalFile(*args, **kwargs): + # This exists so it can be replaced by a function that provides the tmpDir. + # We do that replacement in conf.py. + return AtomicFile(*args, **kwargs) + +def stackTrace(frame=None, compact=True): + if frame is None: + frame = sys._getframe() + if compact: + L = [] + while frame: + lineno = frame.f_lineno + funcname = frame.f_code.co_name + filename = os.path.basename(frame.f_code.co_filename) + L.append('[%s|%s|%s]' % (filename, funcname, lineno)) + frame = frame.f_back + return textwrap.fill(' '.join(L)) + else: + return traceback.format_stack(frame) + +def callTracer(fd=None, basename=True): + if fd is None: + fd = sys.stdout + def tracer(frame, event, _): + if event == 'call': + code = frame.f_code + lineno = frame.f_lineno + funcname = code.co_name + filename = code.co_filename + if basename: + filename = os.path.basename(filename) + print >>fd, '%s: %s(%s)' % (filename, funcname, lineno) + return tracer + + +def toBool(s): + s = s.strip().lower() + if s in ('true', 'on', 'enable', 'enabled', '1'): + return True + elif s in ('false', 'off', 'disable', 'disabled', '0'): + return False + else: + raise ValueError, 'Invalid string for toBool: %s' % quoted(s) + +def mapinto(f, L): + for (i, x) in enumerate(L): + L[i] = f(x) + +if __name__ == '__main__': + import doctest + doctest.testmod(sys.modules['__main__']) + + +# vim:set shiftwidth=4 tabstop=8 expandtab textwidth=78: diff --git a/src/webutils.py b/src/webutils.py new file mode 100644 index 000000000..6c7d3b058 --- /dev/null +++ b/src/webutils.py @@ -0,0 +1,129 @@ +### +# Copyright (c) 2002-2004, Jeremiah Fincher +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND 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 COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +### + + + +import supybot.fix as fix + +import re +import socket +import urllib +import urllib2 +import httplib +import urlparse + +import supybot.conf as conf + +Request = urllib2.Request +urlquote = urllib.quote +urlunquote = urllib.unquote + +class WebError(Exception): + pass + +# XXX We should tighten this up a bit. +urlRe = re.compile(r"(\w+://[^\])>\s]+)", re.I) +httpUrlRe = re.compile(r"(https?://[^\])>\s]+)", re.I) + +REFUSED = 'Connection refused.' +TIMED_OUT = 'Connection timed out.' +UNKNOWN_HOST = 'Unknown host.' +RESET_BY_PEER = 'Connection reset by peer.' +FORBIDDEN = 'Client forbidden from accessing URL.' + +def strError(e): + try: + n = e.args[0] + except Exception: + return str(e) + if n == 111: + return REFUSED + elif n in (110, 10060): + return TIMED_OUT + elif n == 104: + return RESET_BY_PEER + elif n in (8, 3, 2): + return UNKNOWN_HOST + elif n == 403: + return FORBIDDEN + else: + return str(e) + +_headers = { + 'User-agent': 'Mozilla/4.0 (compatible; Supybot %s)' % conf.version, + } + +def getUrlFd(url, headers=None): + """Gets a file-like object for a url.""" + if headers is None: + headers = _headers + try: + if not isinstance(url, urllib2.Request): + if '#' in url: + url = url[:url.index('#')] + request = urllib2.Request(url, headers=headers) + else: + request = url + httpProxy = conf.supybot.protocols.http.proxy() + if httpProxy: + request.set_proxy(httpProxy, 'http') + fd = urllib2.urlopen(request) + return fd + except socket.timeout, e: + raise WebError, TIMED_OUT + except (socket.error, socket.sslerror), e: + raise WebError, strError(e) + except httplib.InvalidURL, e: + raise WebError, 'Invalid URL: %s' % e + except urllib2.HTTPError, e: + raise WebError, strError(e) + except urllib2.URLError, e: + raise WebError, strError(e.reason) + # Raised when urllib doesn't recognize the url type + except ValueError, e: + raise WebError, strError(e) + +def getUrl(url, size=None, headers=None): + """Gets a page. Returns a string that is the page gotten.""" + fd = getUrlFd(url, headers=headers) + try: + if size is None: + text = fd.read() + else: + text = fd.read(size) + except socket.timeout, e: + raise WebError, TIMED_OUT + fd.close() + return text + +def getDomain(url): + return urlparse.urlparse(url)[1] + +# vim:set shiftwidth=4 tabstop=8 expandtab textwidth=78: + diff --git a/src/world.py b/src/world.py new file mode 100644 index 000000000..3efa1739c --- /dev/null +++ b/src/world.py @@ -0,0 +1,202 @@ +### +# Copyright (c) 2002-2004, Jeremiah Fincher +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND 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 COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +### + +""" +Module for general worldly stuff, like global variables and whatnot. +""" + + + +import supybot.fix as fix + +import gc +import os +import sys +import sre +import time +import atexit +import threading + +import supybot.log as log +import supybot.conf as conf +import supybot.drivers as drivers +import supybot.ircutils as ircutils +import supybot.registry as registry + +startedAt = time.time() # Just in case it doesn't get set later. + +starting = False + +mainThread = threading.currentThread() +# ??? Should we do this? What do we gain? +# assert 'MainThread' in repr(mainThread) + +def isMainThread(): + return mainThread is threading.currentThread() + +threadsSpawned = 1 # Starts at one for the initial "thread." + +class SupyThread(threading.Thread): + def __init__(self, *args, **kwargs): + global threadsSpawned + threadsSpawned += 1 + super(SupyThread, self).__init__(*args, **kwargs) + +commandsProcessed = 0 + +ircs = [] # A list of all the IRCs. + +def getIrc(network): + network = network.lower() + for irc in ircs: + if irc.network.lower() == network: + return irc + return None + +def _flushUserData(): + userdataFilename = os.path.join(conf.supybot.directories.conf(), + 'userdata.conf') + registry.close(conf.users, userdataFilename) + +flushers = [_flushUserData] # A periodic function will flush all these. + +registryFilename = None + +def flush(): + """Flushes all the registered flushers.""" + for (i, f) in enumerate(flushers): + try: + f() + except Exception, e: + log.exception('Uncaught exception in flusher #%s (%s):', i, f) + +def debugFlush(s=''): + if conf.supybot.debug.flushVeryOften(): + if s: + log.debug(s) + flush() + +def upkeep(): + """Does upkeep (like flushing, garbage collection, etc.)""" + sys.exc_clear() # Just in case, let's clear the exception info. + if os.name == 'nt': + try: + import msvcrt + msvcrt.heapmin() + except ImportError: + pass + except IOError: # Win98 sux0rs! + pass + if conf.daemonized: + # If we're daemonized, sys.stdout has been replaced with a StringIO + # object, so let's see if anything's been printed, and if so, let's + # log.warning it (things shouldn't be printed, and we're more likely + # to get bug reports if we make it a warning). + assert not type(sys.stdout) == file, 'Not a StringIO object!' + s = sys.stdout.getvalue() + if s: + log.warning('Printed to stdout after daemonization: %s', s) + sys.stdout.reset() # Seeks to 0. + sys.stdout.truncate() # Truncates to current offset. + assert not type(sys.stderr) == file, 'Not a StringIO object!' + s = sys.stderr.getvalue() + if s: + log.error('Printed to stderr after daemonization: %s', s) + sys.stderr.reset() # Seeks to 0. + sys.stderr.truncate() # Truncates to current offset. + doFlush = conf.supybot.flush() and not starting + if doFlush: + flush() + # This is so registry._cache gets filled. + # This seems dumb, so we'll try not doing it anymore. + #if registryFilename is not None: + # registry.open(registryFilename) + if not dying: + log.debug('Regexp cache size: %s', len(sre._cache)) + log.debug('Pattern cache size: %s'%len(ircutils._patternCache)) + log.debug('HostmaskPatternEqual cache size: %s' % + len(ircutils._hostmaskPatternEqualCache)) + #timestamp = log.timestamp() + if doFlush: + log.info('Flushers flushed and garbage collected.') + else: + log.info('Garbage collected.') + collected = gc.collect() + if gc.garbage: + log.warning('Noncollectable garbage (file this as a bug on SF.net): %s', + gc.garbage) + return collected + +def makeDriversDie(): + """Kills drivers.""" + log.info('Killing Driver objects.') + for driver in drivers._drivers.itervalues(): + driver.die() + +def makeIrcsDie(): + """Kills Ircs.""" + log.info('Killing Irc objects.') + for irc in ircs[:]: + if not irc.zombie: + irc.die() + else: + log.debug('Not killing %s, it\'s already a zombie.', irc) + +def startDying(): + """Starts dying.""" + log.info('Shutdown initiated.') + global dying + dying = True + +def finished(): + log.info('Shutdown complete.') + +# These are in order; don't reorder them for cosmetic purposes. The order +# in which they're registered is the reverse order in which they will run. +atexit.register(finished) +atexit.register(upkeep) +atexit.register(makeIrcsDie) +atexit.register(makeDriversDie) +atexit.register(startDying) + +################################################## +################################################## +################################################## +## Don't even *think* about messing with these. ## +################################################## +################################################## +################################################## +dying = False +testing = False +starting = False +profiling = False +documenting = False + + +# vim:set shiftwidth=4 tabstop=8 expandtab textwidth=78: diff --git a/test/test_callbacks.py b/test/test_callbacks.py new file mode 100644 index 000000000..3df7814d9 --- /dev/null +++ b/test/test_callbacks.py @@ -0,0 +1,570 @@ +### +# Copyright (c) 2002-2004, Jeremiah Fincher +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND 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 COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +### + +from testsupport import * + +import supybot.conf as conf +import supybot.utils as utils +import supybot.ircmsgs as ircmsgs +import supybot.callbacks as callbacks + +tokenize = callbacks.tokenize + + +class TokenizerTestCase(SupyTestCase): + def testEmpty(self): + self.assertEqual(tokenize(''), []) + + def testNullCharacter(self): + self.assertEqual(tokenize(utils.dqrepr('\0')), ['\0']) + + def testSingleDQInDQString(self): + self.assertEqual(tokenize('"\\""'), ['"']) + + def testDQsWithBackslash(self): + self.assertEqual(tokenize('"\\\\"'), ["\\"]) + + def testDoubleQuotes(self): + self.assertEqual(tokenize('"\\"foo\\""'), ['"foo"']) + + def testSingleWord(self): + self.assertEqual(tokenize('foo'), ['foo']) + + def testMultipleSimpleWords(self): + words = 'one two three four five six seven eight'.split() + for i in range(len(words)): + self.assertEqual(tokenize(' '.join(words[:i])), words[:i]) + + def testSingleQuotesNotQuotes(self): + self.assertEqual(tokenize("it's"), ["it's"]) + + def testQuotedWords(self): + self.assertEqual(tokenize('"foo bar"'), ['foo bar']) + self.assertEqual(tokenize('""'), ['']) + self.assertEqual(tokenize('foo "" bar'), ['foo', '', 'bar']) + self.assertEqual(tokenize('foo "bar baz" quux'), + ['foo', 'bar baz', 'quux']) + + def testNesting(self): + self.assertEqual(tokenize('[]'), [[]]) + self.assertEqual(tokenize('[foo]'), [['foo']]) + self.assertEqual(tokenize('[ foo ]'), [['foo']]) + self.assertEqual(tokenize('foo [bar]'), ['foo', ['bar']]) + self.assertEqual(tokenize('foo bar [baz quux]'), + ['foo', 'bar', ['baz', 'quux']]) + try: + orig = conf.supybot.commands.nested() + conf.supybot.commands.nested.setValue(False) + self.assertEqual(tokenize('[]'), ['[]']) + self.assertEqual(tokenize('[foo]'), ['[foo]']) + self.assertEqual(tokenize('foo [bar]'), ['foo', '[bar]']) + self.assertEqual(tokenize('foo bar [baz quux]'), + ['foo', 'bar', '[baz', 'quux]']) + finally: + conf.supybot.commands.nested.setValue(orig) + + def testError(self): + self.assertRaises(SyntaxError, tokenize, '[foo') #] + self.assertRaises(SyntaxError, tokenize, '"foo') #" + + def testPipe(self): + try: + conf.supybot.commands.nested.pipeSyntax.setValue(True) + self.assertRaises(SyntaxError, tokenize, '| foo') + self.assertRaises(SyntaxError, tokenize, 'foo ||bar') + self.assertRaises(SyntaxError, tokenize, 'bar |') + self.assertEqual(tokenize('foo|bar'), ['bar', ['foo']]) + self.assertEqual(tokenize('foo | bar'), ['bar', ['foo']]) + self.assertEqual(tokenize('foo | bar | baz'), + ['baz', ['bar',['foo']]]) + self.assertEqual(tokenize('foo bar | baz'), + ['baz', ['foo', 'bar']]) + self.assertEqual(tokenize('foo | bar baz'), + ['bar', 'baz', ['foo']]) + self.assertEqual(tokenize('foo bar | baz quux'), + ['baz', 'quux', ['foo', 'bar']]) + finally: + conf.supybot.commands.nested.pipeSyntax.setValue(False) + self.assertEqual(tokenize('foo|bar'), ['foo|bar']) + self.assertEqual(tokenize('foo | bar'), ['foo', '|', 'bar']) + self.assertEqual(tokenize('foo | bar | baz'), + ['foo', '|', 'bar', '|', 'baz']) + self.assertEqual(tokenize('foo bar | baz'), + ['foo', 'bar', '|', 'baz']) + + def testQuoteConfiguration(self): + f = callbacks.tokenize + self.assertEqual(f('[foo]'), [['foo']]) + self.assertEqual(f('"[foo]"'), ['[foo]']) + try: + original = conf.supybot.commands.quotes() + conf.supybot.commands.quotes.setValue('`') + self.assertEqual(f('[foo]'), [['foo']]) + self.assertEqual(f('`[foo]`'), ['[foo]']) + conf.supybot.commands.quotes.setValue('\'') + self.assertEqual(f('[foo]'), [['foo']]) + self.assertEqual(f('\'[foo]\''), ['[foo]']) + conf.supybot.commands.quotes.setValue('`\'') + self.assertEqual(f('[foo]'), [['foo']]) + self.assertEqual(f('`[foo]`'), ['[foo]']) + self.assertEqual(f('[foo]'), [['foo']]) + self.assertEqual(f('\'[foo]\''), ['[foo]']) + finally: + conf.supybot.commands.quotes.setValue(original) + + def testBold(self): + s = '\x02foo\x02' + self.assertEqual(tokenize(s), [s]) + s = s[:-1] + '\x0f' + self.assertEqual(tokenize(s), [s]) + + def testColor(self): + s = '\x032,3foo\x03' + self.assertEqual(tokenize(s), [s]) + s = s[:-1] + '\x0f' + self.assertEqual(tokenize(s), [s]) + + +class FunctionsTestCase(SupyTestCase): + def testCanonicalName(self): + self.assertEqual('foo', callbacks.canonicalName('foo')) + self.assertEqual('foobar', callbacks.canonicalName('foo-bar')) + self.assertEqual('foobar', callbacks.canonicalName('foo_bar')) + self.assertEqual('foobar', callbacks.canonicalName('FOO-bar')) + self.assertEqual('foobar', callbacks.canonicalName('FOOBAR')) + self.assertEqual('foobar', callbacks.canonicalName('foo___bar')) + self.assertEqual('foobar', callbacks.canonicalName('_f_o_o-b_a_r')) + # The following seems to be a hack for the Karma plugin; I'm not + # entirely sure that it's completely necessary anymore. + self.assertEqual('foobar--', callbacks.canonicalName('foobar--')) + + def testAddressed(self): + oldprefixchars = str(conf.supybot.reply.whenAddressedBy.chars) + nick = 'supybot' + conf.supybot.reply.whenAddressedBy.chars.set('~!@') + inChannel = ['~foo', '@foo', '!foo', + '%s: foo' % nick, '%s foo' % nick, + '%s: foo' % nick.capitalize(), '%s: foo' % nick.upper()] + inChannel = [ircmsgs.privmsg('#foo', s) for s in inChannel] + badmsg = ircmsgs.privmsg('#foo', '%s:foo' % nick) + self.failIf(callbacks.addressed(nick, badmsg)) + badmsg = ircmsgs.privmsg('#foo', '%s^: foo' % nick) + self.failIf(callbacks.addressed(nick, badmsg)) + for msg in inChannel: + self.assertEqual('foo', callbacks.addressed(nick, msg), msg) + msg = ircmsgs.privmsg(nick, 'foo') + self.assertEqual('foo', callbacks.addressed(nick, msg)) + conf.supybot.reply.whenAddressedBy.chars.set(oldprefixchars) + msg = ircmsgs.privmsg('#foo', '%s::::: bar' % nick) + self.assertEqual('bar', callbacks.addressed(nick, msg)) + msg = ircmsgs.privmsg('#foo', '%s: foo' % nick.upper()) + self.assertEqual('foo', callbacks.addressed(nick, msg)) + badmsg = ircmsgs.privmsg('#foo', '%s`: foo' % nick) + self.failIf(callbacks.addressed(nick, badmsg)) + + def testAddressedReplyWhenNotAddressed(self): + msg1 = ircmsgs.privmsg('#foo', '@bar') + msg2 = ircmsgs.privmsg('#foo', 'bar') + self.assertEqual(callbacks.addressed('blah', msg1), 'bar') + self.assertEqual(callbacks.addressed('blah', msg2), '') + try: + original = conf.supybot.reply.whenNotAddressed() + conf.supybot.reply.whenNotAddressed.setValue(True) + # need to recreate the msg objects since the old ones have already + # been tagged + msg1 = ircmsgs.privmsg('#foo', '@bar') + msg2 = ircmsgs.privmsg('#foo', 'bar') + self.assertEqual(callbacks.addressed('blah', msg1), 'bar') + self.assertEqual(callbacks.addressed('blah', msg2), 'bar') + finally: + conf.supybot.reply.whenNotAddressed.setValue(original) + + def testAddressedWithMultipleNicks(self): + msg = ircmsgs.privmsg('#foo', 'bar: baz') + self.assertEqual(callbacks.addressed('bar', msg), 'baz') + # need to recreate the msg objects since the old ones have already + # been tagged + msg = ircmsgs.privmsg('#foo', 'bar: baz') + self.assertEqual(callbacks.addressed('biff', msg, nicks=['bar']), + 'baz') + + def testAddressedWithNickAtEnd(self): + msg = ircmsgs.privmsg('#foo', 'baz, bar') + self.assertEqual(callbacks.addressed('bar', msg, + whenAddressedByNickAtEnd=True), + 'baz') + + def testAddressedPrefixCharsTakePrecedenceOverNickAtEnd(self): + msg = ircmsgs.privmsg('#foo', '@echo foo') + self.assertEqual(callbacks.addressed('foo', msg, + whenAddressedByNickAtEnd=True, + prefixChars='@'), + 'echo foo') + + + def testReply(self): + prefix = 'foo!bar@baz' + channelMsg = ircmsgs.privmsg('#foo', 'bar baz', prefix=prefix) + nonChannelMsg = ircmsgs.privmsg('supybot', 'bar baz', prefix=prefix) + self.assertEqual(ircmsgs.privmsg(nonChannelMsg.nick, 'foo'), + callbacks.reply(channelMsg, 'foo', private=True)) + self.assertEqual(ircmsgs.privmsg(nonChannelMsg.nick, 'foo'), + callbacks.reply(nonChannelMsg, 'foo')) + self.assertEqual(ircmsgs.privmsg(channelMsg.args[0], + '%s: foo' % channelMsg.nick), + callbacks.reply(channelMsg, 'foo')) + self.assertEqual(ircmsgs.privmsg(channelMsg.args[0], + 'foo'), + callbacks.reply(channelMsg, 'foo', prefixName=False)) + self.assertEqual(ircmsgs.notice(nonChannelMsg.nick, 'foo'), + callbacks.reply(channelMsg, 'foo', + notice=True, private=True)) + + def testReplyTo(self): + prefix = 'foo!bar@baz' + msg = ircmsgs.privmsg('#foo', 'bar baz', prefix=prefix) + self.assertEqual(callbacks.reply(msg, 'blah', to='blah'), + ircmsgs.privmsg('#foo', 'blah: blah')) + self.assertEqual(callbacks.reply(msg, 'blah', to='blah', private=True), + ircmsgs.privmsg('blah', 'blah')) + + def testGetCommands(self): + self.assertEqual(callbacks.getCommands(['foo']), ['foo']) + self.assertEqual(callbacks.getCommands(['foo', 'bar']), ['foo']) + self.assertEqual(callbacks.getCommands(['foo', ['bar', 'baz']]), + ['foo', 'bar']) + self.assertEqual(callbacks.getCommands(['foo', 'bar', ['baz']]), + ['foo', 'baz']) + self.assertEqual(callbacks.getCommands(['foo', ['bar'], ['baz']]), + ['foo', 'bar', 'baz']) + + def testTokenize(self): + self.assertEqual(callbacks.tokenize(''), []) + self.assertEqual(callbacks.tokenize('foo'), ['foo']) + self.assertEqual(callbacks.tokenize('foo'), ['foo']) + self.assertEqual(callbacks.tokenize('bar [baz]'), ['bar', ['baz']]) + + +class PrivmsgTestCase(ChannelPluginTestCase): + plugins = ('Utilities', 'Misc', 'Http') + conf.allowEval = True + timeout = 2 + def testEmptySquareBrackets(self): + self.assertError('echo []') + + def testHelpNoNameError(self): + # This will raise a NameError if some dynamic scoping isn't working + self.assertHelp('extension') + + def testMaximumNestingDepth(self): + original = conf.supybot.commands.nested.maximum() + try: + conf.supybot.commands.nested.maximum.setValue(3) + self.assertResponse('echo foo', 'foo') + self.assertResponse('echo [echo foo]', 'foo') + self.assertResponse('echo [echo [echo foo]]', 'foo') + self.assertResponse('echo [echo [echo [echo foo]]]', 'foo') + self.assertError('echo [echo [echo [echo [echo foo]]]]') + finally: + conf.supybot.commands.nested.maximum.setValue(original) + + def testSimpleReply(self): + self.assertResponse("eval irc.reply('foo')", 'foo') + + def testSimpleReplyAction(self): + self.assertResponse("eval irc.reply('foo', action=True)", + '\x01ACTION foo\x01') + + def testReplyWithNickPrefix(self): + self.feedMsg('@strlen foo') + m = self.irc.takeMsg() + self.failUnless(m is not None, 'm: %r' % m) + self.failUnless(m.args[1].startswith(self.nick)) + try: + original = conf.supybot.reply.withNickPrefix() + conf.supybot.reply.withNickPrefix.setValue(False) + self.feedMsg('@strlen foobar') + m = self.irc.takeMsg() + self.failUnless(m is not None) + self.failIf(m.args[1].startswith(self.nick)) + finally: + conf.supybot.reply.withNickPrefix.setValue(original) + + def testErrorPrivateKwarg(self): + try: + original = conf.supybot.reply.error.inPrivate() + conf.supybot.reply.error.inPrivate.setValue(False) + m = self.getMsg("eval irc.error('foo', private=True)") + self.failUnless(m, 'No message returned.') + self.failIf(ircutils.isChannel(m.args[0])) + finally: + conf.supybot.reply.error.inPrivate.setValue(original) + + def testErrorNoArgumentIsArgumentError(self): + self.assertHelp('eval irc.error()') + + def testErrorWithNotice(self): + try: + original = conf.supybot.reply.error.withNotice() + conf.supybot.reply.error.withNotice.setValue(True) + m = self.getMsg("eval irc.error('foo')") + self.failUnless(m, 'No message returned.') + self.failUnless(m.command == 'NOTICE') + finally: + conf.supybot.reply.error.withNotice.setValue(original) + + def testErrorReplyPrivate(self): + try: + original = str(conf.supybot.reply.error.inPrivate) + conf.supybot.reply.error.inPrivate.set('False') + # If this doesn't raise an error, we've got a problem, so the next + # two assertions shouldn't run. So we first check that what we + # expect to error actually does so we don't go on a wild goose + # chase because our command never errored in the first place :) + s = 're s/foo/bar baz' # will error; should be "re s/foo/bar/ baz" + self.assertError(s) + m = self.getMsg(s) + self.failUnless(ircutils.isChannel(m.args[0])) + conf.supybot.reply.error.inPrivate.set('True') + m = self.getMsg(s) + self.failIf(ircutils.isChannel(m.args[0])) + finally: + conf.supybot.reply.error.inPrivate.set(original) + + # Now for stuff not based on the plugins. + class First(callbacks.Privmsg): + def firstcmd(self, irc, msg, args): + """First""" + irc.reply('foo') + + class Second(callbacks.Privmsg): + def secondcmd(self, irc, msg, args): + """Second""" + irc.reply('bar') + + class FirstRepeat(callbacks.Privmsg): + def firstcmd(self, irc, msg, args): + """FirstRepeat""" + irc.reply('baz') + + class Third(callbacks.Privmsg): + def third(self, irc, msg, args): + """Third""" + irc.reply(' '.join(args)) + + def tearDown(self): + if hasattr(self.First, 'first'): + del self.First.first + if hasattr(self.Second, 'second'): + del self.Second.second + if hasattr(self.FirstRepeat, 'firstrepeat'): + del self.FirstRepeat.firstrepeat + ChannelPluginTestCase.tearDown(self) + + def testDispatching(self): + self.irc.addCallback(self.First()) + self.irc.addCallback(self.Second()) + self.assertResponse('firstcmd', 'foo') + self.assertResponse('secondcmd', 'bar') + self.assertResponse('first firstcmd', 'foo') + self.assertResponse('second secondcmd', 'bar') + + def testAmbiguousError(self): + self.irc.addCallback(self.First()) + self.assertNotError('firstcmd') + self.irc.addCallback(self.FirstRepeat()) + self.assertError('firstcmd') + self.assertError('firstcmd [firstcmd]') + self.assertNotRegexp('firstcmd', '(foo.*baz|baz.*foo)') + self.assertResponse('first firstcmd', 'foo') + self.assertResponse('firstrepeat firstcmd', 'baz') + + def testAmbiguousHelpError(self): + self.irc.addCallback(self.First()) + self.irc.addCallback(self.FirstRepeat()) + self.assertError('help first') + + def testHelpDispatching(self): + self.irc.addCallback(self.First()) + self.assertHelp('help firstcmd') + self.assertHelp('help first firstcmd') + self.irc.addCallback(self.FirstRepeat()) + self.assertError('help firstcmd') + self.assertRegexp('help first firstcmd', 'First', 0) # no re.I flag. + self.assertRegexp('help firstrepeat firstcmd', 'FirstRepeat', 0) + + class TwoRepliesFirstAction(callbacks.Privmsg): + def testactionreply(self, irc, msg, args): + irc.reply('foo', action=True) + irc.reply('bar') # We're going to check that this isn't an action. + + def testNotActionSecondReply(self): + self.irc.addCallback(self.TwoRepliesFirstAction()) + self.assertAction('testactionreply', 'foo') + m = self.getMsg(' ') + self.failIf(m.args[1].startswith('\x01ACTION')) + + def testEmptyNest(self): + try: + conf.supybot.reply.whenNotCommand.set('True') + self.assertError('echo []') + conf.supybot.reply.whenNotCommand.set('False') + self.assertResponse('echo []', '[]') + finally: + conf.supybot.reply.whenNotCommand.set('False') + + def testDispatcherHelp(self): + self.assertNotRegexp('help first', r'\(dispatcher') + self.assertNotRegexp('help first', r'%s') + + def testDefaultCommand(self): + self.irc.addCallback(self.First()) + self.irc.addCallback(self.Third()) + self.assertError('first blah') + self.assertResponse('third foo bar baz', 'foo bar baz') + + def testSyntaxErrorNotEscaping(self): + self.assertError('load [foo') + self.assertError('load foo]') + + def testNoEscapingAttributeErrorFromTokenizeWithFirstElementList(self): + self.assertError('[plugin list] list') + + class InvalidCommand(callbacks.Privmsg): + def invalidCommand(self, irc, msg, tokens): + irc.reply('foo') + + def testInvalidCommandOneReplyOnly(self): + try: + original = str(conf.supybot.reply.whenNotCommand) + conf.supybot.reply.whenNotCommand.set('True') + self.assertRegexp('asdfjkl', 'not a valid command') + self.irc.addCallback(self.InvalidCommand()) + self.assertResponse('asdfjkl', 'foo') + self.assertNoResponse(' ', 2) + finally: + conf.supybot.reply.whenNotCommand.set(original) + + class BadInvalidCommand(callbacks.Privmsg): + def invalidCommand(self, irc, msg, tokens): + s = 'This shouldn\'t keep Misc.invalidCommand from being called' + raise Exception, s + + def testBadInvalidCommandDoesNotKillAll(self): + try: + original = str(conf.supybot.reply.whenNotCommand) + conf.supybot.reply.whenNotCommand.set('True') + self.irc.addCallback(self.BadInvalidCommand()) + self.assertRegexp('asdfjkl', 'not a valid command') + finally: + conf.supybot.reply.whenNotCommand.set(original) + + +class PrivmsgCommandAndRegexpTestCase(PluginTestCase): + plugins = () + class PCAR(callbacks.PrivmsgCommandAndRegexp): + def test(self, irc, msg, args): + "" + raise callbacks.ArgumentError + def testNoEscapingArgumentError(self): + self.irc.addCallback(self.PCAR()) + self.assertResponse('test', 'test ') + +class RichReplyMethodsTestCase(PluginTestCase): + plugins = () + class NoCapability(callbacks.Privmsg): + def error(self, irc, msg, args): + irc.errorNoCapability('admin') + def testErrorNoCapability(self): + self.irc.addCallback(self.NoCapability()) + self.assertRegexp('error', 'admin') + + +class WithPrivateNoticeTestCase(ChannelPluginTestCase): + plugins = ('Utilities',) + class WithPrivateNotice(callbacks.Privmsg): + def normal(self, irc, msg, args): + irc.reply('should be with private notice') + def explicit(self, irc, msg, args): + irc.reply('should not be with private notice', + private=False, notice=False) + def implicit(self, irc, msg, args): + irc.reply('should be with notice due to private', + private=True) + def test(self): + self.irc.addCallback(self.WithPrivateNotice()) + # Check normal behavior. + m = self.assertNotError('normal') + self.failIf(m.command == 'NOTICE') + self.failUnless(ircutils.isChannel(m.args[0])) + m = self.assertNotError('explicit') + self.failIf(m.command == 'NOTICE') + self.failUnless(ircutils.isChannel(m.args[0])) + # Check abnormal behavior. + originalInPrivate = conf.supybot.reply.inPrivate() + originalWithNotice = conf.supybot.reply.withNotice() + try: + conf.supybot.reply.inPrivate.setValue(True) + conf.supybot.reply.withNotice.setValue(True) + m = self.assertNotError('normal') + self.failUnless(m.command == 'NOTICE') + self.failIf(ircutils.isChannel(m.args[0])) + m = self.assertNotError('explicit') + self.failIf(m.command == 'NOTICE') + self.failUnless(ircutils.isChannel(m.args[0])) + finally: + conf.supybot.reply.inPrivate.setValue(originalInPrivate) + conf.supybot.reply.withNotice.setValue(originalWithNotice) + orig = conf.supybot.reply.withNoticeWhenPrivate() + try: + conf.supybot.reply.withNoticeWhenPrivate.setValue(True) + m = self.assertNotError('implicit') + self.failUnless(m.command == 'NOTICE') + self.failIf(ircutils.isChannel(m.args[0])) + m = self.assertNotError('normal') + self.failIf(m.command == 'NOTICE') + self.failUnless(ircutils.isChannel(m.args[0])) + finally: + conf.supybot.reply.withNoticeWhenPrivate.setValue(orig) + + def testWithNoticeWhenPrivateNotChannel(self): + original = conf.supybot.reply.withNoticeWhenPrivate() + try: + conf.supybot.reply.withNoticeWhenPrivate.setValue(True) + m = self.assertNotError("eval irc.reply('y',to='x',private=True)") + self.failUnless(m.command == 'NOTICE') + m = self.getMsg(' ') + m = self.assertNotError("eval irc.reply('y',to='#x',private=True)") + self.failIf(m.command == 'NOTICE') + finally: + conf.supybot.reply.withNoticeWhenPrivate.setValue(original) + + + +# vim:set shiftwidth=4 tabstop=8 expandtab textwidth=78: diff --git a/test/test_commands.py b/test/test_commands.py new file mode 100644 index 000000000..6d739102c --- /dev/null +++ b/test/test_commands.py @@ -0,0 +1,138 @@ +### +# Copyright (c) 2002-2004, Jeremiah Fincher +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND 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 COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +### + +from testsupport import * + +from supybot.commands import * +import supybot.irclib as irclib +import supybot.ircmsgs as ircmsgs +import supybot.callbacks as callbacks + + +class CommandsTestCase(SupyTestCase): + def assertState(self, spec, given, expected, target='test', **kwargs): + msg = ircmsgs.privmsg(target, 'foo') + realIrc = getTestIrc() + realIrc.nick = 'test' + realIrc.state.supported['chantypes'] = '#' + irc = callbacks.SimpleProxy(realIrc, msg) + myspec = Spec(spec, **kwargs) + state = myspec(irc, msg, given) + self.assertEqual(state.args, expected, + 'Expected %r, got %r' % (expected, state.args)) + + def testEmptySpec(self): + self.assertState([], [], []) + + def testSpecInt(self): + self.assertState(['int'], ['1'], [1]) + self.assertState(['int', 'int', 'int'], ['1', '2', '3'], [1, 2, 3]) + + def testSpecLong(self): + self.assertState(['long'], ['1'], [1L]) + self.assertState(['long', 'long', 'long'], ['1', '2', '3'], + [1L, 2L, 3L]) + + def testRestHandling(self): + self.assertState([rest(None)], ['foo', 'bar', 'baz'], ['foo bar baz']) + + def testRestRequiresArgs(self): + self.assertRaises(callbacks.ArgumentError, + self.assertState, [rest('something')], [], ['asdf']) + + def testOptional(self): + spec = [optional('int', 999), None] + self.assertState(spec, ['12', 'foo'], [12, 'foo']) + self.assertState(spec, ['foo'], [999, 'foo']) + + def testAdditional(self): + spec = [additional('int', 999)] + self.assertState(spec, ['12'], [12]) + self.assertState(spec, [], [999]) + self.assertRaises(callbacks.Error, + self.assertState, spec, ['foo'], ['asdf']) + + def testReverse(self): + spec = [reverse('positiveInt'), 'float', 'text'] + self.assertState(spec, ['-1.0', 'foo', '1'], [1, -1.0, 'foo']) + + def testGetopts(self): + spec = ['int', getopts({'foo': None, 'bar': 'int'}), 'int'] + self.assertState(spec, + ['12', '--foo', 'baz', '--bar', '13', '15'], + [12, [('foo', 'baz'), ('bar', 13)], 15]) + + def testAny(self): + self.assertState([any('int')], ['1', '2', '3'], [[1, 2, 3]]) + self.assertState([None, any('int')], ['1', '2', '3'], ['1', [2, 3]]) + self.assertState([any('int')], [], [[]]) + self.assertState([any('int', continueOnError=True), 'text'], + ['1', '2', 'test'], [[1, 2], 'test']) + + def testMany(self): + spec = [many('int')] + self.assertState(spec, ['1', '2', '3'], [[1, 2, 3]]) + self.assertRaises(callbacks.Error, + self.assertState, spec, [], ['asdf']) + def testChannelRespectsNetwork(self): + spec = ['channel', 'text'] + self.assertState(spec, ['#foo', '+s'], ['#foo', '+s']) + self.assertState(spec, ['+s'], ['#foo', '+s'], target='#foo') + + def testGlob(self): + spec = ['glob'] + self.assertState(spec, ['foo'], ['*foo*']) + self.assertState(spec, ['?foo'], ['?foo']) + self.assertState(spec, ['foo*'], ['foo*']) + + def testGetId(self): + spec = ['id'] + self.assertState(spec, ['#12'], [12]) + + def testCommaList(self): + spec = [commalist('int')] + self.assertState(spec, ['12'], [[12]]) + self.assertState(spec, ['12,', '10'], [[12, 10]]) + self.assertState(spec, ['12,11,10,', '9'], [[12, 11, 10, 9]]) + spec.append('int') + self.assertState(spec, ['12,11,10', '9'], [[12, 11, 10], 9]) + + def testLiteral(self): + spec = [('literal', ['foo', 'bar', 'baz'])] + self.assertState(spec, ['foo'], ['foo']) + self.assertState(spec, ['fo'], ['foo']) + self.assertState(spec, ['f'], ['foo']) + self.assertState(spec, ['bar'], ['bar']) + self.assertState(spec, ['baz'], ['baz']) + self.assertRaises(callbacks.ArgumentError, + self.assertState, spec, ['ba'], ['baz']) + + +# vim:set shiftwidth=4 tabstop=8 expandtab textwidth=78: + diff --git a/test/test_fix.py b/test/test_fix.py new file mode 100644 index 000000000..c0f4cfe2b --- /dev/null +++ b/test/test_fix.py @@ -0,0 +1,127 @@ +### +# Copyright (c) 2002-2004, Jeremiah Fincher +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND 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 COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +### + +## from __future__ import generators + +from testsupport import * + +import random +import itertools + +class FunctionsTest(SupyTestCase): + def testRandomChoice(self): + self.assertRaises(IndexError, random.choice, {}) + + def testReversed(self): + L = range(10) + revL = list(reversed(L)) + L.reverse() + self.assertEqual(L, revL, 'reversed didn\'t return reversed list') + for _ in reversed([]): + self.fail('reversed caused iteration over empty sequence') + + def testGroup(self): + s = '1. d4 d5 2. Nf3 Nc6 3. e3 Nf6 4. Nc3 e6 5. Bd3 a6' + self.assertEqual(group(s.split(), 3)[:3], + [['1.', 'd4', 'd5'], + ['2.', 'Nf3', 'Nc6'], + ['3.', 'e3', 'Nf6']]) + + def testWindow(self): + L = range(10) + def wwindow(*args): + return list(window(*args)) + self.assertEqual(wwindow([], 1), [], 'Empty sequence, empty window') + self.assertEqual(wwindow([], 2), [], 'Empty sequence, empty window') + self.assertEqual(wwindow([], 5), [], 'Empty sequence, empty window') + self.assertEqual(wwindow([], 100), [], 'Empty sequence, empty window') + self.assertEqual(wwindow(L, 1), [[x] for x in L], 'Window length 1') + self.assertRaises(ValueError, wwindow, [], 0) + self.assertRaises(ValueError, wwindow, [], -1) + + def testAny(self): + self.failUnless(any(lambda i: i == 0, range(10))) + self.failIf(any(None, range(1))) + self.failUnless(any(None, range(2))) + self.failIf(any(None, [])) + + def testAll(self): + self.failIf(all(lambda i: i == 0, range(10))) + self.failIf(all(lambda i: i % 2, range(2))) + self.failIf(all(lambda i: i % 2 == 0, [1, 3, 5])) + self.failUnless(all(lambda i: i % 2 == 0, [2, 4, 6])) + self.failUnless(all(None, ())) + + def testPartition(self): + L = range(10) + def even(i): + return not(i % 2) + (yes, no) = partition(even, L) + self.assertEqual(yes, [0, 2, 4, 6, 8]) + self.assertEqual(no, [1, 3, 5, 7, 9]) + + def testIlen(self): + self.assertEqual(itertools.ilen(iter(range(10))), 10) + + def testRsplit(self): + self.assertEqual(rsplit('foo bar baz'), 'foo bar baz'.split()) + self.assertEqual(rsplit('foo bar baz', maxsplit=1), + ['foo bar', 'baz']) + self.assertEqual(rsplit('foo bar baz', maxsplit=1), + ['foo bar', 'baz']) + self.assertEqual(rsplit('foobarbaz', 'bar'), ['foo', 'baz']) + + +class TestDynamic(SupyTestCase): + def test(self): + def f(x): + i = 2 + return g(x) + def g(y): + j = 3 + return h(y) + def h(z): + self.assertEqual(dynamic.z, z) + self.assertEqual(dynamic.j, 3) + self.assertEqual(dynamic.i, 2) + self.assertEqual(dynamic.y, z) + self.assertEqual(dynamic.x, z) + #self.assertRaises(NameError, getattr, dynamic, 'asdfqwerqewr') + self.assertEqual(dynamic.self, self) + return z + self.assertEqual(f(10), 10) + + def testCommonUsage(self): + foo = 'bar' + def f(): + foo = dynamic.foo + self.assertEqual(foo, 'bar') + f() + +# vim:set shiftwidth=4 tabstop=8 expandtab textwidth=78: diff --git a/test/test_ircdb.py b/test/test_ircdb.py new file mode 100644 index 000000000..4b7727f18 --- /dev/null +++ b/test/test_ircdb.py @@ -0,0 +1,564 @@ +### +# Copyright (c) 2002-2004, Jeremiah Fincher +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND 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 COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +### + +from testsupport import * + +import os +import unittest + +import supybot.conf as conf +import supybot.world as world +import supybot.ircdb as ircdb +import supybot.ircutils as ircutils + +class IrcdbTestCase(SupyTestCase): + def setUp(self): + world.testing = False + SupyTestCase.setUp(self) + + def tearDown(self): + world.testing = True + SupyTestCase.tearDown(self) + +class FunctionsTestCase(IrcdbTestCase): + def testIsAntiCapability(self): + self.failIf(ircdb.isAntiCapability('foo')) + self.failIf(ircdb.isAntiCapability('#foo,bar')) + self.failUnless(ircdb.isAntiCapability('-foo')) + self.failUnless(ircdb.isAntiCapability('#foo,-bar')) + self.failUnless(ircdb.isAntiCapability('#foo.bar,-baz')) + + def testIsChannelCapability(self): + self.failIf(ircdb.isChannelCapability('foo')) + self.failUnless(ircdb.isChannelCapability('#foo,bar')) + self.failUnless(ircdb.isChannelCapability('#foo.bar,baz')) + self.failUnless(ircdb.isChannelCapability('#foo,bar.baz')) + + def testMakeAntiCapability(self): + self.assertEqual(ircdb.makeAntiCapability('foo'), '-foo') + self.assertEqual(ircdb.makeAntiCapability('#foo,bar'), '#foo,-bar') + + def testMakeChannelCapability(self): + self.assertEqual(ircdb.makeChannelCapability('#f', 'b'), '#f,b') + self.assertEqual(ircdb.makeChannelCapability('#f', '-b'), '#f,-b') + + def testFromChannelCapability(self): + self.assertEqual(ircdb.fromChannelCapability('#foo,bar'), + ['#foo', 'bar']) + self.assertEqual(ircdb.fromChannelCapability('#foo.bar,baz'), + ['#foo.bar', 'baz']) + self.assertEqual(ircdb.fromChannelCapability('#foo,bar.baz'), + ['#foo', 'bar.baz']) + + def testUnAntiCapability(self): + self.assertEqual(ircdb.unAntiCapability('-bar'), 'bar') + self.assertEqual(ircdb.unAntiCapability('#foo,-bar'), '#foo,bar') + self.assertEqual(ircdb.unAntiCapability('#foo.bar,-baz'), + '#foo.bar,baz') + + def testInvertCapability(self): + self.assertEqual(ircdb.invertCapability('bar'), '-bar') + self.assertEqual(ircdb.invertCapability('-bar'), 'bar') + self.assertEqual(ircdb.invertCapability('#foo,bar'), '#foo,-bar') + self.assertEqual(ircdb.invertCapability('#foo,-bar'), '#foo,bar') + + +class CapabilitySetTestCase(SupyTestCase): + def testGeneral(self): + d = ircdb.CapabilitySet() + self.assertRaises(KeyError, d.check, 'foo') + d = ircdb.CapabilitySet(('foo',)) + self.failUnless(d.check('foo')) + self.failIf(d.check('-foo')) + d.add('bar') + self.failUnless(d.check('bar')) + self.failIf(d.check('-bar')) + d.add('-baz') + self.failIf(d.check('baz')) + self.failUnless(d.check('-baz')) + d.add('-bar') + self.failIf(d.check('bar')) + self.failUnless(d.check('-bar')) + d.remove('-bar') + self.assertRaises(KeyError, d.check, '-bar') + self.assertRaises(KeyError, d.check, 'bar') + + def testReprEval(self): + s = ircdb.UserCapabilitySet() + self.assertEqual(s, eval(repr(s), ircdb.__dict__, ircdb.__dict__)) + s.add('foo') + self.assertEqual(s, eval(repr(s), ircdb.__dict__, ircdb.__dict__)) + s.add('bar') + self.assertEqual(s, eval(repr(s), ircdb.__dict__, ircdb.__dict__)) + + def testContains(self): + s = ircdb.CapabilitySet() + self.failIf('foo' in s) + self.failIf('-foo' in s) + s.add('foo') + self.failUnless('foo' in s) + self.failUnless('-foo' in s) + s.remove('foo') + self.failIf('foo' in s) + self.failIf('-foo' in s) + s.add('-foo') + self.failUnless('foo' in s) + self.failUnless('-foo' in s) + + def testCheck(self): + s = ircdb.CapabilitySet() + self.assertRaises(KeyError, s.check, 'foo') + self.assertRaises(KeyError, s.check, '-foo') + s.add('foo') + self.failUnless(s.check('foo')) + self.failIf(s.check('-foo')) + s.remove('foo') + self.assertRaises(KeyError, s.check, 'foo') + self.assertRaises(KeyError, s.check, '-foo') + s.add('-foo') + self.failIf(s.check('foo')) + self.failUnless(s.check('-foo')) + s.remove('-foo') + self.assertRaises(KeyError, s.check, 'foo') + self.assertRaises(KeyError, s.check, '-foo') + + def testAdd(self): + s = ircdb.CapabilitySet() + s.add('foo') + s.add('-foo') + self.failIf(s.check('foo')) + self.failUnless(s.check('-foo')) + s.add('foo') + self.failUnless(s.check('foo')) + self.failIf(s.check('-foo')) + + +class UserCapabilitySetTestCase(SupyTestCase): + def testOwnerHasAll(self): + d = ircdb.UserCapabilitySet(('owner',)) + self.failIf(d.check('-foo')) + self.failUnless(d.check('foo')) + + def testOwnerIsAlwaysPresent(self): + d = ircdb.UserCapabilitySet() + self.failUnless('owner' in d) + self.failUnless('-owner' in d) + self.failIf(d.check('owner')) + d.add('owner') + self.failUnless(d.check('owner')) + + def testReprEval(self): + s = ircdb.UserCapabilitySet() + self.assertEqual(s, eval(repr(s), ircdb.__dict__, ircdb.__dict__)) + s.add('foo') + self.assertEqual(s, eval(repr(s), ircdb.__dict__, ircdb.__dict__)) + s.add('bar') + self.assertEqual(s, eval(repr(s), ircdb.__dict__, ircdb.__dict__)) + + def testOwner(self): + s = ircdb.UserCapabilitySet() + s.add('owner') + self.failUnless('foo' in s) + self.failUnless('-foo' in s) + self.failUnless(s.check('owner')) + self.failIf(s.check('-owner')) + self.failIf(s.check('-foo')) + self.failUnless(s.check('foo')) + +## def testWorksAfterReload(self): +## s = ircdb.UserCapabilitySet(['owner']) +## self.failUnless(s.check('owner')) +## import sets +## reload(sets) +## self.failUnless(s.check('owner')) + + +class IrcUserTestCase(IrcdbTestCase): + def testCapabilities(self): + u = ircdb.IrcUser() + u.addCapability('foo') + self.failUnless(u._checkCapability('foo')) + self.failIf(u._checkCapability('-foo')) + u.addCapability('-bar') + self.failUnless(u._checkCapability('-bar')) + self.failIf(u._checkCapability('bar')) + u.removeCapability('foo') + u.removeCapability('-bar') + self.assertRaises(KeyError, u._checkCapability, 'foo') + self.assertRaises(KeyError, u._checkCapability, '-bar') + + def testAddhostmask(self): + u = ircdb.IrcUser() + self.assertRaises(ValueError, u.addHostmask, '*!*@*') + + def testRemoveHostmask(self): + u = ircdb.IrcUser() + u.addHostmask('foo!bar@baz') + self.failUnless(u.checkHostmask('foo!bar@baz')) + u.addHostmask('foo!bar@baz') + u.removeHostmask('foo!bar@baz') + self.failIf(u.checkHostmask('foo!bar@baz')) + + def testOwner(self): + u = ircdb.IrcUser() + u.addCapability('owner') + self.failUnless(u._checkCapability('foo')) + self.failIf(u._checkCapability('-foo')) + + def testInitCapabilities(self): + u = ircdb.IrcUser(capabilities=['foo']) + self.failUnless(u._checkCapability('foo')) + + def testPassword(self): + u = ircdb.IrcUser() + u.setPassword('foobar') + self.failUnless(u.checkPassword('foobar')) + self.failIf(u.checkPassword('somethingelse')) + + def testTimeoutAuth(self): + orig = conf.supybot.databases.users.timeoutIdentification() + try: + conf.supybot.databases.users.timeoutIdentification.setValue(2) + u = ircdb.IrcUser() + u.addAuth('foo!bar@baz') + self.failUnless(u.checkHostmask('foo!bar@baz')) + time.sleep(2.1) + self.failIf(u.checkHostmask('foo!bar@baz')) + finally: + conf.supybot.databases.users.timeoutIdentification.setValue(orig) + + def testMultipleAuth(self): + orig = conf.supybot.databases.users.timeoutIdentification() + try: + conf.supybot.databases.users.timeoutIdentification.setValue(2) + u = ircdb.IrcUser() + u.addAuth('foo!bar@baz') + self.failUnless(u.checkHostmask('foo!bar@baz')) + u.addAuth('boo!far@fizz') + self.failUnless(u.checkHostmask('boo!far@fizz')) + time.sleep(2.1) + self.failIf(u.checkHostmask('foo!bar@baz')) + self.failIf(u.checkHostmask('boo!far@fizz')) + finally: + conf.supybot.databases.users.timeoutIdentification.setValue(orig) + + def testHashedPassword(self): + u = ircdb.IrcUser() + u.setPassword('foobar', hashed=True) + self.failUnless(u.checkPassword('foobar')) + self.failIf(u.checkPassword('somethingelse')) + self.assertNotEqual(u.password, 'foobar') + + def testHostmasks(self): + prefix = 'foo12341234!bar@baz.domain.tld' + hostmasks = ['*!bar@baz.domain.tld', 'foo12341234!*@*'] + u = ircdb.IrcUser() + self.failIf(u.checkHostmask(prefix)) + for hostmask in hostmasks: + u.addHostmask(hostmask) + self.failUnless(u.checkHostmask(prefix)) + + def testAuth(self): + prefix = 'foo!bar@baz' + u = ircdb.IrcUser() + u.addAuth(prefix) + self.failUnless(u.auth) + u.clearAuth() + self.failIf(u.auth) + + def testIgnore(self): + u = ircdb.IrcUser(ignore=True) + self.failIf(u._checkCapability('foo')) + self.failUnless(u._checkCapability('-foo')) + + def testRemoveCapability(self): + u = ircdb.IrcUser(capabilities=('foo',)) + self.assertRaises(KeyError, u.removeCapability, 'bar') + +class IrcChannelTestCase(IrcdbTestCase): + def testInit(self): + c = ircdb.IrcChannel() + self.failIf(c._checkCapability('op')) + self.failIf(c._checkCapability('voice')) + self.failIf(c._checkCapability('halfop')) + self.failIf(c._checkCapability('protected')) + + def testCapabilities(self): + c = ircdb.IrcChannel(defaultAllow=False) + self.failIf(c._checkCapability('foo')) + c.addCapability('foo') + self.failUnless(c._checkCapability('foo')) + c.removeCapability('foo') + self.failIf(c._checkCapability('foo')) + + def testDefaultCapability(self): + c = ircdb.IrcChannel() + c.setDefaultCapability(False) + self.failIf(c._checkCapability('foo')) + self.failUnless(c._checkCapability('-foo')) + c.setDefaultCapability(True) + self.failUnless(c._checkCapability('foo')) + self.failIf(c._checkCapability('-foo')) + + def testLobotomized(self): + c = ircdb.IrcChannel(lobotomized=True) + self.failUnless(c.checkIgnored('foo!bar@baz')) + + def testIgnored(self): + prefix = 'foo!bar@baz' + banmask = ircutils.banmask(prefix) + c = ircdb.IrcChannel() + self.failIf(c.checkIgnored(prefix)) + c.addIgnore(banmask) + self.failUnless(c.checkIgnored(prefix)) + c.removeIgnore(banmask) + self.failIf(c.checkIgnored(prefix)) + c.addBan(banmask) + self.failUnless(c.checkIgnored(prefix)) + c.removeBan(banmask) + self.failIf(c.checkIgnored(prefix)) + +class UsersDictionaryTestCase(IrcdbTestCase): + filename = os.path.join(conf.supybot.directories.conf(), + 'UsersDictionaryTestCase.conf') + def setUp(self): + try: + os.remove(self.filename) + except: + pass + self.users = ircdb.UsersDictionary() + IrcdbTestCase.setUp(self) + + def testIterAndNumUsers(self): + self.assertEqual(self.users.numUsers(), 0) + u = self.users.newUser() + hostmask = 'foo!xyzzy@baz.domain.com' + banmask = ircutils.banmask(hostmask) + u.addHostmask(banmask) + u.name = 'foo' + self.users.setUser(u) + self.assertEqual(self.users.numUsers(), 1) + u = self.users.newUser() + hostmask = 'biff!fladksfj@blakjdsf' + banmask = ircutils.banmask(hostmask) + u.addHostmask(banmask) + u.name = 'biff' + self.users.setUser(u) + self.assertEqual(self.users.numUsers(), 2) + self.users.delUser(2) + self.assertEqual(self.users.numUsers(), 1) + self.users.delUser(1) + self.assertEqual(self.users.numUsers(), 0) + + def testGetSetDelUser(self): + self.assertRaises(KeyError, self.users.getUser, 'foo') + self.assertRaises(KeyError, + self.users.getUser, 'foo!xyzzy@baz.domain.com') + u = self.users.newUser() + hostmask = 'foo!xyzzy@baz.domain.com' + banmask = ircutils.banmask(hostmask) + u.addHostmask(banmask) + u.addHostmask(hostmask) + u.name = 'foo' + self.users.setUser(u) + self.assertEqual(self.users.getUser('foo'), u) + self.assertEqual(self.users.getUser('FOO'), u) + self.assertEqual(self.users.getUser(hostmask), u) + self.assertEqual(self.users.getUser(banmask), u) + # The UsersDictionary shouldn't allow users to be added whose hostmasks + # match another user's already in the database. + u2 = self.users.newUser() + u2.addHostmask('*!xyzzy@baz.domain.c?m') + self.assertRaises(ValueError, self.users.setUser, u2) + + +class CheckCapabilityTestCase(IrcdbTestCase): + filename = os.path.join(conf.supybot.directories.conf(), + 'CheckCapabilityTestCase.conf') + owner = 'owner!owner@owner' + nothing = 'nothing!nothing@nothing' + justfoo = 'justfoo!justfoo@justfoo' + antifoo = 'antifoo!antifoo@antifoo' + justchanfoo = 'justchanfoo!justchanfoo@justchanfoo' + antichanfoo = 'antichanfoo!antichanfoo@antichanfoo' + securefoo = 'securefoo!securefoo@securefoo' + channel = '#channel' + cap = 'foo' + anticap = ircdb.makeAntiCapability(cap) + chancap = ircdb.makeChannelCapability(channel, cap) + antichancap = ircdb.makeAntiCapability(chancap) + chanop = ircdb.makeChannelCapability(channel, 'op') + channelnothing = ircdb.IrcChannel() + channelcap = ircdb.IrcChannel() + channelcap.addCapability(cap) + channelanticap = ircdb.IrcChannel() + channelanticap.addCapability(anticap) + def setUp(self): + IrcdbTestCase.setUp(self) + try: + os.remove(self.filename) + except: + pass + self.users = ircdb.UsersDictionary() + #self.users.open(self.filename) + self.channels = ircdb.ChannelsDictionary() + #self.channels.open(self.filename) + + owner = self.users.newUser() + owner.name = 'owner' + owner.addCapability('owner') + owner.addHostmask(self.owner) + self.users.setUser(owner) + + nothing = self.users.newUser() + nothing.name = 'nothing' + nothing.addHostmask(self.nothing) + self.users.setUser(nothing) + + justfoo = self.users.newUser() + justfoo.name = 'justfoo' + justfoo.addCapability(self.cap) + justfoo.addHostmask(self.justfoo) + self.users.setUser(justfoo) + + antifoo = self.users.newUser() + antifoo.name = 'antifoo' + antifoo.addCapability(self.anticap) + antifoo.addHostmask(self.antifoo) + self.users.setUser(antifoo) + + justchanfoo = self.users.newUser() + justchanfoo.name = 'justchanfoo' + justchanfoo.addCapability(self.chancap) + justchanfoo.addHostmask(self.justchanfoo) + self.users.setUser(justchanfoo) + + antichanfoo = self.users.newUser() + antichanfoo.name = 'antichanfoo' + antichanfoo.addCapability(self.antichancap) + antichanfoo.addHostmask(self.antichanfoo) + self.users.setUser(antichanfoo) + + securefoo = self.users.newUser() + securefoo.name = 'securefoo' + securefoo.addCapability(self.cap) + securefoo.secure = True + securefoo.addHostmask(self.securefoo) + self.users.setUser(securefoo) + + channel = ircdb.IrcChannel() + self.channels.setChannel(self.channel, channel) + + def checkCapability(self, hostmask, capability): + return ircdb.checkCapability(hostmask, capability, + self.users, self.channels) + + def testOwner(self): + self.failUnless(self.checkCapability(self.owner, self.cap)) + self.failIf(self.checkCapability(self.owner, self.anticap)) + self.failUnless(self.checkCapability(self.owner, self.chancap)) + self.failIf(self.checkCapability(self.owner, self.antichancap)) + self.channels.setChannel(self.channel, self.channelanticap) + self.failUnless(self.checkCapability(self.owner, self.cap)) + self.failIf(self.checkCapability(self.owner, self.anticap)) + + def testNothingAgainstChannel(self): + self.channels.setChannel(self.channel, self.channelnothing) + self.assertEqual(self.checkCapability(self.nothing, self.chancap), + self.channelnothing.defaultAllow) + self.channelnothing.defaultAllow = not self.channelnothing.defaultAllow + self.channels.setChannel(self.channel, self.channelnothing) + self.assertEqual(self.checkCapability(self.nothing, self.chancap), + self.channelnothing.defaultAllow) + self.channels.setChannel(self.channel, self.channelcap) + self.failUnless(self.checkCapability(self.nothing, self.chancap)) + self.failIf(self.checkCapability(self.nothing, self.antichancap)) + self.channels.setChannel(self.channel, self.channelanticap) + self.failIf(self.checkCapability(self.nothing, self.chancap)) + self.failUnless(self.checkCapability(self.nothing, self.antichancap)) + + def testNothing(self): + self.assertEqual(self.checkCapability(self.nothing, self.cap), + conf.supybot.capabilities.default()) + self.assertEqual(self.checkCapability(self.nothing, self.anticap), + not conf.supybot.capabilities.default()) + + def testJustFoo(self): + self.failUnless(self.checkCapability(self.justfoo, self.cap)) + self.failIf(self.checkCapability(self.justfoo, self.anticap)) + + def testAntiFoo(self): + self.failUnless(self.checkCapability(self.antifoo, self.anticap)) + self.failIf(self.checkCapability(self.antifoo, self.cap)) + + def testJustChanFoo(self): + self.channels.setChannel(self.channel, self.channelnothing) + self.failUnless(self.checkCapability(self.justchanfoo, self.chancap)) + self.failIf(self.checkCapability(self.justchanfoo, self.antichancap)) + self.channelnothing.defaultAllow = not self.channelnothing.defaultAllow + self.failUnless(self.checkCapability(self.justchanfoo, self.chancap)) + self.failIf(self.checkCapability(self.justchanfoo, self.antichancap)) + self.channels.setChannel(self.channel, self.channelanticap) + self.failUnless(self.checkCapability(self.justchanfoo, self.chancap)) + self.failIf(self.checkCapability(self.justchanfoo, self.antichancap)) + + def testChanOpCountsAsEverything(self): + self.channels.setChannel(self.channel, self.channelanticap) + id = self.users.getUserId('nothing') + u = self.users.getUser(id) + u.addCapability(self.chanop) + self.users.setUser(u) + self.failUnless(self.checkCapability(self.nothing, self.chancap)) + self.channels.setChannel(self.channel, self.channelnothing) + self.failUnless(self.checkCapability(self.nothing, self.chancap)) + self.channelnothing.defaultAllow = not self.channelnothing.defaultAllow + self.failUnless(self.checkCapability(self.nothing, self.chancap)) + + def testAntiChanFoo(self): + self.channels.setChannel(self.channel, self.channelnothing) + self.failIf(self.checkCapability(self.antichanfoo, self.chancap)) + self.failUnless(self.checkCapability(self.antichanfoo, + self.antichancap)) + + def testSecurefoo(self): + self.failUnless(self.checkCapability(self.securefoo, self.cap)) + id = self.users.getUserId(self.securefoo) + u = self.users.getUser(id) + u.addAuth(self.securefoo) + self.users.setUser(u) + try: + originalConfDefaultAllow = conf.supybot.capabilities.default() + conf.supybot.capabilities.default.set('False') + self.failIf(self.checkCapability('a' + self.securefoo, self.cap)) + finally: + conf.supybot.capabilities.default.set(str(originalConfDefaultAllow)) + + +# vim:set shiftwidth=4 tabstop=8 expandtab textwidth=78: + diff --git a/test/test_irclib.py b/test/test_irclib.py new file mode 100644 index 000000000..4d9c1f9dd --- /dev/null +++ b/test/test_irclib.py @@ -0,0 +1,457 @@ +### +# Copyright (c) 2002-2004, Jeremiah Fincher +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND 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 COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +### + +from testsupport import * + +import copy +import pickle + +import supybot.conf as conf +import supybot.irclib as irclib +import supybot.ircmsgs as ircmsgs + +class IrcMsgQueueTestCase(SupyTestCase): + mode = ircmsgs.op('#foo', 'jemfinch') + msg = ircmsgs.privmsg('#foo', 'hey, you') + msgs = [ircmsgs.privmsg('#foo', str(i)) for i in range(10)] + kick = ircmsgs.kick('#foo', 'PeterB') + pong = ircmsgs.pong('123') + ping = ircmsgs.ping('123') + topic = ircmsgs.topic('#foo') + notice = ircmsgs.notice('jemfinch', 'supybot here') + join = ircmsgs.join('#foo') + who = ircmsgs.who('#foo') + + def testInit(self): + q = irclib.IrcMsgQueue([self.msg, self.topic, self.ping]) + self.assertEqual(len(q), 3) + + def testLen(self): + q = irclib.IrcMsgQueue() + q.enqueue(self.msg) + self.assertEqual(len(q), 1) + q.enqueue(self.mode) + self.assertEqual(len(q), 2) + q.enqueue(self.kick) + self.assertEqual(len(q), 3) + q.enqueue(self.topic) + self.assertEqual(len(q), 4) + q.dequeue() + self.assertEqual(len(q), 3) + q.dequeue() + self.assertEqual(len(q), 2) + q.dequeue() + self.assertEqual(len(q), 1) + q.dequeue() + self.assertEqual(len(q), 0) + + def testRepr(self): + q = irclib.IrcMsgQueue() + self.assertEqual(repr(q), 'IrcMsgQueue([])') + q.enqueue(self.msg) + try: + repr(q) + except Exception, e: + self.fail('repr(q) raised an exception: %s' % utils.exnToString(e)) + + def testEmpty(self): + q = irclib.IrcMsgQueue() + self.failIf(q) + + def testEnqueueDequeue(self): + q = irclib.IrcMsgQueue() + q.enqueue(self.msg) + self.failUnless(q) + self.assertEqual(self.msg, q.dequeue()) + self.failIf(q) + q.enqueue(self.msg) + q.enqueue(self.notice) + self.assertEqual(self.msg, q.dequeue()) + self.assertEqual(self.notice, q.dequeue()) + for msg in self.msgs: + q.enqueue(msg) + for msg in self.msgs: + self.assertEqual(msg, q.dequeue()) + + def testPrioritizing(self): + q = irclib.IrcMsgQueue() + q.enqueue(self.msg) + q.enqueue(self.mode) + self.assertEqual(self.mode, q.dequeue()) + self.assertEqual(self.msg, q.dequeue()) + q.enqueue(self.msg) + q.enqueue(self.kick) + self.assertEqual(self.kick, q.dequeue()) + self.assertEqual(self.msg, q.dequeue()) + q.enqueue(self.ping) + q.enqueue(self.msgs[0]) + q.enqueue(self.kick) + q.enqueue(self.msgs[1]) + q.enqueue(self.mode) + self.assertEqual(self.kick, q.dequeue()) + self.assertEqual(self.mode, q.dequeue()) + self.assertEqual(self.ping, q.dequeue()) + self.assertEqual(self.msgs[0], q.dequeue()) + self.assertEqual(self.msgs[1], q.dequeue()) + + def testNoIdenticals(self): + q = irclib.IrcMsgQueue() + q.enqueue(self.msg) + q.enqueue(self.msg) + self.assertEqual(self.msg, q.dequeue()) + self.failIf(q) + + def testJoinBeforeWho(self): + q = irclib.IrcMsgQueue() + q.enqueue(self.join) + q.enqueue(self.who) + self.assertEqual(self.join, q.dequeue()) + self.assertEqual(self.who, q.dequeue()) + q.enqueue(self.who) + q.enqueue(self.join) + self.assertEqual(self.join, q.dequeue()) + self.assertEqual(self.who, q.dequeue()) + + def testTopicBeforePrivmsg(self): + q = irclib.IrcMsgQueue() + q.enqueue(self.msg) + q.enqueue(self.topic) + self.assertEqual(self.topic, q.dequeue()) + self.assertEqual(self.msg, q.dequeue()) + + def testModeBeforePrivmsg(self): + q = irclib.IrcMsgQueue() + q.enqueue(self.msg) + q.enqueue(self.mode) + self.assertEqual(self.mode, q.dequeue()) + self.assertEqual(self.msg, q.dequeue()) + q.enqueue(self.mode) + q.enqueue(self.msg) + self.assertEqual(self.mode, q.dequeue()) + self.assertEqual(self.msg, q.dequeue()) + + +class ChannelStateTestCase(SupyTestCase): + def testPickleCopy(self): + c = irclib.ChannelState() + self.assertEqual(pickle.loads(pickle.dumps(c)), c) + c.addUser('jemfinch') + c1 = pickle.loads(pickle.dumps(c)) + self.assertEqual(c, c1) + c.removeUser('jemfinch') + self.failIf('jemfinch' in c.users) + self.failUnless('jemfinch' in c1.users) + + def testCopy(self): + c = irclib.ChannelState() + c.addUser('jemfinch') + c1 = copy.deepcopy(c) + c.removeUser('jemfinch') + self.failIf('jemfinch' in c.users) + self.failUnless('jemfinch' in c1.users) + + def testAddUser(self): + c = irclib.ChannelState() + c.addUser('foo') + self.failUnless('foo' in c.users) + self.failIf('foo' in c.ops) + self.failIf('foo' in c.voices) + self.failIf('foo' in c.halfops) + c.addUser('+bar') + self.failUnless('bar' in c.users) + self.failUnless('bar' in c.voices) + self.failIf('bar' in c.ops) + self.failIf('bar' in c.halfops) + c.addUser('%baz') + self.failUnless('baz' in c.users) + self.failUnless('baz' in c.halfops) + self.failIf('baz' in c.voices) + self.failIf('baz' in c.ops) + c.addUser('@quuz') + self.failUnless('quuz' in c.users) + self.failUnless('quuz' in c.ops) + self.failIf('quuz' in c.halfops) + self.failIf('quuz' in c.voices) + + +class IrcStateTestCase(SupyTestCase): + class FakeIrc: + nick = 'nick' + prefix = 'nick!user@host' + irc = FakeIrc() + def testAddMsgRemovesOpsProperly(self): + st = irclib.IrcState() + st.channels['#foo'] = irclib.ChannelState() + st.channels['#foo'].ops.add('bar') + m = ircmsgs.mode('#foo', ('-o', 'bar')) + st.addMsg(self.irc, m) + self.failIf('bar' in st.channels['#foo'].ops) + + def testNickChangesChangeChannelUsers(self): + st = irclib.IrcState() + st.channels['#foo'] = irclib.ChannelState() + st.channels['#foo'].addUser('@bar') + self.failUnless('bar' in st.channels['#foo'].users) + self.failUnless(st.channels['#foo'].isOp('bar')) + st.addMsg(self.irc, ircmsgs.IrcMsg(':bar!asfd@asdf.com NICK baz')) + self.failIf('bar' in st.channels['#foo'].users) + self.failIf(st.channels['#foo'].isOp('bar')) + self.failUnless('baz' in st.channels['#foo'].users) + self.failUnless(st.channels['#foo'].isOp('baz')) + + def testHistory(self): + oldconfmaxhistory = conf.supybot.protocols.irc.maxHistoryLength() + conf.supybot.protocols.irc.maxHistoryLength.setValue(10) + state = irclib.IrcState() + for msg in msgs: + try: + state.addMsg(self.irc, msg) + except Exception: + pass + self.failIf(len(state.history) > + conf.supybot.protocols.irc.maxHistoryLength()) + self.assertEqual(len(state.history), + conf.supybot.protocols.irc.maxHistoryLength()) + self.assertEqual(list(state.history), + msgs[len(msgs) - + conf.supybot.protocols.irc.maxHistoryLength():]) + conf.supybot.protocols.irc.maxHistoryLength.setValue(oldconfmaxhistory) + + def testWasteland005(self): + state = irclib.IrcState() + # Here we're testing if PREFIX works without the (ov) there. + state.addMsg(self.irc, ircmsgs.IrcMsg(':desolate.wasteland.org 005 jemfinch NOQUIT WATCH=128 SAFELIST MODES=6 MAXCHANNELS=10 MAXBANS=100 NICKLEN=30 TOPICLEN=307 KICKLEN=307 CHANTYPES=&# PREFIX=@+ NETWORK=DALnet SILENCE=10 :are available on this server')) + self.assertEqual(state.supported['prefix']['o'], '@') + self.assertEqual(state.supported['prefix']['v'], '+') + + def testEmptyTopic(self): + state = irclib.IrcState() + state.addMsg(self.irc, ircmsgs.topic('#foo')) + + def testPickleCopy(self): + state = irclib.IrcState() + self.assertEqual(state, pickle.loads(pickle.dumps(state))) + for msg in msgs: + try: + state.addMsg(self.irc, msg) + except Exception: + pass + self.assertEqual(state, pickle.loads(pickle.dumps(state))) + + def testCopy(self): + state = irclib.IrcState() + self.assertEqual(state, state.copy()) + for msg in msgs: + try: + state.addMsg(self.irc, msg) + except Exception: + pass + self.assertEqual(state, state.copy()) + + def testCopyCopiesChannels(self): + state = irclib.IrcState() + stateCopy = state.copy() + state.channels['#foo'] = None + self.failIf('#foo' in stateCopy.channels) + + def testJoin(self): + st = irclib.IrcState() + st.addMsg(self.irc, ircmsgs.join('#foo', prefix=self.irc.prefix)) + self.failUnless('#foo' in st.channels) + self.failUnless(self.irc.nick in st.channels['#foo'].users) + st.addMsg(self.irc, ircmsgs.join('#foo', prefix='foo!bar@baz')) + self.failUnless('foo' in st.channels['#foo'].users) + st2 = st.copy() + st.addMsg(self.irc, ircmsgs.quit(prefix='foo!bar@baz')) + self.failIf('foo' in st.channels['#foo'].users) + self.failUnless('foo' in st2.channels['#foo'].users) + + + def testEq(self): + state1 = irclib.IrcState() + state2 = irclib.IrcState() + self.assertEqual(state1, state2) + for msg in msgs: + try: + state1.addMsg(self.irc, msg) + state2.addMsg(self.irc, msg) + self.assertEqual(state1, state2) + except Exception: + pass + + def testHandlesModes(self): + st = irclib.IrcState() + st.addMsg(self.irc, ircmsgs.join('#foo', prefix=self.irc.prefix)) + self.failIf('bar' in st.channels['#foo'].ops) + st.addMsg(self.irc, ircmsgs.op('#foo', 'bar')) + self.failUnless('bar' in st.channels['#foo'].ops) + st.addMsg(self.irc, ircmsgs.deop('#foo', 'bar')) + self.failIf('bar' in st.channels['#foo'].ops) + + self.failIf('bar' in st.channels['#foo'].voices) + st.addMsg(self.irc, ircmsgs.voice('#foo', 'bar')) + self.failUnless('bar' in st.channels['#foo'].voices) + st.addMsg(self.irc, ircmsgs.devoice('#foo', 'bar')) + self.failIf('bar' in st.channels['#foo'].voices) + + self.failIf('bar' in st.channels['#foo'].halfops) + st.addMsg(self.irc, ircmsgs.halfop('#foo', 'bar')) + self.failUnless('bar' in st.channels['#foo'].halfops) + st.addMsg(self.irc, ircmsgs.dehalfop('#foo', 'bar')) + self.failIf('bar' in st.channels['#foo'].halfops) + + def testDoModeOnlyChannels(self): + st = irclib.IrcState() + self.assert_(st.addMsg(self.irc, ircmsgs.IrcMsg('MODE foo +i')) or 1) + + +class IrcTestCase(SupyTestCase): + def setUp(self): + self.irc = irclib.Irc('test') + _ = self.irc.takeMsg() # NICK + _ = self.irc.takeMsg() # USER + + def testPingResponse(self): + self.irc.feedMsg(ircmsgs.ping('123')) + self.assertEqual(ircmsgs.pong('123'), self.irc.takeMsg()) + + def test433Response(self): + # This is necessary; it won't change nick if irc.originalName==irc.nick + self.irc.nick = 'somethingElse' + self.irc.feedMsg(ircmsgs.IrcMsg('433 * %s :Nickname already in use.' %\ + self.irc.nick)) + msg = self.irc.takeMsg() + self.failUnless(msg.command == 'NICK' and msg.args[0] != self.irc.nick) + self.irc.feedMsg(ircmsgs.IrcMsg('433 * %s :Nickname already in use.' %\ + self.irc.nick)) + msg = self.irc.takeMsg() + self.failUnless(msg.command == 'NICK' and msg.args[0] != self.irc.nick) + + def testSendBeforeQueue(self): + while self.irc.takeMsg() is not None: + self.irc.takeMsg() + self.irc.queueMsg(ircmsgs.IrcMsg('NOTICE #foo bar')) + self.irc.sendMsg(ircmsgs.IrcMsg('PRIVMSG #foo yeah!')) + msg = self.irc.takeMsg() + self.failUnless(msg.command == 'PRIVMSG') + msg = self.irc.takeMsg() + self.failUnless(msg.command == 'NOTICE') + + def testNoMsgLongerThan512(self): + self.irc.queueMsg(ircmsgs.privmsg('whocares', 'x'*1000)) + msg = self.irc.takeMsg() + self.failUnless(len(msg) <= 512, 'len(msg) was %s' % len(msg)) + + def testReset(self): + for msg in msgs: + try: + self.irc.feedMsg(msg) + except: + pass + self.irc.reset() + self.failIf(self.irc.fastqueue) + self.failIf(self.irc.state.history) + self.failIf(self.irc.state.channels) + self.failIf(self.irc.outstandingPing) + + def testHistory(self): + self.irc.reset() + msg1 = ircmsgs.IrcMsg('PRIVMSG #linux :foo bar baz!') + self.irc.feedMsg(msg1) + self.assertEqual(self.irc.state.history[0], msg1) + msg2 = ircmsgs.IrcMsg('JOIN #sourcereview') + self.irc.feedMsg(msg2) + self.assertEqual(list(self.irc.state.history), [msg1, msg2]) + + +class IrcCallbackTestCase(SupyTestCase): + class FakeIrc: + pass + irc = FakeIrc() + def testName(self): + class UnnamedIrcCallback(irclib.IrcCallback): + pass + unnamed = UnnamedIrcCallback() + + class NamedIrcCallback(irclib.IrcCallback): + myName = 'foobar' + def name(self): + return self.myName + named = NamedIrcCallback() + self.assertEqual(unnamed.name(), unnamed.__class__.__name__) + self.assertEqual(named.name(), named.myName) + + def testDoCommand(self): + def makeCommand(msg): + return 'do' + msg.command.capitalize() + class DoCommandCatcher(irclib.IrcCallback): + def __init__(self): + self.L = [] + def __getattr__(self, attr): + self.L.append(attr) + return lambda *args: None + doCommandCatcher = DoCommandCatcher() + for msg in msgs: + doCommandCatcher(self.irc, msg) + commands = map(makeCommand, msgs) + self.assertEqual(doCommandCatcher.L, commands) + + def testFirstCommands(self): + try: + originalNick = conf.supybot.nick() + originalUser = conf.supybot.user() + originalPassword = conf.supybot.networks.test.password() + nick = 'nick' + conf.supybot.nick.setValue(nick) + user = 'user any user' + conf.supybot.user.setValue(user) + expected = [ircmsgs.nick(nick), ircmsgs.user('supybot', user)] + irc = irclib.Irc('test') + msgs = [irc.takeMsg()] + while msgs[-1] != None: + msgs.append(irc.takeMsg()) + msgs.pop() + self.assertEqual(msgs, expected) + password = 'password' + conf.supybot.networks.test.password.setValue(password) + irc = irclib.Irc('test') + msgs = [irc.takeMsg()] + while msgs[-1] != None: + msgs.append(irc.takeMsg()) + msgs.pop() + expected.insert(0, ircmsgs.password(password)) + self.assertEqual(msgs, expected) + finally: + conf.supybot.nick.setValue(nick) + conf.supybot.user.setValue(user) + conf.supybot.networks.test.password.setValue(password) + conf.supybot.nick.setValue(nick) + +# vim:set shiftwidth=4 tabstop=8 expandtab textwidth=78: + diff --git a/test/test_ircmsgs.py b/test/test_ircmsgs.py new file mode 100644 index 000000000..ef6012a31 --- /dev/null +++ b/test/test_ircmsgs.py @@ -0,0 +1,240 @@ +### +# Copyright (c) 2002-2004, Jeremiah Fincher +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND 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 COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +### + +from testsupport import * + +import copy +import pickle + +import supybot.ircmsgs as ircmsgs +import supybot.ircutils as ircutils + + +class IrcMsgTestCase(SupyTestCase): + def testLen(self): + for msg in msgs: + if msg.prefix: + strmsg = str(msg) + self.failIf(len(msg) != len(strmsg) and \ + strmsg.replace(':', '') == strmsg) + + def testRepr(self): + IrcMsg = ircmsgs.IrcMsg + ignore(IrcMsg) # Make pychecker happy. + for msg in msgs: + self.assertEqual(msg, eval(repr(msg))) + + def testStr(self): + for (rawmsg, msg) in zip(rawmsgs, msgs): + strmsg = str(msg).strip() + self.failIf(rawmsg != strmsg and \ + strmsg.replace(':', '') == strmsg) + + def testEq(self): + for msg in msgs: + self.assertEqual(msg, msg) + self.failIf(msgs[0] == []) # Comparison to unhashable type. + + def testNe(self): + for msg in msgs: + self.failIf(msg != msg) + +## def testImmutability(self): +## s = 'something else' +## t = ('foo', 'bar', 'baz') +## for msg in msgs: +## self.assertRaises(AttributeError, setattr, msg, 'prefix', s) +## self.assertRaises(AttributeError, setattr, msg, 'nick', s) +## self.assertRaises(AttributeError, setattr, msg, 'user', s) +## self.assertRaises(AttributeError, setattr, msg, 'host', s) +## self.assertRaises(AttributeError, setattr, msg, 'command', s) +## self.assertRaises(AttributeError, setattr, msg, 'args', t) +## if msg.args: +## def setArgs(msg): +## msg.args[0] = s +## self.assertRaises(TypeError, setArgs, msg) + + def testInit(self): + for msg in msgs: + self.assertEqual(msg, ircmsgs.IrcMsg(prefix=msg.prefix, + command=msg.command, + args=msg.args)) + self.assertEqual(msg, ircmsgs.IrcMsg(msg=msg)) + self.assertRaises(ValueError, + ircmsgs.IrcMsg, + args=('foo', 'bar'), + prefix='foo!bar@baz') + + def testPickleCopy(self): + for msg in msgs: + self.assertEqual(msg, pickle.loads(pickle.dumps(msg))) + self.assertEqual(msg, copy.copy(msg)) + + def testHashNotZero(self): + zeroes = 0 + for msg in msgs: + if hash(msg) == 0: + zeroes += 1 + self.failIf(zeroes > (len(msgs)/10), 'Too many zero hashes.') + + def testMsgKeywordHandledProperly(self): + msg = ircmsgs.notice('foo', 'bar') + msg2 = ircmsgs.IrcMsg(msg=msg, command='PRIVMSG') + self.assertEqual(msg2.command, 'PRIVMSG') + self.assertEqual(msg2.args, msg.args) + + def testMalformedIrcMsgRaised(self): + self.assertRaises(ircmsgs.MalformedIrcMsg, ircmsgs.IrcMsg, ':foo') + self.assertRaises(ircmsgs.MalformedIrcMsg, ircmsgs.IrcMsg, + args=('biff',), prefix='foo!bar@baz') + + def testTags(self): + m = ircmsgs.privmsg('foo', 'bar') + self.failIf(m.repliedTo) + m.tag('repliedTo') + self.failUnless(m.repliedTo) + m.tag('repliedTo') + self.failUnless(m.repliedTo) + m.tag('repliedTo', 12) + self.assertEqual(m.repliedTo, 12) + +class FunctionsTestCase(SupyTestCase): + def testIsAction(self): + L = [':jemfinch!~jfincher@ts26-2.homenet.ohio-state.edu PRIVMSG' + ' #sourcereview :ACTION does something', + ':supybot!~supybot@underthemain.net PRIVMSG #sourcereview ' + ':ACTION beats angryman senseless with a Unix manual (#2)', + ':supybot!~supybot@underthemain.net PRIVMSG #sourcereview ' + ':ACTION beats ang senseless with a 50lb Unix manual (#2)', + ':supybot!~supybot@underthemain.net PRIVMSG #sourcereview ' + ':ACTION resizes angryman\'s terminal to 40x24 (#16)'] + msgs = map(ircmsgs.IrcMsg, L) + for msg in msgs: + self.failUnless(ircmsgs.isAction(msg)) + + def testIsActionIsntStupid(self): + m = ircmsgs.privmsg('#x', '\x01NOTANACTION foo\x01') + self.failIf(ircmsgs.isAction(m)) + m = ircmsgs.privmsg('#x', '\x01ACTION foo bar\x01') + self.failUnless(ircmsgs.isAction(m)) + + def testIsCtcp(self): + self.failUnless(ircmsgs.isCtcp(ircmsgs.privmsg('foo', + '\x01VERSION\x01'))) + self.failIf(ircmsgs.isCtcp(ircmsgs.privmsg('foo', '\x01'))) + + def testIsActionFalseWhenNoSpaces(self): + msg = ircmsgs.IrcMsg('PRIVMSG #foo :\x01ACTIONfoobar\x01') + self.failIf(ircmsgs.isAction(msg)) + + def testUnAction(self): + s = 'foo bar baz' + msg = ircmsgs.action('#foo', s) + self.assertEqual(ircmsgs.unAction(msg), s) + + def testBan(self): + channel = '#osu' + ban = '*!*@*.edu' + exception = '*!*@*ohio-state.edu' + noException = ircmsgs.ban(channel, ban) + self.assertEqual(ircutils.separateModes(noException.args[1:]), + [('+b', ban)]) + withException = ircmsgs.ban(channel, ban, exception) + self.assertEqual(ircutils.separateModes(withException.args[1:]), + [('+b', ban), ('+e', exception)]) + + def testBans(self): + channel = '#osu' + bans = ['*!*@*', 'jemfinch!*@*'] + exceptions = ['*!*@*ohio-state.edu'] + noException = ircmsgs.bans(channel, bans) + self.assertEqual(ircutils.separateModes(noException.args[1:]), + [('+b', bans[0]), ('+b', bans[1])]) + withExceptions = ircmsgs.bans(channel, bans, exceptions) + self.assertEqual(ircutils.separateModes(withExceptions.args[1:]), + [('+b', bans[0]), ('+b', bans[1]), + ('+e', exceptions[0])]) + + def testUnban(self): + channel = '#supybot' + ban = 'foo!bar@baz' + self.assertEqual(str(ircmsgs.unban(channel, ban)), + 'MODE %s -b :%s\r\n' % (channel, ban)) + + def testJoin(self): + channel = '#osu' + key = 'michiganSucks' + self.assertEqual(ircmsgs.join(channel).args, ('#osu',)) + self.assertEqual(ircmsgs.join(channel, key).args, + ('#osu', 'michiganSucks')) + + def testJoins(self): + channels = ['#osu', '#umich'] + keys = ['michiganSucks', 'osuSucks'] + self.assertEqual(ircmsgs.joins(channels).args, ('#osu,#umich',)) + self.assertEqual(ircmsgs.joins(channels, keys).args, + ('#osu,#umich', 'michiganSucks,osuSucks')) + keys.pop() + self.assertEqual(ircmsgs.joins(channels, keys).args, + ('#osu,#umich', 'michiganSucks')) + + def testQuit(self): + self.failUnless(ircmsgs.quit(prefix='foo!bar@baz')) + + def testOps(self): + m = ircmsgs.ops('#foo', ['foo', 'bar', 'baz']) + self.assertEqual(str(m), 'MODE #foo +ooo foo bar :baz\r\n') + + def testDeops(self): + m = ircmsgs.deops('#foo', ['foo', 'bar', 'baz']) + self.assertEqual(str(m), 'MODE #foo -ooo foo bar :baz\r\n') + + def testVoices(self): + m = ircmsgs.voices('#foo', ['foo', 'bar', 'baz']) + self.assertEqual(str(m), 'MODE #foo +vvv foo bar :baz\r\n') + + def testDevoices(self): + m = ircmsgs.devoices('#foo', ['foo', 'bar', 'baz']) + self.assertEqual(str(m), 'MODE #foo -vvv foo bar :baz\r\n') + + def testHalfops(self): + m = ircmsgs.halfops('#foo', ['foo', 'bar', 'baz']) + self.assertEqual(str(m), 'MODE #foo +hhh foo bar :baz\r\n') + + def testDehalfops(self): + m = ircmsgs.dehalfops('#foo', ['foo', 'bar', 'baz']) + self.assertEqual(str(m), 'MODE #foo -hhh foo bar :baz\r\n') + + def testMode(self): + m = ircmsgs.mode('#foo', ('-b', 'foo!bar@baz')) + s = str(m) + self.assertEqual(s, 'MODE #foo -b :foo!bar@baz\r\n') + + +# vim:set shiftwidth=4 tabstop=8 expandtab textwidth=78: diff --git a/test/test_ircutils.py b/test/test_ircutils.py new file mode 100644 index 000000000..07753951d --- /dev/null +++ b/test/test_ircutils.py @@ -0,0 +1,354 @@ +### +# Copyright (c) 2002-2004, Jeremiah Fincher +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND 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 COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +### + + +from testsupport import * + +import copy +import random + +import supybot.ircmsgs as ircmsgs +import supybot.ircutils as ircutils + +class FunctionsTestCase(SupyTestCase): + hostmask = 'foo!bar@baz' + def testHostmaskPatternEqual(self): + for msg in msgs: + if msg.prefix and ircutils.isUserHostmask(msg.prefix): + s = msg.prefix + self.failUnless(ircutils.hostmaskPatternEqual(s, s), + '%r did not match itself.' % s) + banmask = ircutils.banmask(s) + self.failUnless(ircutils.hostmaskPatternEqual(banmask, s), + '%r did not match %r' % (s, banmask)) + s = 'supybot!~supybot@dhcp065-024-075-056.columbus.rr.com' + self.failUnless(ircutils.hostmaskPatternEqual(s, s)) + s = 'jamessan|work!~jamessan@209-6-166-196.c3-0.' \ + 'abr-ubr1.sbo-abr.ma.cable.rcn.com' + self.failUnless(ircutils.hostmaskPatternEqual(s, s)) + + def testIsUserHostmask(self): + self.failUnless(ircutils.isUserHostmask(self.hostmask)) + self.failUnless(ircutils.isUserHostmask('a!b@c')) + self.failIf(ircutils.isUserHostmask('!bar@baz')) + self.failIf(ircutils.isUserHostmask('!@baz')) + self.failIf(ircutils.isUserHostmask('!bar@')) + self.failIf(ircutils.isUserHostmask('!@')) + self.failIf(ircutils.isUserHostmask('foo!@baz')) + self.failIf(ircutils.isUserHostmask('foo!bar@')) + self.failIf(ircutils.isUserHostmask('')) + self.failIf(ircutils.isUserHostmask('!')) + self.failIf(ircutils.isUserHostmask('@')) + self.failIf(ircutils.isUserHostmask('!bar@baz')) + + def testIsChannel(self): + self.failUnless(ircutils.isChannel('#')) + self.failUnless(ircutils.isChannel('&')) + self.failUnless(ircutils.isChannel('+')) + self.failUnless(ircutils.isChannel('!')) + self.failUnless(ircutils.isChannel('#foo')) + self.failUnless(ircutils.isChannel('&foo')) + self.failUnless(ircutils.isChannel('+foo')) + self.failUnless(ircutils.isChannel('!foo')) + self.failIf(ircutils.isChannel('#foo bar')) + self.failIf(ircutils.isChannel('#foo,bar')) + self.failIf(ircutils.isChannel('#foobar\x07')) + self.failIf(ircutils.isChannel('foo')) + self.failIf(ircutils.isChannel('')) + + def testBold(self): + s = ircutils.bold('foo') + self.assertEqual(s[0], '\x02') + self.assertEqual(s[-1], '\x02') + + def testUnderline(self): + s = ircutils.underline('foo') + self.assertEqual(s[0], '\x1f') + self.assertEqual(s[-1], '\x1f') + + def testReverse(self): + s = ircutils.reverse('foo') + self.assertEqual(s[0], '\x16') + self.assertEqual(s[-1], '\x16') + + def testMircColor(self): + # No colors provided should return the same string + s = 'foo' + self.assertEqual(s, ircutils.mircColor(s)) + # Test positional args + self.assertEqual('\x0300foo\x03', ircutils.mircColor(s, 'white')) + self.assertEqual('\x031,02foo\x03',ircutils.mircColor(s,'black','blue')) + self.assertEqual('\x03,03foo\x03', ircutils.mircColor(s, None, 'green')) + # Test keyword args + self.assertEqual('\x0304foo\x03', ircutils.mircColor(s, fg='red')) + self.assertEqual('\x03,05foo\x03', ircutils.mircColor(s, bg='brown')) + self.assertEqual('\x036,07foo\x03', + ircutils.mircColor(s, bg='orange', fg='purple')) + +# Commented out because we don't map numbers to colors anymore. +## def testMircColors(self): +## # Make sure all (k, v) pairs are also (v, k) pairs. +## for (k, v) in ircutils.mircColors.items(): +## if k: +## self.assertEqual(ircutils.mircColors[v], k) + + def testStripBold(self): + self.assertEqual(ircutils.stripBold(ircutils.bold('foo')), 'foo') + + def testStripColor(self): + self.assertEqual(ircutils.stripColor('\x02bold\x0302,04foo\x03bar\x0f'), + '\x02boldfoobar\x0f') + self.assertEqual(ircutils.stripColor('\x03foo\x03'), 'foo') + self.assertEqual(ircutils.stripColor('\x03foo\x0F'), 'foo\x0F') + self.assertEqual(ircutils.stripColor('\x0312foo\x03'), 'foo') + self.assertEqual(ircutils.stripColor('\x0312,14foo\x03'), 'foo') + self.assertEqual(ircutils.stripColor('\x03,14foo\x03'), 'foo') + self.assertEqual(ircutils.stripColor('\x03,foo\x03'), ',foo') + self.assertEqual(ircutils.stripColor('\x0312foo\x0F'), 'foo\x0F') + self.assertEqual(ircutils.stripColor('\x0312,14foo\x0F'), 'foo\x0F') + self.assertEqual(ircutils.stripColor('\x03,14foo\x0F'), 'foo\x0F') + self.assertEqual(ircutils.stripColor('\x03,foo\x0F'), ',foo\x0F') + + def testStripReverse(self): + self.assertEqual(ircutils.stripReverse(ircutils.reverse('foo')), 'foo') + + def testStripUnderline(self): + self.assertEqual(ircutils.stripUnderline(ircutils.underline('foo')), + 'foo') + + def testStripFormatting(self): + self.assertEqual(ircutils.stripFormatting(ircutils.bold('foo')), 'foo') + self.assertEqual(ircutils.stripFormatting(ircutils.reverse('foo')), + 'foo') + self.assertEqual(ircutils.stripFormatting(ircutils.underline('foo')), + 'foo') + self.assertEqual(ircutils.stripFormatting('\x02bold\x0302,04foo\x03' + 'bar\x0f'), + 'boldfoobar') + s = ircutils.mircColor('[', 'blue') + ircutils.bold('09:21') + self.assertEqual(ircutils.stripFormatting(s), '[09:21') + + def testSafeArgument(self): + s = 'I have been running for 9 seconds' + bolds = ircutils.bold(s) + colors = ircutils.mircColor(s, 'pink', 'orange') + self.assertEqual(s, ircutils.safeArgument(s)) + self.assertEqual(bolds, ircutils.safeArgument(bolds)) + self.assertEqual(colors, ircutils.safeArgument(colors)) + + def testSafeArgumentConvertsToString(self): + self.assertEqual('1', ircutils.safeArgument(1)) + self.assertEqual(str(None), ircutils.safeArgument(None)) + + def testIsNick(self): + try: + original = conf.supybot.protocols.irc.strictRfc() + conf.supybot.protocols.irc.strictRfc.setValue(True) + self.failUnless(ircutils.isNick('jemfinch')) + self.failUnless(ircutils.isNick('jemfinch0')) + self.failUnless(ircutils.isNick('[0]')) + self.failUnless(ircutils.isNick('{jemfinch}')) + self.failUnless(ircutils.isNick('[jemfinch]')) + self.failUnless(ircutils.isNick('jem|finch')) + self.failUnless(ircutils.isNick('\\```')) + self.failUnless(ircutils.isNick('`')) + self.failUnless(ircutils.isNick('A')) + self.failIf(ircutils.isNick('')) + self.failIf(ircutils.isNick('8foo')) + self.failIf(ircutils.isNick('10')) + conf.supybot.protocols.irc.strictRfc.setValue(False) + self.failUnless(ircutils.isNick('services@something.undernet.net')) + finally: + conf.supybot.protocols.irc.strictRfc.setValue(original) + + def testIsNickNeverAllowsSpaces(self): + try: + original = conf.supybot.protocols.irc.strictRfc() + conf.supybot.protocols.irc.strictRfc.setValue(True) + self.failIf(ircutils.isNick('foo bar')) + conf.supybot.protocols.irc.strictRfc.setValue(False) + self.failIf(ircutils.isNick('foo bar')) + finally: + conf.supybot.protocols.irc.strictRfc.setValue(original) + + + + def testBanmask(self): + for msg in msgs: + if ircutils.isUserHostmask(msg.prefix): + banmask = ircutils.banmask(msg.prefix) + self.failUnless(ircutils.hostmaskPatternEqual(banmask, + msg.prefix), + '%r didn\'t match %r' % (msg.prefix, banmask)) + self.assertEqual(ircutils.banmask('foobar!user@host'), '*!*@host') + self.assertEqual(ircutils.banmask('foo!bar@2001::'), '*!*@2001::*') + + def testSeparateModes(self): + self.assertEqual(ircutils.separateModes(['+ooo', 'x', 'y', 'z']), + [('+o', 'x'), ('+o', 'y'), ('+o', 'z')]) + self.assertEqual(ircutils.separateModes(['+o-o', 'x', 'y']), + [('+o', 'x'), ('-o', 'y')]) + self.assertEqual(ircutils.separateModes(['+s-o', 'x']), + [('+s', None), ('-o', 'x')]) + self.assertEqual(ircutils.separateModes(['+sntl', '100']), + [('+s', None),('+n', None),('+t', None),('+l', 100)]) + + def testNickFromHostmask(self): + self.assertEqual(ircutils.nickFromHostmask('nick!user@host.domain.tld'), + 'nick') + + def testToLower(self): + self.assertEqual('jemfinch', ircutils.toLower('jemfinch')) + self.assertEqual('{}|^', ircutils.toLower('[]\\~')) + + def testReplyTo(self): + prefix = 'foo!bar@baz' + channel = ircmsgs.privmsg('#foo', 'bar baz', prefix=prefix) + private = ircmsgs.privmsg('jemfinch', 'bar baz', prefix=prefix) + self.assertEqual(ircutils.replyTo(channel), channel.args[0]) + self.assertEqual(ircutils.replyTo(private), private.nick) + + def testJoinModes(self): + plusE = ('+e', '*!*@*ohio-state.edu') + plusB = ('+b', '*!*@*umich.edu') + minusL = ('-l', None) + modes = [plusB, plusE, minusL] + self.assertEqual(ircutils.joinModes(modes), + ['+be-l', plusB[1], plusE[1]]) + + def testDccIpStuff(self): + def randomIP(): + def rand(): + return random.randrange(0, 256) + return '.'.join(map(str, [rand(), rand(), rand(), rand()])) + for _ in range(100): # 100 should be good :) + ip = randomIP() + self.assertEqual(ip, ircutils.unDccIP(ircutils.dccIP(ip))) + + +class IrcDictTestCase(SupyTestCase): + def test(self): + d = ircutils.IrcDict() + d['#FOO'] = 'bar' + self.assertEqual(d['#FOO'], 'bar') + self.assertEqual(d['#Foo'], 'bar') + self.assertEqual(d['#foo'], 'bar') + del d['#fOO'] + d['jemfinch{}'] = 'bar' + self.assertEqual(d['jemfinch{}'], 'bar') + self.assertEqual(d['jemfinch[]'], 'bar') + self.assertEqual(d['JEMFINCH[]'], 'bar') + + def testKeys(self): + d = ircutils.IrcDict() + self.assertEqual(d.keys(), []) + + def testSetdefault(self): + d = ircutils.IrcDict() + d.setdefault('#FOO', []).append(1) + self.assertEqual(d['#foo'], [1]) + self.assertEqual(d['#fOO'], [1]) + self.assertEqual(d['#FOO'], [1]) + + def testGet(self): + d = ircutils.IrcDict() + self.assertEqual(d.get('#FOO'), None) + d['#foo'] = 1 + self.assertEqual(d.get('#FOO'), 1) + + def testContains(self): + d = ircutils.IrcDict() + d['#FOO'] = None + self.failUnless('#foo' in d) + d['#fOOBAR[]'] = None + self.failUnless('#foobar{}' in d) + + def testGetSetItem(self): + d = ircutils.IrcDict() + d['#FOO'] = 12 + self.assertEqual(12, d['#foo']) + d['#fOOBAR[]'] = 'blah' + self.assertEqual('blah', d['#foobar{}']) + + def testCopyable(self): + d = ircutils.IrcDict() + d['foo'] = 'bar' + self.failUnless(d == copy.copy(d)) + self.failUnless(d == copy.deepcopy(d)) + + +class IrcSetTestCase(SupyTestCase): + def test(self): + s = ircutils.IrcSet() + s.add('foo') + s.add('bar') + self.failUnless('foo' in s) + self.failUnless('FOO' in s) + s.discard('alfkj') + s.remove('FOo') + self.failIf('foo' in s) + self.failIf('FOo' in s) + + def testCopy(self): + s = ircutils.IrcSet() + s.add('foo') + s.add('bar') + s1 = copy.deepcopy(s) + self.failUnless('foo' in s) + self.failUnless('FOO' in s) + s.discard('alfkj') + s.remove('FOo') + self.failIf('foo' in s) + self.failIf('FOo' in s) + self.failUnless('foo' in s1) + self.failUnless('FOO' in s1) + s1.discard('alfkj') + s1.remove('FOo') + self.failIf('foo' in s1) + self.failIf('FOo' in s1) + + +class IrcStringTestCase(SupyTestCase): + def testEquality(self): + self.assertEqual('#foo', ircutils.IrcString('#foo')) + self.assertEqual('#foo', ircutils.IrcString('#FOO')) + self.assertEqual('#FOO', ircutils.IrcString('#foo')) + self.assertEqual('#FOO', ircutils.IrcString('#FOO')) + self.assertEqual(hash(ircutils.IrcString('#FOO')), + hash(ircutils.IrcString('#foo'))) + + def testInequality(self): + s1 = 'supybot' + s2 = ircutils.IrcString('Supybot') + self.failUnless(s1 == s2) + self.failIf(s1 != s2) + + +# vim:set shiftwidth=4 tabstop=8 expandtab textwidth=78: + diff --git a/test/test_plugins.py b/test/test_plugins.py new file mode 100644 index 000000000..7248341b3 --- /dev/null +++ b/test/test_plugins.py @@ -0,0 +1,44 @@ +### +# Copyright (c) 2002-2004, Jeremiah Fincher +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND 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 COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +### + +from testsupport import * + +import sets + +import supybot.irclib as irclib +import supybot.plugins as plugins + + + + + + + + + diff --git a/test/test_privmsgs.py b/test/test_privmsgs.py new file mode 100644 index 000000000..090e474af --- /dev/null +++ b/test/test_privmsgs.py @@ -0,0 +1,65 @@ +### +# Copyright (c) 2002-2004, Jeremiah Fincher +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND 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 COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +### + +from testsupport import * + +import supybot.ircmsgs as ircmsgs +import supybot.privmsgs as privmsgs +import supybot.callbacks as callbacks + +class FunctionsTest(SupyTestCase): + def testGetChannel(self): + channel = '#foo' + msg = ircmsgs.privmsg(channel, 'foo bar baz') + args = msg.args[1].split() + originalArgs = args[:] + self.assertEqual(privmsgs.getChannel(msg, args), channel) + self.assertEqual(args, originalArgs) + msg = ircmsgs.privmsg('nick', '%s bar baz' % channel) + args = msg.args[1].split() + originalArgs = args[:] + self.assertEqual(privmsgs.getChannel(msg, args), channel) + self.assertEqual(args, originalArgs[1:]) + + def testGetArgs(self): + args = ['foo', 'bar', 'baz'] + self.assertEqual(privmsgs.getArgs(args), ' '.join(args)) + self.assertEqual(privmsgs.getArgs(args, required=2), + [args[0], ' '.join(args[1:])]) + self.assertEqual(privmsgs.getArgs(args, required=3), args) + self.assertRaises(callbacks.ArgumentError, + privmsgs.getArgs, args, required=4) + self.assertEqual(privmsgs.getArgs(args, required=3, optional=1), + args + ['']) + self.assertEqual(privmsgs.getArgs(args, required=0, optional=1), + ' '.join(args)) + + +# vim:set shiftwidth=4 tabstop=8 expandtab textwidth=78: + diff --git a/test/test_registry.py b/test/test_registry.py new file mode 100644 index 000000000..d956843ab --- /dev/null +++ b/test/test_registry.py @@ -0,0 +1,171 @@ +### +# Copyright (c) 2004, Jeremiah Fincher +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND 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 COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +### + +from testsupport import * + +import re + +import supybot.conf as conf +import supybot.registry as registry + +join = registry.join +split = registry.split +escape = registry.escape +unescape = registry.unescape +class FunctionsTestCase(SupyTestCase): + def testEscape(self): + self.assertEqual('foo', escape('foo')) + self.assertEqual('foo\\.bar', escape('foo.bar')) + self.assertEqual('foo\\:bar', escape('foo:bar')) + + def testUnescape(self): + self.assertEqual('foo', unescape('foo')) + self.assertEqual('foo.bar', unescape('foo\\.bar')) + self.assertEqual('foo:bar', unescape('foo\\:bar')) + + def testEscapeAndUnescapeAreInverses(self): + for s in ['foo', 'foo.bar']: + self.assertEqual(s, unescape(escape(s))) + self.assertEqual(escape(s), escape(unescape(escape(s)))) + + def testSplit(self): + self.assertEqual(['foo'], split('foo')) + self.assertEqual(['foo', 'bar'], split('foo.bar')) + self.assertEqual(['foo.bar'], split('foo\\.bar')) + + def testJoin(self): + self.assertEqual('foo', join(['foo'])) + self.assertEqual('foo.bar', join(['foo', 'bar'])) + self.assertEqual('foo\\.bar', join(['foo.bar'])) + + def testJoinAndSplitAreInverses(self): + for s in ['foo', 'foo.bar', 'foo\\.bar']: + self.assertEqual(s, join(split(s))) + self.assertEqual(split(s), split(join(split(s)))) + + + +class ValuesTestCase(SupyTestCase): + def testBoolean(self): + v = registry.Boolean(True, """Help""") + self.failUnless(v()) + v.setValue(False) + self.failIf(v()) + v.set('True') + self.failUnless(v()) + v.set('False') + self.failIf(v()) + v.set('On') + self.failUnless(v()) + v.set('Off') + self.failIf(v()) + v.set('enable') + self.failUnless(v()) + v.set('disable') + self.failIf(v()) + v.set('toggle') + self.failUnless(v()) + v.set('toggle') + self.failIf(v()) + + def testInteger(self): + v = registry.Integer(1, 'help') + self.assertEqual(v(), 1) + v.setValue(10) + self.assertEqual(v(), 10) + v.set('100') + self.assertEqual(v(), 100) + v.set('-1000') + self.assertEqual(v(), -1000) + + def testPositiveInteger(self): + v = registry.PositiveInteger(1, 'help') + self.assertEqual(v(), 1) + self.assertRaises(registry.InvalidRegistryValue, v.setValue, -1) + self.assertRaises(registry.InvalidRegistryValue, v.set, '-1') + + def testFloat(self): + v = registry.Float(1.0, 'help') + self.assertEqual(v(), 1.0) + v.setValue(10) + self.assertEqual(v(), 10.0) + v.set('0') + self.assertEqual(v(), 0.0) + + def testString(self): + v = registry.String('foo', 'help') + self.assertEqual(v(), 'foo') + v.setValue('bar') + self.assertEqual(v(), 'bar') + v.set('"biff"') + self.assertEqual(v(), 'biff') + v.set("'buff'") + self.assertEqual(v(), 'buff') + v.set('"xyzzy') + self.assertEqual(v(), '"xyzzy') + + def testNormalizedString(self): + v = registry.NormalizedString("""foo + bar baz + biff + """, 'help') + self.assertEqual(v(), 'foo bar baz biff') + v.setValue('foo bar baz') + self.assertEqual(v(), 'foo bar baz') + v.set('"foo bar baz"') + self.assertEqual(v(), 'foo bar baz') + + def testStringSurroundedBySpaces(self): + v = registry.StringSurroundedBySpaces('foo', 'help') + self.assertEqual(v(), ' foo ') + v.setValue('||') + self.assertEqual(v(), ' || ') + v.set('&&') + self.assertEqual(v(), ' && ') + + def testCommaSeparatedListOfStrings(self): + v = registry.CommaSeparatedListOfStrings(['foo', 'bar'], 'help') + self.assertEqual(v(), ['foo', 'bar']) + v.setValue(['foo', 'bar', 'baz']) + self.assertEqual(v(), ['foo', 'bar', 'baz']) + v.set('foo,bar') + self.assertEqual(v(), ['foo', 'bar']) + + def testRegexp(self): + v = registry.Regexp(None, 'help') + self.assertEqual(v(), None) + v.set('m/foo/') + self.failUnless(v().match('foo')) + v.set('') + self.assertEqual(v(), None) + self.assertRaises(registry.InvalidRegistryValue, + v.setValue, re.compile(r'foo')) + + +# vim:set shiftwidth=4 tabstop=8 expandtab textwidth=78: diff --git a/test/test_schedule.py b/test/test_schedule.py new file mode 100644 index 000000000..30c02da8d --- /dev/null +++ b/test/test_schedule.py @@ -0,0 +1,78 @@ +### +# Copyright (c) 2002-2004, Jeremiah Fincher +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND 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 COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +### + +from testsupport import * + +import time + +import supybot.schedule as schedule + +class TestSchedule(SupyTestCase): + def testSchedule(self): + sched = schedule.Schedule() + i = [0] + def add10(): + i[0] = i[0] + 10 + def add1(): + i[0] = i[0] + 1 + + sched.addEvent(add10, time.time() + 3) + sched.addEvent(add1, time.time() + 1) + time.sleep(1.2) + sched.run() + self.assertEqual(i[0], 1) + time.sleep(1.9) + sched.run() + self.assertEqual(i[0], 11) + + sched.addEvent(add10, time.time() + 3, 'test') + sched.run() + self.assertEqual(i[0], 11) + sched.removeEvent('test') + self.assertEqual(i[0], 11) + time.sleep(3) + self.assertEqual(i[0], 11) + + def testReschedule(self): + sched = schedule.Schedule() + i = [0] + def inc(): + i[0] += 1 + n = sched.addEvent(inc, time.time() + 1) + sched.rescheduleEvent(n, time.time() + 3) + time.sleep(1.2) + sched.run() + self.assertEqual(i[0], 0) + time.sleep(2) + sched.run() + self.assertEqual(i[0], 1) + + +# vim:set shiftwidth=4 tabstop=8 expandtab textwidth=78: + diff --git a/test/test_standardSubstitute.py b/test/test_standardSubstitute.py new file mode 100644 index 000000000..bef495199 --- /dev/null +++ b/test/test_standardSubstitute.py @@ -0,0 +1,91 @@ +### +# Copyright (c) 2002-2004, Jeremiah Fincher +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND 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 COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +### + +from testsupport import * + +import sets + +import supybot.irclib as irclib +import supybot.ircutils as ircutils + +class holder: + users = sets.Set(map(str, range(1000))) + +class FunctionsTestCase(SupyTestCase): + class irc: + class state: + channels = {'#foo': holder()} + nick = 'foobar' + + def testStandardSubstitute(self): + f = ircutils.standardSubstitute + msg = ircmsgs.privmsg('#foo', 'filler', prefix='biff!quux@xyzzy') + s = f(self.irc, msg, '$rand') + try: + int(s) + except ValueError: + self.fail('$rand wasn\'t an int.') + s = f(self.irc, msg, '$randomInt') + try: + int(s) + except ValueError: + self.fail('$randomint wasn\'t an int.') + self.assertEqual(f(self.irc, msg, '$botnick'), self.irc.nick) + self.assertEqual(f(self.irc, msg, '$who'), msg.nick) + self.assertEqual(f(self.irc, msg, '$WHO'), + msg.nick, 'stand. sub. not case-insensitive.') + self.assertEqual(f(self.irc, msg, '$nick'), msg.nick) + self.assertNotEqual(f(self.irc, msg, '$randomdate'), '$randomdate') + q = f(self.irc,msg,'$randomdate\t$randomdate') + dl = q.split('\t') + if dl[0] == dl[1]: + self.fail ('Two $randomdates in the same string were the same') + q = f(self.irc, msg, '$randomint\t$randomint') + dl = q.split('\t') + if dl[0] == dl[1]: + self.fail ('Two $randomints in the same string were the same') + self.assertNotEqual(f(self.irc, msg, '$today'), '$today') + self.assertNotEqual(f(self.irc, msg, '$now'), '$now') + n = f(self.irc, msg, '$randnick') + self.failUnless(n in self.irc.state.channels['#foo'].users) + n = f(self.irc, msg, '$randomnick') + self.failUnless(n in self.irc.state.channels['#foo'].users) + n = f(self.irc, msg, '$randomnick '*100) + L = n.split() + self.failIf(all(L[0].__eq__, L), 'all $randomnicks were the same') + c = f(self.irc, msg, '$channel') + self.assertEqual(c, msg.args[0]) + + + + + + + + diff --git a/test/test_structures.py b/test/test_structures.py new file mode 100644 index 000000000..bd4206b8a --- /dev/null +++ b/test/test_structures.py @@ -0,0 +1,614 @@ +### +# Copyright (c) 2002-2004, Jeremiah Fincher +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND 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 COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +### + +from testsupport import * + +import pickle + +from supybot.structures import * + +class RingBufferTestCase(SupyTestCase): + def testInit(self): + self.assertRaises(ValueError, RingBuffer, -1) + self.assertRaises(ValueError, RingBuffer, 0) + self.assertEqual(range(10), list(RingBuffer(10, range(10)))) + + def testLen(self): + b = RingBuffer(3) + self.assertEqual(0, len(b)) + b.append(1) + self.assertEqual(1, len(b)) + b.append(2) + self.assertEqual(2, len(b)) + b.append(3) + self.assertEqual(3, len(b)) + b.append(4) + self.assertEqual(3, len(b)) + b.append(5) + self.assertEqual(3, len(b)) + + def testNonzero(self): + b = RingBuffer(3) + self.failIf(b) + b.append(1) + self.failUnless(b) + + def testAppend(self): + b = RingBuffer(3) + self.assertEqual([], list(b)) + b.append(1) + self.assertEqual([1], list(b)) + b.append(2) + self.assertEqual([1, 2], list(b)) + b.append(3) + self.assertEqual([1, 2, 3], list(b)) + b.append(4) + self.assertEqual([2, 3, 4], list(b)) + b.append(5) + self.assertEqual([3, 4, 5], list(b)) + b.append(6) + self.assertEqual([4, 5, 6], list(b)) + + def testContains(self): + b = RingBuffer(3, range(3)) + self.failUnless(0 in b) + self.failUnless(1 in b) + self.failUnless(2 in b) + self.failIf(3 in b) + + def testGetitem(self): + L = range(10) + b = RingBuffer(len(L), L) + for i in range(len(b)): + self.assertEqual(L[i], b[i]) + for i in range(len(b)): + self.assertEqual(L[-i], b[-i]) + for i in range(len(b)): + b.append(i) + for i in range(len(b)): + self.assertEqual(L[i], b[i]) + for i in range(len(b)): + self.assertEqual(list(b), list(b[:i]) + list(b[i:])) + + def testSliceGetitem(self): + L = range(10) + b = RingBuffer(len(L), L) + for i in range(len(b)): + self.assertEqual(L[:i], b[:i]) + self.assertEqual(L[i:], b[i:]) + self.assertEqual(L[i:len(b)-i], b[i:len(b)-i]) + self.assertEqual(L[:-i], b[:-i]) + self.assertEqual(L[-i:], b[-i:]) + self.assertEqual(L[i:-i], b[i:-i]) + for i in range(len(b)): + b.append(i) + for i in range(len(b)): + self.assertEqual(L[:i], b[:i]) + self.assertEqual(L[i:], b[i:]) + self.assertEqual(L[i:len(b)-i], b[i:len(b)-i]) + self.assertEqual(L[:-i], b[:-i]) + self.assertEqual(L[-i:], b[-i:]) + self.assertEqual(L[i:-i], b[i:-i]) + + def testSetitem(self): + L = range(10) + b = RingBuffer(len(L), [0]*len(L)) + for i in range(len(b)): + b[i] = i + for i in range(len(b)): + self.assertEqual(b[i], i) + for i in range(len(b)): + b.append(0) + for i in range(len(b)): + b[i] = i + for i in range(len(b)): + self.assertEqual(b[i], i) + + def testSliceSetitem(self): + L = range(10) + b = RingBuffer(len(L), [0]*len(L)) + self.assertRaises(ValueError, b.__setitem__, slice(0, 10), []) + b[2:4] = L[2:4] + self.assertEquals(b[2:4], L[2:4]) + for _ in range(len(b)): + b.append(0) + b[2:4] = L[2:4] + self.assertEquals(b[2:4], L[2:4]) + + def testExtend(self): + b = RingBuffer(3, range(3)) + self.assertEqual(list(b), range(3)) + b.extend(range(6)) + self.assertEqual(list(b), range(6)[3:]) + + def testRepr(self): + b = RingBuffer(3) + self.assertEqual(repr(b), 'RingBuffer(3, [])') + b.append(1) + self.assertEqual(repr(b), 'RingBuffer(3, [1])') + b.append(2) + self.assertEqual(repr(b), 'RingBuffer(3, [1, 2])') + b.append(3) + self.assertEqual(repr(b), 'RingBuffer(3, [1, 2, 3])') + b.append(4) + self.assertEqual(repr(b), 'RingBuffer(3, [2, 3, 4])') + b.append(5) + self.assertEqual(repr(b), 'RingBuffer(3, [3, 4, 5])') + b.append(6) + self.assertEqual(repr(b), 'RingBuffer(3, [4, 5, 6])') + + def testPickleCopy(self): + b = RingBuffer(10, range(10)) + self.assertEqual(pickle.loads(pickle.dumps(b)), b) + + def testEq(self): + b = RingBuffer(3, range(3)) + self.failIf(b == range(3)) + b1 = RingBuffer(3) + self.failIf(b == b1) + b1.append(0) + self.failIf(b == b1) + b1.append(1) + self.failIf(b == b1) + b1.append(2) + self.failUnless(b == b1) + b = RingBuffer(100, range(10)) + b1 = RingBuffer(10, range(10)) + self.failIf(b == b1) + + def testIter(self): + b = RingBuffer(3, range(3)) + L = [] + for elt in b: + L.append(elt) + self.assertEqual(L, range(3)) + for elt in range(3): + b.append(elt) + del L[:] + for elt in b: + L.append(elt) + self.assertEqual(L, range(3)) + + +class QueueTest(SupyTestCase): + def testReset(self): + q = queue() + q.enqueue(1) + self.assertEqual(len(q), 1) + q.reset() + self.assertEqual(len(q), 0) + + def testGetitem(self): + q = queue() + n = 10 + self.assertRaises(IndexError, q.__getitem__, 0) + for i in xrange(n): + q.enqueue(i) + for i in xrange(n): + self.assertEqual(q[i], i) + for i in xrange(n, 0, -1): + self.assertEqual(q[-i], n-i) + for i in xrange(len(q)): + self.assertEqual(list(q), list(q[:i]) + list(q[i:])) + self.assertRaises(IndexError, q.__getitem__, -(n+1)) + self.assertRaises(IndexError, q.__getitem__, n) + self.assertEqual(q[3:7], queue([3, 4, 5, 6])) + + def testSetitem(self): + q1 = queue() + self.assertRaises(IndexError, q1.__setitem__, 0, 0) + for i in xrange(10): + q1.enqueue(i) + q2 = eval(repr(q1)) + for (i, elt) in enumerate(q2): + q2[i] = elt*2 + self.assertEqual([x*2 for x in q1], list(q2)) + + def testNonzero(self): + q = queue() + self.failIf(q, 'queue not zero after initialization') + q.enqueue(1) + self.failUnless(q, 'queue zero after adding element') + q.dequeue() + self.failIf(q, 'queue not zero after dequeue of only element') + + def testLen(self): + q = queue() + self.assertEqual(0, len(q), 'queue len not 0 after initialization') + q.enqueue(1) + self.assertEqual(1, len(q), 'queue len not 1 after enqueue') + q.enqueue(2) + self.assertEqual(2, len(q), 'queue len not 2 after enqueue') + q.dequeue() + self.assertEqual(1, len(q), 'queue len not 1 after dequeue') + q.dequeue() + self.assertEqual(0, len(q), 'queue len not 0 after dequeue') + for i in range(10): + L = range(i) + q = queue(L) + self.assertEqual(len(q), i) + + def testEq(self): + q1 = queue() + q2 = queue() + self.failUnless(q1 == q1, 'queue not equal to itself') + self.failUnless(q2 == q2, 'queue not equal to itself') + self.failUnless(q1 == q2, 'initialized queues not equal') + q1.enqueue(1) + self.failUnless(q1 == q1, 'queue not equal to itself') + self.failUnless(q2 == q2, 'queue not equal to itself') + q2.enqueue(1) + self.failUnless(q1 == q1, 'queue not equal to itself') + self.failUnless(q2 == q2, 'queue not equal to itself') + self.failUnless(q1 == q2, 'queues not equal after identical enqueue') + q1.dequeue() + self.failUnless(q1 == q1, 'queue not equal to itself') + self.failUnless(q2 == q2, 'queue not equal to itself') + self.failIf(q1 == q2, 'queues equal after one dequeue') + q2.dequeue() + self.failUnless(q1 == q2, 'queues not equal after both are dequeued') + self.failUnless(q1 == q1, 'queue not equal to itself') + self.failUnless(q2 == q2, 'queue not equal to itself') + + def testInit(self): + self.assertEqual(len(queue()), 0, 'queue len not 0 after init') + q = queue() + q.enqueue(1) + q.enqueue(2) + q.enqueue(3) + self.assertEqual(queue((1, 2, 3)),q, 'init not equivalent to enqueues') + q = queue((1, 2, 3)) + self.assertEqual(q.dequeue(), 1, 'values not returned in proper order') + self.assertEqual(q.dequeue(), 2, 'values not returned in proper order') + self.assertEqual(q.dequeue(), 3, 'values not returned in proper order') + + def testRepr(self): + q = queue() + q.enqueue(1) + self.assertEqual(q, eval(repr(q)), 'repr doesn\'t eval to same queue') + q.enqueue('foo') + self.assertEqual(q, eval(repr(q)), 'repr doesn\'t eval to same queue') + q.enqueue(None) + self.assertEqual(q, eval(repr(q)), 'repr doesn\'t eval to same queue') + q.enqueue(1.0) + self.assertEqual(q, eval(repr(q)), 'repr doesn\'t eval to same queue') + q.enqueue([]) + self.assertEqual(q, eval(repr(q)), 'repr doesn\'t eval to same queue') + q.enqueue(()) + self.assertEqual(q, eval(repr(q)), 'repr doesn\'t eval to same queue') + q.enqueue([1]) + self.assertEqual(q, eval(repr(q)), 'repr doesn\'t eval to same queue') + q.enqueue((1,)) + self.assertEqual(q, eval(repr(q)), 'repr doesn\'t eval to same queue') + + def testEnqueueDequeue(self): + q = queue() + self.assertRaises(IndexError, q.dequeue) + q.enqueue(1) + self.assertEqual(q.dequeue(), 1, + 'first dequeue didn\'t return same as first enqueue') + q.enqueue(1) + q.enqueue(2) + q.enqueue(3) + self.assertEqual(q.dequeue(), 1) + self.assertEqual(q.dequeue(), 2) + self.assertEqual(q.dequeue(), 3) + + def testPeek(self): + q = queue() + self.assertRaises(IndexError, q.peek) + q.enqueue(1) + self.assertEqual(q.peek(), 1, 'peek didn\'t return first enqueue') + q.enqueue(2) + self.assertEqual(q.peek(), 1, 'peek didn\'t return first enqueue') + q.dequeue() + self.assertEqual(q.peek(), 2, 'peek didn\'t return second enqueue') + q.dequeue() + self.assertRaises(IndexError, q.peek) + + def testContains(self): + q = queue() + self.failIf(1 in q, 'empty queue cannot have elements') + q.enqueue(1) + self.failUnless(1 in q, 'recent enqueued element not in q') + q.enqueue(2) + self.failUnless(1 in q, 'original enqueued element not in q') + self.failUnless(2 in q, 'second enqueued element not in q') + q.dequeue() + self.failIf(1 in q, 'dequeued element in q') + self.failUnless(2 in q, 'not dequeued element not in q') + q.dequeue() + self.failIf(2 in q, 'dequeued element in q') + + def testIter(self): + q1 = queue((1, 2, 3)) + q2 = queue() + for i in q1: + q2.enqueue(i) + self.assertEqual(q1, q2, 'iterate didn\'t return all elements') + for _ in queue(): + self.fail('no elements should be in empty queue') + + def testPickleCopy(self): + q = queue(range(10)) + self.assertEqual(q, pickle.loads(pickle.dumps(q))) + +queue = smallqueue + +class SmallQueueTest(SupyTestCase): + def testReset(self): + q = queue() + q.enqueue(1) + self.assertEqual(len(q), 1) + q.reset() + self.assertEqual(len(q), 0) + + def testGetitem(self): + q = queue() + n = 10 + self.assertRaises(IndexError, q.__getitem__, 0) + for i in xrange(n): + q.enqueue(i) + for i in xrange(n): + self.assertEqual(q[i], i) + for i in xrange(n, 0, -1): + self.assertEqual(q[-i], n-i) + for i in xrange(len(q)): + self.assertEqual(list(q), list(q[:i]) + list(q[i:])) + self.assertRaises(IndexError, q.__getitem__, -(n+1)) + self.assertRaises(IndexError, q.__getitem__, n) + self.assertEqual(q[3:7], queue([3, 4, 5, 6])) + + def testSetitem(self): + q1 = queue() + self.assertRaises(IndexError, q1.__setitem__, 0, 0) + for i in xrange(10): + q1.enqueue(i) + q2 = eval(repr(q1)) + for (i, elt) in enumerate(q2): + q2[i] = elt*2 + self.assertEqual([x*2 for x in q1], list(q2)) + + def testNonzero(self): + q = queue() + self.failIf(q, 'queue not zero after initialization') + q.enqueue(1) + self.failUnless(q, 'queue zero after adding element') + q.dequeue() + self.failIf(q, 'queue not zero after dequeue of only element') + + def testLen(self): + q = queue() + self.assertEqual(0, len(q), 'queue len not 0 after initialization') + q.enqueue(1) + self.assertEqual(1, len(q), 'queue len not 1 after enqueue') + q.enqueue(2) + self.assertEqual(2, len(q), 'queue len not 2 after enqueue') + q.dequeue() + self.assertEqual(1, len(q), 'queue len not 1 after dequeue') + q.dequeue() + self.assertEqual(0, len(q), 'queue len not 0 after dequeue') + for i in range(10): + L = range(i) + q = queue(L) + self.assertEqual(len(q), i) + + def testEq(self): + q1 = queue() + q2 = queue() + self.failUnless(q1 == q1, 'queue not equal to itself') + self.failUnless(q2 == q2, 'queue not equal to itself') + self.failUnless(q1 == q2, 'initialized queues not equal') + q1.enqueue(1) + self.failUnless(q1 == q1, 'queue not equal to itself') + self.failUnless(q2 == q2, 'queue not equal to itself') + q2.enqueue(1) + self.failUnless(q1 == q1, 'queue not equal to itself') + self.failUnless(q2 == q2, 'queue not equal to itself') + self.failUnless(q1 == q2, 'queues not equal after identical enqueue') + q1.dequeue() + self.failUnless(q1 == q1, 'queue not equal to itself') + self.failUnless(q2 == q2, 'queue not equal to itself') + self.failIf(q1 == q2, 'queues equal after one dequeue') + q2.dequeue() + self.failUnless(q1 == q2, 'queues not equal after both are dequeued') + self.failUnless(q1 == q1, 'queue not equal to itself') + self.failUnless(q2 == q2, 'queue not equal to itself') + + def testInit(self): + self.assertEqual(len(queue()), 0, 'queue len not 0 after init') + q = queue() + q.enqueue(1) + q.enqueue(2) + q.enqueue(3) + self.assertEqual(queue((1, 2, 3)),q, 'init not equivalent to enqueues') + q = queue((1, 2, 3)) + self.assertEqual(q.dequeue(), 1, 'values not returned in proper order') + self.assertEqual(q.dequeue(), 2, 'values not returned in proper order') + self.assertEqual(q.dequeue(), 3, 'values not returned in proper order') + + def testRepr(self): + q = queue() + q.enqueue(1) + self.assertEqual(q, eval(repr(q)), 'repr doesn\'t eval to same queue') + q.enqueue('foo') + self.assertEqual(q, eval(repr(q)), 'repr doesn\'t eval to same queue') + q.enqueue(None) + self.assertEqual(q, eval(repr(q)), 'repr doesn\'t eval to same queue') + q.enqueue(1.0) + self.assertEqual(q, eval(repr(q)), 'repr doesn\'t eval to same queue') + q.enqueue([]) + self.assertEqual(q, eval(repr(q)), 'repr doesn\'t eval to same queue') + q.enqueue(()) + self.assertEqual(q, eval(repr(q)), 'repr doesn\'t eval to same queue') + q.enqueue([1]) + self.assertEqual(q, eval(repr(q)), 'repr doesn\'t eval to same queue') + q.enqueue((1,)) + self.assertEqual(q, eval(repr(q)), 'repr doesn\'t eval to same queue') + + def testEnqueueDequeue(self): + q = queue() + self.assertRaises(IndexError, q.dequeue) + q.enqueue(1) + self.assertEqual(q.dequeue(), 1, + 'first dequeue didn\'t return same as first enqueue') + q.enqueue(1) + q.enqueue(2) + q.enqueue(3) + self.assertEqual(q.dequeue(), 1) + self.assertEqual(q.dequeue(), 2) + self.assertEqual(q.dequeue(), 3) + + def testPeek(self): + q = queue() + self.assertRaises(IndexError, q.peek) + q.enqueue(1) + self.assertEqual(q.peek(), 1, 'peek didn\'t return first enqueue') + q.enqueue(2) + self.assertEqual(q.peek(), 1, 'peek didn\'t return first enqueue') + q.dequeue() + self.assertEqual(q.peek(), 2, 'peek didn\'t return second enqueue') + q.dequeue() + self.assertRaises(IndexError, q.peek) + + def testContains(self): + q = queue() + self.failIf(1 in q, 'empty queue cannot have elements') + q.enqueue(1) + self.failUnless(1 in q, 'recent enqueued element not in q') + q.enqueue(2) + self.failUnless(1 in q, 'original enqueued element not in q') + self.failUnless(2 in q, 'second enqueued element not in q') + q.dequeue() + self.failIf(1 in q, 'dequeued element in q') + self.failUnless(2 in q, 'not dequeued element not in q') + q.dequeue() + self.failIf(2 in q, 'dequeued element in q') + + def testIter(self): + q1 = queue((1, 2, 3)) + q2 = queue() + for i in q1: + q2.enqueue(i) + self.assertEqual(q1, q2, 'iterate didn\'t return all elements') + for _ in queue(): + self.fail('no elements should be in empty queue') + + def testPickleCopy(self): + q = queue(range(10)) + self.assertEqual(q, pickle.loads(pickle.dumps(q))) + + +class MaxLengthQueueTestCase(SupyTestCase): + def testInit(self): + q = MaxLengthQueue(3, (1, 2, 3)) + self.assertEqual(list(q), [1, 2, 3]) + self.assertRaises(TypeError, MaxLengthQueue, 3, 1, 2, 3) + + def testMaxLength(self): + q = MaxLengthQueue(3) + q.enqueue(1) + self.assertEqual(len(q), 1) + q.enqueue(2) + self.assertEqual(len(q), 2) + q.enqueue(3) + self.assertEqual(len(q), 3) + q.enqueue(4) + self.assertEqual(len(q), 3) + self.assertEqual(q.peek(), 2) + q.enqueue(5) + self.assertEqual(len(q), 3) + self.assertEqual(q[0], 3) + + +class TwoWayDictionaryTestCase(SupyTestCase): + def testInit(self): + d = TwoWayDictionary(foo='bar') + self.failUnless('foo' in d) + self.failUnless('bar' in d) + + d = TwoWayDictionary({1: 2}) + self.failUnless(1 in d) + self.failUnless(2 in d) + + def testSetitem(self): + d = TwoWayDictionary() + d['foo'] = 'bar' + self.failUnless('foo' in d) + self.failUnless('bar' in d) + + def testDelitem(self): + d = TwoWayDictionary(foo='bar') + del d['foo'] + self.failIf('foo' in d) + self.failIf('bar' in d) + d = TwoWayDictionary(foo='bar') + del d['bar'] + self.failIf('bar' in d) + self.failIf('foo' in d) + + +class TestTimeoutQueue(SupyTestCase): + def test(self): + q = TimeoutQueue(1) + q.enqueue(1) + self.assertEqual(len(q), 1) + q.enqueue(2) + self.assertEqual(len(q), 2) + q.enqueue(3) + self.assertEqual(len(q), 3) + self.assertEqual(sum(q), 6) + time.sleep(1.1) + self.assertEqual(len(q), 0) + self.assertEqual(sum(q), 0) + + def testCallableTimeout(self): + q = TimeoutQueue(lambda : 1) + q.enqueue(1) + self.assertEqual(len(q), 1) + q.enqueue(2) + self.assertEqual(len(q), 2) + q.enqueue(3) + self.assertEqual(len(q), 3) + self.assertEqual(sum(q), 6) + time.sleep(1.1) + self.assertEqual(len(q), 0) + self.assertEqual(sum(q), 0) + + def testContains(self): + q = TimeoutQueue(1) + q.enqueue(1) + self.failUnless(1 in q) + self.failIf(2 in q) + time.sleep(1.1) + self.failIf(1 in q) + + +# vim:set shiftwidth=4 tabstop=8 expandtab textwidth=78: + diff --git a/test/test_utils.py b/test/test_utils.py new file mode 100644 index 000000000..ac060d287 --- /dev/null +++ b/test/test_utils.py @@ -0,0 +1,411 @@ +### +# Copyright (c) 2002-2004, Jeremiah Fincher +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND 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 COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +### + +from testsupport import * +import sets + +import supybot.utils as utils + +class UtilsTest(SupyTestCase): + def testExnToString(self): + try: + raise KeyError, 1 + except Exception, e: + self.assertEqual(utils.exnToString(e), 'KeyError: 1') + try: + raise EOFError + except Exception, e: + self.assertEqual(utils.exnToString(e), 'EOFError') + + def testMatchCase(self): + f = utils.matchCase + self.assertEqual('bar', f('foo', 'bar')) + self.assertEqual('Bar', f('Foo', 'bar')) + self.assertEqual('BAr', f('FOo', 'bar')) + self.assertEqual('BAR', f('FOO', 'bar')) + self.assertEqual('bAR', f('fOO', 'bar')) + self.assertEqual('baR', f('foO', 'bar')) + self.assertEqual('BaR', f('FoO', 'bar')) + + def testPluralize(self): + f = utils.pluralize + self.assertEqual('bike', f('bike', 1)) + self.assertEqual('bikes', f('bike', 2)) + self.assertEqual('BIKE', f('BIKE', 1)) + self.assertEqual('BIKES', f('BIKE', 2)) + self.assertEqual('match', f('match', 1)) + self.assertEqual('matches', f('match', 2)) + self.assertEqual('Patch', f('Patch', 1)) + self.assertEqual('Patches', f('Patch', 2)) + self.assertEqual('fish', f('fish', 1)) + self.assertEqual('fishes', f('fish', 2)) + self.assertEqual('try', f('try', 1)) + self.assertEqual('tries', f('try', 2)) + self.assertEqual('day', f('day', 1)) + self.assertEqual('days', f('day', 2)) + + def testDepluralize(self): + f = utils.depluralize + self.assertEqual('bike', f('bikes')) + self.assertEqual('Bike', f('Bikes')) + self.assertEqual('BIKE', f('BIKES')) + self.assertEqual('match', f('matches')) + self.assertEqual('Match', f('Matches')) + self.assertEqual('fish', f('fishes')) + self.assertEqual('try', f('tries')) + + def testTimeElapsed(self): + self.assertRaises(ValueError, utils.timeElapsed, 0, + leadingZeroes=False, seconds=False) + then = 0 + now = 0 + for now, expected in [(0, '0 seconds'), + (1, '1 second'), + (60, '1 minute and 0 seconds'), + (61, '1 minute and 1 second'), + (62, '1 minute and 2 seconds'), + (122, '2 minutes and 2 seconds'), + (3722, '1 hour, 2 minutes, and 2 seconds'), + (7322, '2 hours, 2 minutes, and 2 seconds'), + (90061,'1 day, 1 hour, 1 minute, and 1 second'), + (180122, '2 days, 2 hours, 2 minutes, ' + 'and 2 seconds')]: + self.assertEqual(utils.timeElapsed(now - then), expected) + + def timeElapsedShort(self): + self.assertEqual(utils.timeElapsed(123, short=True), '2m 3s') + + def testEachSubstring(self): + s = 'foobar' + L = ['f', 'fo', 'foo', 'foob', 'fooba', 'foobar'] + self.assertEqual(list(utils.eachSubstring(s)), L) + + def testDistance(self): + self.assertEqual(utils.distance('', ''), 0) + self.assertEqual(utils.distance('a', 'b'), 1) + self.assertEqual(utils.distance('a', 'a'), 0) + self.assertEqual(utils.distance('foobar', 'jemfinch'), 8) + self.assertEqual(utils.distance('a', 'ab'), 1) + self.assertEqual(utils.distance('foo', ''), 3) + self.assertEqual(utils.distance('', 'foo'), 3) + self.assertEqual(utils.distance('appel', 'nappe'), 2) + self.assertEqual(utils.distance('nappe', 'appel'), 2) + + def testAbbrev(self): + L = ['abc', 'bcd', 'bbe', 'foo', 'fool'] + d = utils.abbrev(L) + def getItem(s): + return d[s] + self.assertRaises(KeyError, getItem, 'f') + self.assertRaises(KeyError, getItem, 'fo') + self.assertRaises(KeyError, getItem, 'b') + self.assertEqual(d['bb'], 'bbe') + self.assertEqual(d['bc'], 'bcd') + self.assertEqual(d['a'], 'abc') + self.assertEqual(d['ab'], 'abc') + self.assertEqual(d['fool'], 'fool') + self.assertEqual(d['foo'], 'foo') + + def testAbbrevFailsWithDups(self): + L = ['english', 'english'] + self.assertRaises(ValueError, utils.abbrev, L) + + def testSoundex(self): + L = [('Euler', 'E460'), + ('Ellery', 'E460'), + ('Gauss', 'G200'), + ('Ghosh', 'G200'), + ('Hilbert', 'H416'), + ('Heilbronn', 'H416'), + ('Knuth', 'K530'), + ('Kant', 'K530'), + ('Lloyd', 'L300'), + ('Ladd', 'L300'), + ('Lukasiewicz', 'L222'), + ('Lissajous', 'L222')] + for (name, key) in L: + soundex = utils.soundex(name) + self.assertEqual(soundex, key, + '%s was %s, not %s' % (name, soundex, key)) + self.assertRaises(ValueError, utils.soundex, '3') + self.assertRaises(ValueError, utils.soundex, "'") + + + def testDQRepr(self): + L = ['foo', 'foo\'bar', 'foo"bar', '"', '\\', '', '\x00'] + for s in L: + r = utils.dqrepr(s) + self.assertEqual(s, eval(r), s) + self.failUnless(r[0] == '"' and r[-1] == '"', s) + +## def testQuoted(self): +## s = 'foo' +## t = 'let\'s' +## self.assertEqual("'%s'" % s, utils.quoted(s), s) +## self.assertEqual('"%s"' % t, utils.quoted(t), t) + + def testPerlReToPythonRe(self): + r = utils.perlReToPythonRe('m/foo/') + self.failUnless(r.search('foo')) + r = utils.perlReToPythonRe('/foo/') + self.failUnless(r.search('foo')) + r = utils.perlReToPythonRe('m/\\//') + self.failUnless(r.search('/')) + r = utils.perlReToPythonRe('m/cat/i') + self.failUnless(r.search('CAT')) + self.assertRaises(ValueError, utils.perlReToPythonRe, 'm/?/') + + def testP2PReDifferentSeparator(self): + r = utils.perlReToPythonRe('m!foo!') + self.failUnless(r.search('foo')) + + def testPerlReToReplacer(self): + f = utils.perlReToReplacer('s/foo/bar/') + self.assertEqual(f('foobarbaz'), 'barbarbaz') + f = utils.perlReToReplacer('s/fool/bar/') + self.assertEqual(f('foobarbaz'), 'foobarbaz') + f = utils.perlReToReplacer('s/foo//') + self.assertEqual(f('foobarbaz'), 'barbaz') + f = utils.perlReToReplacer('s/ba//') + self.assertEqual(f('foobarbaz'), 'foorbaz') + f = utils.perlReToReplacer('s/ba//g') + self.assertEqual(f('foobarbaz'), 'foorz') + f = utils.perlReToReplacer('s/ba\\///g') + self.assertEqual(f('fooba/rba/z'), 'foorz') + f = utils.perlReToReplacer('s/cat/dog/i') + self.assertEqual(f('CATFISH'), 'dogFISH') + f = utils.perlReToReplacer('s/foo/foo\/bar/') + self.assertEqual(f('foo'), 'foo/bar') + f = utils.perlReToReplacer('s/^/foo/') + self.assertEqual(f('bar'), 'foobar') + + def testPReToReplacerDifferentSeparator(self): + f = utils.perlReToReplacer('s#foo#bar#') + self.assertEqual(f('foobarbaz'), 'barbarbaz') + + def testPerlReToReplacerBug850931(self): + f = utils.perlReToReplacer('s/\b(\w+)\b/\1./g') + self.assertEqual(f('foo bar baz'), 'foo. bar. baz.') + + def testPerlVariableSubstitute(self): + f = utils.perlVariableSubstitute + vars = {'foo': 'bar', 'b a z': 'baz', 'b': 'c', 'i': 100, + 'f': lambda: 'called'} + self.assertEqual(f(vars, '$foo'), 'bar') + self.assertEqual(f(vars, '${foo}'), 'bar') + self.assertEqual(f(vars, '$b'), 'c') + self.assertEqual(f(vars, '${b}'), 'c') + self.assertEqual(f(vars, '$i'), '100') + self.assertEqual(f(vars, '${i}'), '100') + self.assertEqual(f(vars, '$f'), 'called') + self.assertEqual(f(vars, '${f}'), 'called') + self.assertEqual(f(vars, '${b a z}'), 'baz') + self.assertEqual(f(vars, '$b:$i'), 'c:100') + + + def testFindBinaryInPath(self): + if os.name == 'posix': + self.assertEqual(None, utils.findBinaryInPath('asdfhjklasdfhjkl')) + self.failUnless(utils.findBinaryInPath('sh').endswith('/bin/sh')) + + def testCommaAndify(self): + L = ['foo'] + original = L[:] + self.assertEqual(utils.commaAndify(L), 'foo') + self.assertEqual(utils.commaAndify(L, And='or'), 'foo') + self.assertEqual(L, original) + L.append('bar') + original = L[:] + self.assertEqual(utils.commaAndify(L), 'foo and bar') + self.assertEqual(utils.commaAndify(L, And='or'), 'foo or bar') + self.assertEqual(L, original) + L.append('baz') + original = L[:] + self.assertEqual(utils.commaAndify(L), 'foo, bar, and baz') + self.assertEqual(utils.commaAndify(L, And='or'), 'foo, bar, or baz') + self.assertEqual(utils.commaAndify(L, comma=';'), 'foo; bar; and baz') + self.assertEqual(utils.commaAndify(L, comma=';', And='or'), + 'foo; bar; or baz') + self.assertEqual(L, original) + self.failUnless(utils.commaAndify(sets.Set(L))) + + def testCommaAndifyRaisesTypeError(self): + L = [(2,)] + self.assertRaises(TypeError, utils.commaAndify, L) + L.append((3,)) + self.assertRaises(TypeError, utils.commaAndify, L) + + def testUnCommaThe(self): + self.assertEqual(utils.unCommaThe('foo bar'), 'foo bar') + self.assertEqual(utils.unCommaThe('foo bar, the'), 'the foo bar') + self.assertEqual(utils.unCommaThe('foo bar, The'), 'The foo bar') + self.assertEqual(utils.unCommaThe('foo bar,the'), 'the foo bar') + + def testNormalizeWhitespace(self): + self.assertEqual(utils.normalizeWhitespace('foo bar'), 'foo bar') + self.assertEqual(utils.normalizeWhitespace('foo\nbar'), 'foo bar') + self.assertEqual(utils.normalizeWhitespace('foo\tbar'), 'foo bar') + + def testSortBy(self): + L = ['abc', 'z', 'AD'] + utils.sortBy(len, L) + self.assertEqual(L, ['z', 'AD', 'abc']) + utils.sortBy(str.lower, L) + self.assertEqual(L, ['abc', 'AD', 'z']) + L = ['supybot', 'Supybot'] + utils.sortBy(str.lower, L) + self.assertEqual(L, ['supybot', 'Supybot']) + + def testSorted(self): + L = ['a', 'c', 'b'] + self.assertEqual(utils.sorted(L), ['a', 'b', 'c']) + self.assertEqual(L, ['a', 'c', 'b']) + def mycmp(x, y): + return -cmp(x, y) + self.assertEqual(utils.sorted(L, mycmp), ['c', 'b', 'a']) + + def testNItems(self): + self.assertEqual(utils.nItems('tool', 1, 'crazy'), '1 crazy tool') + self.assertEqual(utils.nItems('tool', 1), '1 tool') + self.assertEqual(utils.nItems('tool', 2, 'crazy'), '2 crazy tools') + self.assertEqual(utils.nItems('tool', 2), '2 tools') + + def testItersplit(self): + itersplit = utils.itersplit + L = [1, 2, 3] * 3 + s = 'foo bar baz' + self.assertEqual(list(itersplit(lambda x: x == 3, L)), + [[1, 2], [1, 2], [1, 2]]) + self.assertEqual(list(itersplit(lambda x: x == 3, L, yieldEmpty=True)), + [[1, 2], [1, 2], [1, 2], []]) + self.assertEqual(list(itersplit(lambda x: x, [])), []) + self.assertEqual(list(itersplit(lambda c: c.isspace(), s)), + map(list, s.split())) + self.assertEqual(list(itersplit('for'.__eq__, ['foo', 'for', 'bar'])), + [['foo'], ['bar']]) + self.assertEqual(list(itersplit('for'.__eq__, + ['foo','for','bar','for', 'baz'], 1)), + [['foo'], ['bar', 'for', 'baz']]) + + def testIterableMap(self): + class alist(utils.IterableMap): + def __init__(self): + self.L = [] + + def __setitem__(self, key, value): + self.L.append((key, value)) + + def iteritems(self): + for (k, v) in self.L: + yield (k, v) + AL = alist() + self.failIf(AL) + AL[1] = 2 + AL[2] = 3 + AL[3] = 4 + self.failUnless(AL) + self.assertEqual(AL.items(), [(1, 2), (2, 3), (3, 4)]) + self.assertEqual(list(AL.iteritems()), [(1, 2), (2, 3), (3, 4)]) + self.assertEqual(AL.keys(), [1, 2, 3]) + self.assertEqual(list(AL.iterkeys()), [1, 2, 3]) + self.assertEqual(AL.values(), [2, 3, 4]) + self.assertEqual(list(AL.itervalues()), [2, 3, 4]) + self.assertEqual(len(AL), 3) + + def testFlatten(self): + def lflatten(seq): + return list(utils.flatten(seq)) + self.assertEqual(lflatten([]), []) + self.assertEqual(lflatten([1]), [1]) + self.assertEqual(lflatten(range(10)), range(10)) + twoRanges = range(10)*2 + twoRanges.sort() + self.assertEqual(lflatten(zip(range(10), range(10))), twoRanges) + self.assertEqual(lflatten([1, [2, 3], 4]), [1, 2, 3, 4]) + self.assertEqual(lflatten([[[[[[[[[[]]]]]]]]]]), []) + self.assertEqual(lflatten([1, [2, [3, 4], 5], 6]), [1, 2, 3, 4, 5, 6]) + self.assertRaises(TypeError, lflatten, 1) + + def testEllipsisify(self): + f = utils.ellipsisify + self.assertEqual(f('x'*30, 30), 'x'*30) + self.failUnless(len(f('x'*35, 30)) <= 30) + self.failUnless(f(' '.join(['xxxx']*10), 30)[:-3].endswith('xxxx')) + + def testSaltHash(self): + s = utils.saltHash('jemfinch') + (salt, hash) = s.split('|') + self.assertEqual(utils.saltHash('jemfinch', salt=salt), s) + + def testSafeEval(self): + for s in ['1', '()', '(1,)', '[]', '{}', '{1:2}', '{1:(2,3)}', + '1.0', '[1,2,3]', 'True', 'False', 'None', + '(True,False,None)', '"foo"', '{"foo": "bar"}']: + self.assertEqual(eval(s), utils.safeEval(s)) + for s in ['lambda: 2', 'import foo', 'foo.bar']: + self.assertRaises(ValueError, utils.safeEval, s) + + + def testSafeEvalTurnsSyntaxErrorIntoValueError(self): + self.assertRaises(ValueError, utils.safeEval, '/usr/local/') + + def testLines(self): + L = ['foo', 'bar', '#baz', ' ', 'biff'] + self.assertEqual(list(utils.nonEmptyLines(L)), + ['foo', 'bar', '#baz', 'biff']) + self.assertEqual(list(utils.nonCommentLines(L)), + ['foo', 'bar', ' ', 'biff']) + self.assertEqual(list(utils.nonCommentNonEmptyLines(L)), + ['foo', 'bar', 'biff']) + + def testIsIP(self): + self.failIf(utils.isIP('a.b.c')) + self.failIf(utils.isIP('256.0.0.0')) + self.failUnless(utils.isIP('127.1')) + self.failUnless(utils.isIP('0.0.0.0')) + self.failUnless(utils.isIP('100.100.100.100')) + # This test is too flaky to bother with. + # self.failUnless(utils.isIP('255.255.255.255')) + + def testIsIPV6(self): + f = utils.isIPV6 + self.failUnless(f('2001::')) + self.failUnless(f('2001:888:0:1::666')) + + def testInsensitivePreservingDict(self): + ipd = utils.InsensitivePreservingDict + d = ipd(dict(Foo=10)) + self.failUnless(d['foo'] == 10) + self.assertEqual(d.keys(), ['Foo']) + self.assertEqual(d.get('foo'), 10) + self.assertEqual(d.get('Foo'), 10) + + +# vim:set shiftwidth=4 tabstop=8 expandtab textwidth=78: + diff --git a/test/test_webutils.py b/test/test_webutils.py new file mode 100644 index 000000000..308a5a998 --- /dev/null +++ b/test/test_webutils.py @@ -0,0 +1,47 @@ +### +# Copyright (c) 2004, Jeremiah Fincher +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND 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 COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +### + +from testsupport import * + +import supybot.webutils as webutils + + +class WebutilsTestCase(SupyTestCase): + def testGetDomain(self): + self.assertEqual(webutils.getDomain('http://slashdot.org/foo/bar.exe'), + 'slashdot.org') + + if network: + def testGetUrlWithSize(self): + url = 'http://slashdot.org/' + self.failUnless(len(webutils.getUrl(url, 1024)) == 1024) + + +# vim:set shiftwidth=4 tabstop=8 expandtab textwidth=78: +