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: +