From 948571c17ff863be7d0220e18ed95ddf92125159 Mon Sep 17 00:00:00 2001 From: James Vega Date: Mon, 18 Apr 2005 16:13:53 +0000 Subject: [PATCH] Move MF to s-d --- plugins/MoobotFactoids/README.txt | 1 + plugins/MoobotFactoids/__init__.py | 62 +++ plugins/MoobotFactoids/config.py | 52 +++ plugins/MoobotFactoids/plugin.py | 705 +++++++++++++++++++++++++++++ plugins/MoobotFactoids/test.py | 343 ++++++++++++++ 5 files changed, 1163 insertions(+) create mode 100644 plugins/MoobotFactoids/README.txt create mode 100644 plugins/MoobotFactoids/__init__.py create mode 100644 plugins/MoobotFactoids/config.py create mode 100644 plugins/MoobotFactoids/plugin.py create mode 100644 plugins/MoobotFactoids/test.py diff --git a/plugins/MoobotFactoids/README.txt b/plugins/MoobotFactoids/README.txt new file mode 100644 index 000000000..d60b47a97 --- /dev/null +++ b/plugins/MoobotFactoids/README.txt @@ -0,0 +1 @@ +Insert a description of your plugin here, with any notes, etc. about using it. diff --git a/plugins/MoobotFactoids/__init__.py b/plugins/MoobotFactoids/__init__.py new file mode 100644 index 000000000..f7e8151e3 --- /dev/null +++ b/plugins/MoobotFactoids/__init__.py @@ -0,0 +1,62 @@ +### +# Copyright (c) 2003-2005, Daniel DiPaolo +# 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. +### + +""" +Moobot factoid compatibility module. Moobot's factoids were originally +designed to emulate Blootbot's factoids, so in either case, you should find +this plugin comfortable. +""" + +import supybot +import supybot.world as world + +# Use this for the version of this plugin. You may wish to put a CVS keyword +# in here if you're keeping the plugin in CVS or some similar system. +__version__ = "0.1" + +__author__ = supybot.authors.strike + +# 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. +# Add more reloads here if you add third-party modules and want them to be +# reloaded when this plugin is reloaded. Don't forget to import them as well! + +if world.testing: + import test + +Class = plugin.Class +configure = config.configure + + +# vim:set shiftwidth=4 tabstop=8 expandtab textwidth=78: diff --git a/plugins/MoobotFactoids/config.py b/plugins/MoobotFactoids/config.py new file mode 100644 index 000000000..2a098ce3d --- /dev/null +++ b/plugins/MoobotFactoids/config.py @@ -0,0 +1,52 @@ +### +# Copyright (c) 2003-2005, Daniel DiPaolo +# 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('MoobotFactoids', True) + + +MoobotFactoids = conf.registerPlugin('MoobotFactoids') +conf.registerChannelValue(MoobotFactoids, + 'showFactoidIfOnlyOneMatch', registry.Boolean(True, """Determines whether + or not the factoid value will be shown when a listkeys search returns only + one factoid key.""")) +conf.registerChannelValue(MoobotFactoids, + 'mostCount', registry.Integer(10, """Determines how many items are shown + when the 'most' command is called.""")) + + +# vim:set shiftwidth=4 tabstop=8 expandtab textwidth=78 diff --git a/plugins/MoobotFactoids/plugin.py b/plugins/MoobotFactoids/plugin.py new file mode 100644 index 000000000..a644a4d2b --- /dev/null +++ b/plugins/MoobotFactoids/plugin.py @@ -0,0 +1,705 @@ +### +# Copyright (c) 2003-2005, Daniel DiPaolo +# 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 time +import shlex +import string + +from cStringIO import StringIO + +import supybot.conf as conf +import supybot.ircdb as ircdb +import supybot.utils as utils +from supybot.commands import * +import supybot.plugins as plugins +import supybot.ircutils as ircutils +import supybot.callbacks as callbacks + +allchars = string.maketrans('', '') +class OptionList(object): + validChars = allchars.translate(allchars, '|()') + def _insideParens(self, lexer): + ret = [] + while True: + token = lexer.get_token() + if not token: + return '(%s' % ''.join(ret) #) + elif token == ')': + if len(ret) > 1: + if '|' in ret: + L = map(''.join, + utils.iter.split('|'.__eq__, ret, + yieldEmpty=True)) + return utils.iter.choice(L) + else: + return ''.join(ret) + return [x for x in ret if x != '|'] + elif len(ret) == 1: + return '(%s)' % ret[0] + else: + return '()' + elif token == '(': + ret.append(self._insideParens(lexer)) + elif token == '|': + ret.append(token) + else: + ret.append(token) + + def tokenize(self, s): + lexer = shlex.shlex(StringIO(s)) + lexer.commenters = '' + lexer.quotes = '' + lexer.whitespace = '' + lexer.wordchars = self.validChars + ret = [] + while True: + token = lexer.get_token() + if not token: + break + elif token == '(': + ret.append(self._insideParens(lexer)) + else: + ret.append(token) + return ''.join(ret) + +def pickOptions(s): + return OptionList().tokenize(s) + +class SqliteMoobotDB(object): + def __init__(self, filename): + self.filename = filename + self.dbs = ircutils.IrcDict() + + def close(self): + for db in self.dbs.itervalues(): + db.close() + self.dbs.clear() + + def _getDb(self, channel): + try: + import sqlite + except ImportError: + raise callbacks.Error, \ + 'You need to have PySQLite installed to use this ' \ + 'plugin. Download it at ' + if channel in self.dbs: + return self.dbs[channel] + filename = plugins.makeChannelFilename(self.filename, channel) + if os.path.exists(filename): + self.dbs[channel] = sqlite.connect(filename) + return self.dbs[channel] + db = sqlite.connect(filename) + self.dbs[channel] = db + cursor = db.cursor() + cursor.execute("""CREATE TABLE factoids ( + key TEXT PRIMARY KEY, + created_by INTEGER, + created_at TIMESTAMP, + modified_by INTEGER, + modified_at TIMESTAMP, + locked_at TIMESTAMP, + locked_by INTEGER, + last_requested_by TEXT, + last_requested_at TIMESTAMP, + fact TEXT, + requested_count INTEGER + )""") + db.commit() + return db + + def getFactoid(self, channel, key): + db = self._getDb(channel) + cursor = db.cursor() + cursor.execute("""SELECT fact FROM factoids + WHERE key LIKE %s""", key) + if cursor.rowcount == 0: + return None + else: + return cursor.fetchall()[0] + + def getFactinfo(self, channel, key): + db = self._getDb(channel) + cursor = db.cursor() + cursor.execute("""SELECT created_by, created_at, + modified_by, modified_at, + last_requested_by, last_requested_at, + requested_count, locked_by, locked_at + FROM factoids + WHERE key LIKE %s""", key) + if cursor.rowcount == 0: + return None + else: + return cursor.fetchone() + + def randomFactoid(self, channel): + db = self._getDb(channel) + cursor = db.cursor() + cursor.execute("""SELECT fact, key FROM factoids + ORDER BY random() LIMIT 1""") + if cursor.rowcount == 0: + return None + else: + return cursor.fetchone() + + def addFactoid(self, channel, key, value, creator_id): + db = self._getDb(channel) + cursor = db.cursor() + cursor.execute("""INSERT INTO factoids VALUES + (%s, %s, %s, NULL, NULL, NULL, NULL, + NULL, NULL, %s, 0)""", + key, creator_id, int(time.time()), value) + db.commit() + + def updateFactoid(self, channel, key, newvalue, modifier_id): + db = self._getDb(channel) + cursor = db.cursor() + cursor.execute("""UPDATE factoids + SET fact=%s, modified_by=%s, + modified_at=%s WHERE key LIKE %s""", + newvalue, modifier_id, int(time.time()), key) + db.commit() + + def updateRequest(self, channel, key, hostmask): + db = self._getDb(channel) + cursor = db.cursor() + cursor.execute("""UPDATE factoids SET + last_requested_by = %s, + last_requested_at = %s, + requested_count = requested_count + 1 + WHERE key = %s""", + hostmask, int(time.time()), key) + db.commit() + + def removeFactoid(self, channel, key): + db = self._getDb(channel) + cursor = db.cursor() + cursor.execute("""DELETE FROM factoids WHERE key LIKE %s""", + key) + db.commit() + + def locked(self, channel, key): + db = self._getDb(channel) + cursor = db.cursor() + cursor.execute ("""SELECT locked_by FROM factoids + WHERE key LIKE %s""", key) + if cursor.fetchone()[0] is None: + return False + else: + return True + + def lock(self, channel, key, locker_id): + db = self._getDb(channel) + cursor = db.cursor() + cursor.execute("""UPDATE factoids + SET locked_by=%s, locked_at=%s + WHERE key LIKE %s""", + locker_id, int(time.time()), key) + db.commit() + + def unlock(self, channel, key): + db = self._getDb(channel) + cursor = db.cursor() + cursor.execute("""UPDATE factoids + SET locked_by=%s, locked_at=%s + WHERE key LIKE %s""", None, None, key) + db.commit() + + def mostAuthored(self, channel, limit): + db = self._getDb(channel) + cursor = db.cursor() + cursor.execute("""SELECT created_by, count(key) FROM factoids + GROUP BY created_by + ORDER BY count(key) DESC LIMIT %s""", limit) + return cursor.fetchall() + + def mostRecent(self, channel, limit): + db = self._getDb(channel) + cursor = db.cursor() + cursor.execute("""SELECT key FROM factoids + ORDER BY created_at DESC LIMIT %s""", limit) + return cursor.fetchall() + + def mostPopular(self, channel, limit): + db = self._getDb(channel) + cursor = db.cursor() + cursor.execute("""SELECT key, requested_count FROM factoids + WHERE requested_count > 0 + ORDER BY requested_count DESC LIMIT %s""", limit) + if cursor.rowcount == 0: + return [] + else: + return cursor.fetchall() + + def getKeysByAuthor(self, channel, authorId): + db = self._getDb(channel) + cursor = db.cursor() + cursor.execute("""SELECT key FROM factoids WHERE created_by=%s + ORDER BY key""", authorId) + if cursor.rowcount == 0: + return [] + else: + return cursor.fetchall() + + def getKeysByGlob(self, channel, glob): + db = self._getDb(channel) + cursor = db.cursor() + glob = '%%%s%%' % glob + cursor.execute("""SELECT key FROM factoids WHERE key LIKE %s + ORDER BY key""", glob) + if cursor.rowcount == 0: + return [] + else: + return cursor.fetchall() + + def getKeysByValueGlob(self, channel, glob): + db = self._getDb(channel) + cursor = db.cursor() + glob = '%%%s%%' % glob + cursor.execute("""SELECT key FROM factoids WHERE fact LIKE %s + ORDER BY key""", glob) + if cursor.rowcount == 0: + return [] + else: + return cursor.fetchall() + +MoobotDB = plugins.DB('MoobotFactoids', {'sqlite': SqliteMoobotDB}) + +class MoobotFactoids(callbacks.Plugin): + """Add the help for "@help MoobotFactoids" here (assuming you don't implement a MoobotFactoids + command). This should describe *how* to use this plugin.""" + callBefore = ['Dunno'] + def __init__(self, irc): + self.db = MoobotDB() + self.__parent = super(MoobotFactoids, self) + self.__parent.__init__(irc) + + def die(self): + self.__parent.die() + self.db.close() + + def reset(self): + self.db.close() + + _replyTag = '' + _actionTag = '' + def _parseFactoid(self, irc, msg, fact): + type = 'define' # Default is to just spit the factoid back as a + # definition of what the key is (i.e., "foo is bar") + newfact = pickOptions(fact) + if newfact.startswith(self._replyTag): + newfact = newfact[len(self._replyTag):] + type = 'reply' + elif newfact.startswith(self._actionTag): + newfact = newfact[len(self._actionTag):] + type = 'action' + newfact = newfact.strip() + newfact = ircutils.standardSubstitute(irc, msg, newfact) + return (type, newfact) + + def invalidCommand(self, irc, msg, tokens): + if '=~' in tokens: + self.changeFactoid(irc, msg, tokens) + elif tokens and tokens[0] in ('no', 'no,'): + self.replaceFactoid(irc, msg, tokens) + elif ['is', 'also'] in utils.seq.window(tokens, 2): + self.augmentFactoid(irc, msg, tokens) + else: + key = ' '.join(tokens) + key = self._sanitizeKey(key) + channel = plugins.getChannel(msg.args[0]) + fact = self.db.getFactoid(channel, key) + if fact: + self.db.updateRequest(channel, key, msg.prefix) + # getFactoid returns "all results", so we need to extract the + # first one. + fact = fact[0] + # Update the requested count/requested by for this key + hostmask = msg.prefix + # Now actually get the factoid and respond accordingly + (type, text) = self._parseFactoid(irc, msg, fact) + if type == 'action': + irc.reply(text, action=True) + elif type == 'reply': + irc.reply(text, prefixName=False) + elif type == 'define': + irc.reply(format('%s is %s', key, text), prefixName=False) + else: + assert False, 'Spurious type from _parseFactoid' + else: + if 'is' in tokens or '_is_' in tokens: + self.addFactoid(irc, msg, tokens) + + def _getUserId(self, irc, prefix): + try: + return ircdb.users.getUserId(prefix) + except KeyError: + irc.errorNotRegistered(Raise=True) + + def _sanitizeKey(self, key): + return key.rstrip('!? ') + + def _checkNotLocked(self, irc, channel, key): + if self.db.locked(channel, key): + irc.error(format('Factoid %q is locked.', key), Raise=True) + + def _getFactoid(self, irc, channel, key): + fact = self.db.getFactoid(channel, key) + if fact is not None: + return fact + else: + irc.error(format('Factoid %q not found.', key), Raise=True) + + def _getKeyAndFactoid(self, tokens): + if '_is_' in tokens: + p = '_is_'.__eq__ + elif 'is' in tokens: + p = 'is'.__eq__ + else: + self.log.debug('Invalid tokens for {add,replace}Factoid: %s.', + tokens) + s = 'Missing an \'is\' or \'_is_\'.' + raise ValueError, s + (key, newfact) = map(' '.join, utils.iter.split(p, tokens, maxsplit=1)) + key = self._sanitizeKey(key) + return (key, newfact) + + def addFactoid(self, irc, msg, tokens): + # First, check and see if the entire message matches a factoid key + channel = plugins.getChannel(msg.args[0]) + id = self._getUserId(irc, msg.prefix) + try: + (key, fact) = self._getKeyAndFactoid(tokens) + except ValueError, e: + irc.error(str(e), Raise=True) + # Check and make sure it's not in the DB already + if self.db.getFactoid(channel, key): + irc.error(format('Factoid %q already exists.', key), Raise=True) + self.db.addFactoid(channel, key, fact, id) + irc.replySuccess() + + def changeFactoid(self, irc, msg, tokens): + id = self._getUserId(irc, msg.prefix) + (key, regexp) = map(' '.join, + utils.iter.split('=~'.__eq__, tokens, maxsplit=1)) + channel = plugins.getChannel(msg.args[0]) + # Check and make sure it's in the DB + fact = self._getFactoid(irc, channel, key) + self._checkNotLocked(irc, channel, key) + # It's fair game if we get to here + try: + r = utils.str.perlReToReplacer(regexp) + except ValueError, e: + irc.errorInvalid('regexp', regexp, Raise=True) + fact = fact[0] + new_fact = r(fact) + self.db.updateFactoid(channel, key, new_fact, id) + irc.replySuccess() + + def augmentFactoid(self, irc, msg, tokens): + # Must be registered! + id = self._getUserId(irc, msg.prefix) + pairs = list(utils.seq.window(tokens, 2)) + isAlso = pairs.index(['is', 'also']) + key = ' '.join(tokens[:isAlso]) + new_text = ' '.join(tokens[isAlso+2:]) + channel = plugins.getChannel(msg.args[0]) + fact = self._getFactoid(irc, channel, key) + self._checkNotLocked(irc, channel, key) + # It's fair game if we get to here + fact = fact[0] + new_fact = format('%s, or %s', fact, new_text) + self.db.updateFactoid(channel, key, new_fact, id) + irc.replySuccess() + + def replaceFactoid(self, irc, msg, tokens): + # Must be registered! + channel = plugins.getChannel(msg.args[0]) + id = self._getUserId(irc, msg.prefix) + del tokens[0] # remove the "no," + try: + (key, fact) = self._getKeyAndFactoid(tokens) + except ValueError, e: + irc.error(str(e), Raise=True) + _ = self._getFactoid(irc, channel, key) + self._checkNotLocked(irc, channel, key) + self.db.removeFactoid(channel, key) + self.db.addFactoid(channel, key, fact, id) + irc.replySuccess() + + def literal(self, irc, msg, args, channel, key): + """[] + + Returns the literal factoid for the given factoid key. No parsing of + the factoid value is done as it is with normal retrieval. + is only necessary if the message isn't sent in the channel itself. + """ + fact = self._getFactoid(irc, channel, key) + fact = fact[0] + irc.reply(fact) + literal = wrap(literal, ['channeldb', 'text']) + + def factinfo(self, irc, msg, args, channel, key): + """[] + + Returns the various bits of info on the factoid for the given key. + is only necessary if the message isn't sent in the channel + itself. + """ + # Start building the response string + s = key + ': ' + # Next, get all the info and build the response piece by piece + info = self.db.getFactinfo(channel, key) + if not info: + irc.error(format('No such factoid: %q', key)) + return + (created_by, created_at, modified_by, modified_at, last_requested_by, + last_requested_at, requested_count, locked_by, locked_at) = info + # First, creation info. + # Map the integer created_by to the username + created_by = plugins.getUserName(created_by) + created_at = time.strftime(conf.supybot.reply.format.time(), + time.localtime(int(created_at))) + s += format('Created by %s on %s.', created_by, created_at) + # Next, modification info, if any. + if modified_by is not None: + modified_by = plugins.getUserName(modified_by) + modified_at = time.strftime(conf.supybot.reply.format.time(), + time.localtime(int(modified_at))) + s += format(' Last modified by %s on %s.',modified_by, modified_at) + # Next, last requested info, if any + if last_requested_by is not None: + last_by = last_requested_by # not an int user id + last_at = time.strftime(conf.supybot.reply.format.time(), + time.localtime(int(last_requested_at))) + req_count = requested_count + s += format(' Last requested by %s on %s, requested %n.', + last_by, last_at, (requested_count, 'time')) + # Last, locked info + if locked_at is not None: + lock_at = time.strftime(conf.supybot.reply.format.time(), + time.localtime(int(locked_at))) + lock_by = plugins.getUserName(locked_by) + s += format(' Locked by %s on %s.', lock_by, lock_at) + irc.reply(s) + factinfo = wrap(factinfo, ['channeldb', 'text']) + + def _lock(self, irc, msg, channel, user, key, locking=True): + #self.log.debug('in _lock') + #self.log.debug('id: %s' % id) + id = user.id + info = self.db.getFactinfo(channel, key) + if not info: + irc.error(format('No such factoid: %q', key)) + return + (created_by, _, _, _, _, _, _, locked_by, _) = info + # Don't perform redundant operations + if locking and locked_by is not None: + irc.error(format('Factoid %q is already locked.', key)) + return + if not locking and locked_by is None: + irc.error(format('Factoid %q is not locked.', key)) + return + # Can only lock/unlock own factoids unless you're an admin + #self.log.debug('admin?: %s' % ircdb.checkCapability(id, 'admin')) + #self.log.debug('created_by: %s' % created_by) + if not (ircdb.checkCapability(id, 'admin') or created_by == id): + if locking: + s = 'lock' + else: + s = 'unlock' + irc.error(format('Cannot %s someone else\'s factoid unless you ' + 'are an admin.', s)) + return + # Okay, we're done, ready to lock/unlock + if locking: + self.db.lock(channel, key, id) + else: + self.db.unlock(channel, key) + irc.replySuccess() + + def lock(self, irc, msg, args, channel, user, key): + """[] + + Locks the factoid with the given factoid key. Requires that the user + be registered and have created the factoid originally. is + only necessary if the message isn't sent in the channel itself. + """ + self._lock(irc, msg, channel, user, key, True) + lock = wrap(lock, ['channeldb', 'user', 'text']) + + def unlock(self, irc, msg, args, channel, user, key): + """[] + + Unlocks the factoid with the given factoid key. Requires that the + user be registered and have locked the factoid. is only + necessary if the message isn't sent in the channel itself. + """ + self._lock(irc, msg, channel, user, key, False) + unlock = wrap(unlock, ['channeldb', 'user', 'text']) + + def most(self, irc, msg, args, channel, method): + """[] {popular|authored|recent} + + Lists the most {popular|authored|recent} factoids. "popular" lists the + most frequently requested factoids. "authored" lists the author with + the most factoids. "recent" lists the most recently created factoids. + is only necessary if the message isn't sent in the channel + itself. + """ + method = method.capitalize() + method = getattr(self, '_most%s' % method, None) + if method is None: + raise callbacks.ArgumentError + limit = self.registryValue('mostCount', channel) + method(irc, channel, limit) + most = wrap(most, ['channeldb', + ('literal', ('popular', 'authored', 'recent'))]) + + def _mostAuthored(self, irc, channel, limit): + results = self.db.mostAuthored(channel, limit) + L = ['%s (%s)' % (plugins.getUserName(t[0]), int(t[1])) + for t in results] + if L: + author = 'author' + if len(L) != 1: + author = 'authors' + irc.reply(format('Most prolific %s: %L', author, L)) + else: + irc.error('There are no factoids in my database.') + + def _mostRecent(self, irc, channel, limit): + results = self.db.mostRecent(channel, limit) + L = [format('%q', t[0]) for t in results] + if L: + irc.reply(format('%n: %L', (len(L), 'latest', 'factoid'), L)) + else: + irc.error('There are no factoids in my database.') + + def _mostPopular(self, irc, channel, limit): + results = self.db.mostPopular(channel, limit) + L = [format('%q (%s)', t[0], t[1]) for t in results] + if L: + irc.reply( + format('Top %n: %L', (len(L), 'requested', 'factoid'), L)) + else: + irc.error('No factoids have been requested from my database.') + + def listauth(self, irc, msg, args, channel, author): + """[] + + Lists the keys of the factoids with the given author. Note that if an + author has an integer name, you'll have to use that author's id to use + this function (so don't use integer usernames!). is only + necessary if the message isn't sent in the channel itself. + """ + try: + id = ircdb.users.getUserId(author) + except KeyError: + irc.errorNoUser(name=author, Raise=True) + results = self.db.getKeysByAuthor(channel, id) + if not results: + irc.reply(format('No factoids by %q found.', author)) + return + keys = [format('%q', t[0]) for t in results] + s = format('Author search for %q (%i found): %L', + author, len(keys), keys) + irc.reply(s) + listauth = wrap(listauth, ['channeldb', 'something']) + + def listkeys(self, irc, msg, args, channel, search): + """[] + + Lists the keys of the factoids whose key contains the provided text. + is only necessary if the message isn't sent in the channel + itself. + """ + results = self.db.getKeysByGlob(channel, search) + if not results: + irc.reply(format('No keys matching %q found.', search)) + elif len(results) == 1 and \ + self.registryValue('showFactoidIfOnlyOneMatch', channel): + key = results[0][0] + self.invalidCommand(irc, msg, [key]) + else: + keys = [format('%q', tup[0]) for tup in results] + s = format('Key search for %q (%i found): %L', + search, len(keys), keys) + irc.reply(s) + listkeys = wrap(listkeys, ['channeldb', 'text']) + + def listvalues(self, irc, msg, args, channel, search): + """[] + + Lists the keys of the factoids whose value contains the provided text. + is only necessary if the message isn't sent in the channel + itself. + """ + results = self.db.getKeysByValueGlob(channel, search) + if not results: + irc.reply(format('No values matching %q found.', search)) + return + keys = [format('%q', tup[0]) for tup in results] + s = format('Value search for %q (%i found): %L', + search, len(keys), keys) + irc.reply(s) + listvalues = wrap(listvalues, ['channeldb', 'text']) + + def remove(self, irc, msg, args, channel, _, key): + """[] + + Deletes the factoid with the given key. is only necessary + if the message isn't sent in the channel itself. + """ + _ = self._getFactoid(irc, channel, key) + self._checkNotLocked(irc, channel, key) + self.db.removeFactoid(channel, key) + irc.replySuccess() + remove = wrap(remove, ['channeldb', 'user', 'text']) + + def random(self, irc, msg, args, channel): + """[] + + Displays a random factoid (along with its key) from the database. + is only necessary if the message isn't sent in the channel + itself. + """ + results = self.db.randomFactoid(channel) + if not results: + irc.error('No factoids in the database.') + return + (fact, key) = results + irc.reply(format('Random factoid: %q is %q', key, fact)) + random = wrap(random, ['channeldb']) + + +Class = MoobotFactoids + + +# vim:set shiftwidth=4 tabstop=8 expandtab textwidth=78: diff --git a/plugins/MoobotFactoids/test.py b/plugins/MoobotFactoids/test.py new file mode 100644 index 000000000..1449bd650 --- /dev/null +++ b/plugins/MoobotFactoids/test.py @@ -0,0 +1,343 @@ +# -*- encoding: utf-8 -*- +### +# Copyright (c) 2003-2005, Daniel DiPaolo +# 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.plugin as plugin +import supybot.ircutils as ircutils + +try: + import sqlite +except ImportError: + sqlite = None + +MF = plugin.loadPluginModule('MoobotFactoids') +MFconf = conf.supybot.plugins.MoobotFactoids + +class OptionListTestCase(SupyTestCase): + maxIterations = 267 + def _testOptions(self, s, L): + max = self.maxIterations + original = L[:] + while max and L: + max -= 1 + option = MF.plugin.pickOptions(s) + self.failUnless(option in original, + 'Option %s not in %s' % (option, original)) + if option in L: + L.remove(option) + self.failIf(L, 'Some options never seen: %s' % L) + + def testPickOptions(self): + self._testOptions('(a|b)', ['a', 'b']) + self._testOptions('a', ['a']) + self._testOptions('(a|b (c|d))', ['a', 'b c', 'b d']) + self._testOptions('(a|(b|)c)', ['a', 'bc', 'c']) + self._testOptions('(a(b|)|(c|)d)', ['a', 'ab', 'cd', 'd']) + self._testOptions('(a|)', ['a', '']) + self._testOptions('(|a)', ['a', '']) + self._testOptions('((a)|(b))', ['(a)', '(b)']) + +class FactoidsTestCase(ChannelPluginTestCase): + plugins = ('MoobotFactoids', 'User', 'String', 'Utilities') + config = {'reply.whenNotCommand': False} + def setUp(self): + ChannelPluginTestCase.setUp(self) + # Create a valid user to use + self.prefix = 'mf!bar@baz' + self.irc.feedMsg(ircmsgs.privmsg(self.nick, 'register tester moo', + prefix=self.prefix)) + m = self.irc.takeMsg() # Response to register. + + def testAddFactoid(self): + self.assertNotError('moo is foo') + # Check stripping punctuation + self.assertError('moo!? is foo') # 'moo' already exists + self.assertNotError('foo!? is foo') + self.assertResponse('foo', 'foo is foo') + self.assertNotError('bar is moo is moo') + self.assertResponse('bar', 'moo is moo') + # Check substitution + self.assertNotError('who is $who') + self.assertResponse('who', ircutils.nickFromHostmask(self.prefix)) + # Check that actions ("\x01ACTION...") don't match + m = ircmsgs.action(self.channel, 'is doing something') + self.irc.feedMsg(m) + self.assertNoResponse(' ', 1) + + def testLiteral(self): + self.assertError('literal moo') # no factoids yet + self.assertNotError('moo is foo') + self.assertResponse('literal moo', 'foo') + self.assertNotError('moo2 is moo!') + self.assertResponse('literal moo2', 'moo!') + self.assertNotError('moo3 is foo') + self.assertResponse('literal moo3', 'foo') + + def testGetFactoid(self): + self.assertNotError('moo is foo') + self.assertResponse('moo', 'foo') + self.assertNotError('moo2 is moo!') + self.assertResponse('moo2', 'moo2 is moo!') + self.assertNotError('moo3 is foo') + self.assertAction('moo3', 'foo') + # Test and make sure it's parsing + self.assertNotError('moo4 is (1|2|3)') + self.assertRegexp('moo4', '^(1|2|3)$') + # Check case-insensitivity + self.assertResponse('MOO', 'foo') + self.assertResponse('mOo', 'foo') + self.assertResponse('MoO', 'foo') + # Check the "_is_" ability + self.assertNotError('remove moo') + self.assertNotError('moo _is_ foo') + self.assertResponse('moo', 'foo') + self.assertNotError('foo is bar _is_ baz') + self.assertResponse('foo is bar', 'foo is bar is baz') + + def testFactinfo(self): + self.assertNotError('moo is foo') + self.assertRegexp('factinfo moo', '^moo: Created by tester on.*$') + self.assertNotError('moo') + self.assertRegexp('factinfo moo', self.prefix + '.*1 time') + self.assertNotError('moo') + self.assertRegexp('factinfo moo', self.prefix + '.*2 times') + self.assertNotError('moo =~ s/foo/bar/') + self.assertRegexp('factinfo moo', + '^moo: Created by tester on' + '.*?\. Last modified by tester on .*?\. ' + 'Last requested by %s on .*?, ' + 'requested 2 times.$' % self.prefix) + self.assertNotError('lock moo') + self.assertRegexp('factinfo moo', + '^moo: Created by tester on' + '.*?\. Last modified by tester on .*?\. ' + 'Last requested by %s on .*?, ' + 'requested 2 times. ' + 'Locked by tester on .*\.$' % self.prefix) + self.assertNotError('unlock moo') + self.assertRegexp('factinfo moo', + '^moo: Created by tester on' + '.*?\. Last modified by tester on .*?\. ' + 'Last requested by %s on .*?, ' + 'requested 2 times.$' % self.prefix) + # Make sure I solved this bug + # Check and make sure all the other stuff is reset + self.assertNotError('foo is bar') + self.assertNotError('foo =~ s/bar/blah/') + self.assertNotError('foo') + self.assertNotError('no foo is baz') + self.assertRegexp('factinfo foo', + '^foo: Created by tester on' + '(?!(request|modif)).*?\.$') + + def testLockUnlock(self): + # disable world.testing since we want new users to not + # magically be endowed with the admin capability + try: + world.testing = False + self.assertNotError('moo is moo') + self.assertNotError('lock moo') + self.assertRegexp('factinfo moo', + '^moo: Created by tester on' + '.*?\. Locked by tester on .*?\.') + # switch user + original = self.prefix + self.prefix = 'moo!moo@moo' + self.assertNotError('register nottester moo', private=True) + self.assertError('unlock moo') + self.assertRegexp('factinfo moo', + '^moo: Created by tester on' + '.*?\. Locked by tester on .*?\.') + # switch back + self.prefix = original + self.assertNotError('identify tester moo', private=True) + self.assertNotError('unlock moo') + self.assertRegexp('factinfo moo', + '^moo: Created by tester on.*?\.') + finally: + world.testing = True + + def testChangeFactoid(self): + self.assertNotError('moo is moo') + self.assertNotError('moo =~ s/moo/moos/') + self.assertResponse('moo', 'moos') + self.assertNotError('moo =~ s/reply/action/') + self.assertAction('moo', 'moos') + self.assertNotError('moo =~ s/moos/(moos|woofs)/') + self.assertActionRegexp('moo', '^(moos|woofs)$') + self.assertError('moo =~ s/moo/') + + def testMost(self): + userPrefix1 = 'moo!bar@baz'; userNick1 = 'moo' + userPrefix2 = 'boo!bar@baz'; userNick2 = 'boo' + self.assertNotError('register %s bar' % userNick1, + frm=userPrefix1, private=True) + self.assertNotError('register %s bar' % userNick2, + frm=userPrefix2, private=True) + # Check an empty database + self.assertError('most popular') + self.assertError('most authored') + self.assertError('most recent') + # Check singularity response + self.prefix = userPrefix1 + self.assertNotError('moogle is moo') + self.assertError('most popular') + self.assertResponse('most authored', + 'Most prolific author: moo (1)') + self.assertRegexp('most recent', "1 latest factoid:.*moogle") + self.assertResponse('moogle', 'moo') + self.assertRegexp('most popular', + "Top 1 requested factoid:.*moogle.*(1)") + # Check plural response + self.prefix = userPrefix2 + self.assertNotError('mogle is mo') + self.assertRegexp('most authored', + 'Most prolific authors:.*moo.*(1).*boo.*(1)') + self.assertRegexp('most recent', + "2 latest factoids:.*mogle.*moogle.*") + self.assertResponse('moogle', 'moo') + self.assertRegexp('most popular', + "Top 1 requested factoid:.*moogle.*(2)") + self.assertResponse('mogle', 'mo') + self.assertRegexp('most popular', + "Top 2 requested factoids:.*" + "moogle.*(2).*mogle.*(1)") + # Check most author ordering + self.assertNotError('moo is oom') + self.assertRegexp('most authored', + 'Most prolific authors:.*boo.*(2).*moo.*(1)') + + def testListkeys(self): + self.assertResponse('listkeys %', 'No keys matching "%" found.') + self.assertNotError('moo is moo') + # With this set, if only one key matches, it should respond with + # the factoid + orig = MFconf.showFactoidIfOnlyOneMatch() + try: + MFconf.showFactoidIfOnlyOneMatch.setValue(True) + self.assertResponse('listkeys moo', 'moo') + self.assertResponse('listkeys foo', 'No keys matching "foo" ' + 'found.') + # Throw in a bunch more + for i in range(10): + self.assertNotError('moo%s is moo' % i) + self.assertRegexp('listkeys moo', + '^Key search for "moo" ' + '\(11 found\): ("moo\d*", )+and "moo9"$') + self.assertNotError('foo is bar') + self.assertRegexp('listkeys %', + '^Key search for "\%" ' + '\(12 found\): "foo", ("moo\d*", )+and ' + '"moo9"$') + # Check quoting + self.assertNotError('foo\' is bar') + self.assertResponse('listkeys foo', + 'Key search for "foo" ' + '(2 found): "foo" and "foo\'"') + # Check unicode stuff + self.assertResponse('listkeys Б', 'No keys matching "Б" found.') + self.assertNotError('АБВГДЕЖ is foo') + self.assertNotError('АБВГДЕЖЗИ is foo') + self.assertResponse('listkeys Б', + 'Key search for "Б" ' + '(2 found): "АБВГДЕЖ" and "АБВГДЕЖЗИ"') + finally: + MFconf.showFactoidIfOnlyOneMatch.setValue(orig) + + def testListvalues(self): + self.assertNotError('moo is moo') + self.assertResponse('listvalues moo', + 'Value search for "moo" (1 found): "moo"') + + def testListauth(self): + self.assertNotError('moo is moo') + self.assertRegexp('listauth tester', 'tester.*\(1 found\):.*moo') + self.assertError('listauth moo') + + def testRemove(self): + self.assertNotError('moo is moo') + self.assertNotError('lock moo') + self.assertError('remove moo') + self.assertNotError('unlock moo') + self.assertNotError('remove moo') + + def testAugmentFactoid(self): + self.assertNotError('moo is foo') + self.assertNotError('moo is also bar') + self.assertResponse('moo', 'moo is foo, or bar') + self.assertNotError('moo is bar _is_ foo') + self.assertNotError('moo is bar is also foo') + self.assertResponse('moo is bar', 'moo is bar is foo, or foo') + + def testReplaceFactoid(self): + self.assertNotError('moo is foo') + self.assertNotError('no moo is bar') + self.assertResponse('moo', 'moo is bar') + self.assertNotError('no, moo is baz') + self.assertResponse('moo', 'moo is baz') + self.assertNotError('lock moo') + self.assertError('no moo is qux') + self.assertNotError('foo is bar _is_ foo') + self.assertNotError('no foo is bar _is_ baz') + self.assertResponse('foo is bar', 'foo is bar is baz') + + def testRegexpNotCalledIfAlreadyHandled(self): + self.assertResponse('echo foo is bar', 'foo is bar') + self.assertNoResponse(' ', 3) + + def testNoResponseToCtcp(self): + self.assertNotError('foo is bar') + self.assertResponse('foo', 'foo is bar') + self.irc.feedMsg(ircmsgs.privmsg(self.irc.nick, '\x01VERSION\x01')) + m = self.irc.takeMsg() + self.failIf(m) + + def testAddFactoidNotCalledWithBadNestingSyntax(self): + self.assertError('re s/Error:.*/jbm is a tard/ ]') + self.assertNoResponse(' ', 3) + + def testConfigShowFactoidIfOnlyOneMatch(self): + # man these are long + MFconf = conf.supybot.plugins.MoobotFactoids + self.assertNotError('foo is bar') + # Default to saying the factoid value + self.assertResponse('listkeys foo', 'foo is bar') + # Check the False setting + MFconf.showFactoidIfOnlyOneMatch.setValue(False) + self.assertResponse('listkeys foo', 'Key search for "foo" ' + '(1 found): "foo"') + + def testRandom(self): + self.assertNotError('foo is bar') + self.assertNotError('bar is baz') + self.assertRegexp('random', r'bar|baz') + + +# vim:set shiftwidth=4 tabstop=8 expandtab textwidth=78: