From 11446c7cb594eee560076cea7fceb131faa5ecc3 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Wed, 18 Nov 2009 01:48:11 -0500 Subject: [PATCH 001/243] some work in progress getting plugins to work with sqlite3 --- plugins/Factoids/plugin.py | 66 ++++++++++++++++++++------------------ plugins/__init__.py | 4 +-- 2 files changed, 36 insertions(+), 34 deletions(-) diff --git a/plugins/Factoids/plugin.py b/plugins/Factoids/plugin.py index 66931e318..7881b4b47 100644 --- a/plugins/Factoids/plugin.py +++ b/plugins/Factoids/plugin.py @@ -41,7 +41,7 @@ import supybot.ircutils as ircutils import supybot.callbacks as callbacks try: - import sqlite + import sqlite3 as sqlite except ImportError: raise callbacks.Error, 'You need to have PySQLite installed to use this ' \ 'plugin. Download it at ' \ @@ -126,11 +126,11 @@ class Factoids(callbacks.Plugin, plugins.ChannelDBHandler): def learn(self, irc, msg, args, channel, key, factoid): db = self.getDb(channel) cursor = db.cursor() - cursor.execute("SELECT id, locked FROM keys WHERE key LIKE %s", key) - if cursor.rowcount == 0: - cursor.execute("""INSERT INTO keys VALUES (NULL, %s, 0)""", key) + cursor.execute("SELECT id, locked FROM keys WHERE key LIKE ?", (key,)) + if cursor.rowcount <= 0: + cursor.execute("""INSERT INTO keys VALUES (NULL, ?, 0)""", (key,)) db.commit() - cursor.execute("SELECT id, locked FROM keys WHERE key LIKE %s",key) + cursor.execute("SELECT id, locked FROM keys WHERE key LIKE ?", (key,)) (id, locked) = map(int, cursor.fetchone()) capability = ircdb.makeChannelCapability(channel, 'factoids') if not locked: @@ -139,8 +139,8 @@ class Factoids(callbacks.Plugin, plugins.ChannelDBHandler): else: name = msg.nick cursor.execute("""INSERT INTO factoids VALUES - (NULL, %s, %s, %s, %s)""", - id, name, int(time.time()), factoid) + (NULL, ?, ?, ?, ?)""", + (id, name, int(time.time()), factoid)) db.commit() irc.replySuccess() else: @@ -160,9 +160,9 @@ class Factoids(callbacks.Plugin, plugins.ChannelDBHandler): db = self.getDb(channel) cursor = db.cursor() cursor.execute("""SELECT factoids.fact FROM factoids, keys - WHERE keys.key LIKE %s AND factoids.key_id=keys.id + WHERE keys.key LIKE ? AND factoids.key_id=keys.id ORDER BY factoids.id - LIMIT 20""", key) + LIMIT 20""", (key,)) return [t[0] for t in cursor.fetchall()] def _replyFactoids(self, irc, msg, key, factoids, @@ -229,7 +229,7 @@ class Factoids(callbacks.Plugin, plugins.ChannelDBHandler): """ db = self.getDb(channel) cursor = db.cursor() - cursor.execute("UPDATE keys SET locked=1 WHERE key LIKE %s", key) + cursor.execute("UPDATE keys SET locked=1 WHERE key LIKE ?", (key,)) db.commit() irc.replySuccess() lock = wrap(lock, ['channel', 'text']) @@ -243,7 +243,7 @@ class Factoids(callbacks.Plugin, plugins.ChannelDBHandler): """ db = self.getDb(channel) cursor = db.cursor() - cursor.execute("UPDATE keys SET locked=0 WHERE key LIKE %s", key) + cursor.execute("UPDATE keys SET locked=0 WHERE key LIKE ?", (key,)) db.commit() irc.replySuccess() unlock = wrap(unlock, ['channel', 'text']) @@ -271,25 +271,26 @@ class Factoids(callbacks.Plugin, plugins.ChannelDBHandler): cursor = db.cursor() cursor.execute("""SELECT keys.id, factoids.id FROM keys, factoids - WHERE key LIKE %s AND - factoids.key_id=keys.id""", key) - if cursor.rowcount == 0: + WHERE key LIKE ? AND + factoids.key_id=keys.id""", (key,)) + results = cursor.fetchall() + if len(results) == 0: irc.error('There is no such factoid.') - elif cursor.rowcount == 1 or number is True: - (id, _) = cursor.fetchone() - cursor.execute("""DELETE FROM factoids WHERE key_id=%s""", id) - cursor.execute("""DELETE FROM keys WHERE key LIKE %s""", key) + elif len(results) == 1 or number is True: + (id, _) = results[0] + cursor.execute("""DELETE FROM factoids WHERE key_id=?""", (id,)) + cursor.execute("""DELETE FROM keys WHERE key LIKE ?""", (key,)) db.commit() irc.replySuccess() else: if number is not None: - results = cursor.fetchall() + #results = cursor.fetchall() try: (_, id) = results[number-1] except IndexError: irc.error('Invalid factoid number.') return - cursor.execute("DELETE FROM factoids WHERE id=%s", id) + cursor.execute("DELETE FROM factoids WHERE id=?", (id,)) db.commit() irc.replySuccess() else: @@ -313,7 +314,7 @@ class Factoids(callbacks.Plugin, plugins.ChannelDBHandler): if cursor.rowcount != 0: L = [] for (factoid, id) in cursor.fetchall(): - cursor.execute("""SELECT key FROM keys WHERE id=%s""", id) + cursor.execute("""SELECT key FROM keys WHERE id=?""", (id,)) (key,) = cursor.fetchone() L.append('"%s": %s' % (ircutils.bold(key), factoid)) irc.reply('; '.join(L)) @@ -330,14 +331,14 @@ class Factoids(callbacks.Plugin, plugins.ChannelDBHandler): """ db = self.getDb(channel) cursor = db.cursor() - cursor.execute("SELECT id, locked FROM keys WHERE key LIKE %s", key) + cursor.execute("SELECT id, locked FROM keys WHERE key LIKE ?", (key,)) if cursor.rowcount == 0: irc.error('No factoid matches that key.') return (id, locked) = map(int, cursor.fetchone()) cursor.execute("""SELECT added_by, added_at FROM factoids - WHERE key_id=%s - ORDER BY id""", id) + WHERE key_id=? + ORDER BY id""", (id,)) factoids = cursor.fetchall() L = [] counter = 0 @@ -364,16 +365,17 @@ class Factoids(callbacks.Plugin, plugins.ChannelDBHandler): cursor = db.cursor() cursor.execute("""SELECT factoids.id, factoids.fact FROM keys, factoids - WHERE keys.key LIKE %s AND - keys.id=factoids.key_id""", key) - if cursor.rowcount == 0: + WHERE keys.key LIKE ? AND + keys.id=factoids.key_id""", (key,)) + results = cursor.fetchall() + if len(results) == 0: irc.error(format('I couldn\'t find any key %q', key)) return - elif cursor.rowcount < number: + elif len(results) < number: irc.errorInvalid('key id') - (id, fact) = cursor.fetchall()[number-1] + (id, fact) = results[number-1] newfact = replacer(fact) - cursor.execute("UPDATE factoids SET fact=%s WHERE id=%s", newfact, id) + cursor.execute("UPDATE factoids SET fact=? WHERE id=?", (newfact, id)) db.commit() irc.replySuccess() change = wrap(change, ['channel', 'something', @@ -408,10 +410,10 @@ class Factoids(callbacks.Plugin, plugins.ChannelDBHandler): db.create_function(predicateName, 1, p) predicateName += 'p' for glob in globs: - criteria.append('TARGET LIKE %s') + criteria.append('TARGET LIKE ?') formats.append(glob.translate(self._sqlTrans)) cursor = db.cursor() - sql = """SELECT keys.key FROM %s WHERE %s""" % \ + sql = """SELECT keys.key FROM ? WHERE ?""" % \ (', '.join(tables), ' AND '.join(criteria)) sql = sql.replace('TARGET', target) cursor.execute(sql, formats) diff --git a/plugins/__init__.py b/plugins/__init__.py index c177eb840..5de058f4b 100644 --- a/plugins/__init__.py +++ b/plugins/__init__.py @@ -62,7 +62,7 @@ try: mxCrap[name] = module sys.modules.pop(name) # Now that the mx crap is gone, we can import sqlite. - import sqlite + import sqlite3 as sqlite # And now we'll put it back, even though it sucks. sys.modules.update(mxCrap) # Just in case, we'll do this as well. It doesn't seem to work fine by @@ -176,7 +176,7 @@ class ChannelDBHandler(object): db = self.makeDb(self.makeFilename(channel)) else: db = self.dbCache[channel] - db.autocommit = 1 + db.isolation_level = None return db def die(self): From cc1f4ea01574c556dc2e990b5acda1e650bd9d2f Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Wed, 18 Nov 2009 02:03:44 -0500 Subject: [PATCH 002/243] some more mods toward getting sqlite3 to work --- plugins/Factoids/plugin.py | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/plugins/Factoids/plugin.py b/plugins/Factoids/plugin.py index 7881b4b47..89e4e8ae4 100644 --- a/plugins/Factoids/plugin.py +++ b/plugins/Factoids/plugin.py @@ -127,11 +127,13 @@ class Factoids(callbacks.Plugin, plugins.ChannelDBHandler): db = self.getDb(channel) cursor = db.cursor() cursor.execute("SELECT id, locked FROM keys WHERE key LIKE ?", (key,)) - if cursor.rowcount <= 0: + results = cursor.fetchall() + if len(results) == 0: cursor.execute("""INSERT INTO keys VALUES (NULL, ?, 0)""", (key,)) db.commit() cursor.execute("SELECT id, locked FROM keys WHERE key LIKE ?", (key,)) - (id, locked) = map(int, cursor.fetchone()) + results = cursor.fetchall() + (id, locked) = map(int, results[0]) capability = ircdb.makeChannelCapability(channel, 'factoids') if not locked: if ircdb.users.hasUser(msg.prefix): @@ -297,7 +299,7 @@ class Factoids(callbacks.Plugin, plugins.ChannelDBHandler): irc.error('%s factoids have that key. ' 'Please specify which one to remove, ' 'or use * to designate all of them.' % - cursor.rowcount) + len(results)) forget = wrap(forget, ['channel', many('something')]) def random(self, irc, msg, args, channel): @@ -311,9 +313,10 @@ class Factoids(callbacks.Plugin, plugins.ChannelDBHandler): cursor.execute("""SELECT fact, key_id FROM factoids ORDER BY random() LIMIT 3""") - if cursor.rowcount != 0: + results = cursor.fetchall() + if len(results) != 0: L = [] - for (factoid, id) in cursor.fetchall(): + for (factoid, id) in results: cursor.execute("""SELECT key FROM keys WHERE id=?""", (id,)) (key,) = cursor.fetchone() L.append('"%s": %s' % (ircutils.bold(key), factoid)) @@ -332,10 +335,11 @@ class Factoids(callbacks.Plugin, plugins.ChannelDBHandler): db = self.getDb(channel) cursor = db.cursor() cursor.execute("SELECT id, locked FROM keys WHERE key LIKE ?", (key,)) - if cursor.rowcount == 0: + results = cursor.fetchall() + if len(results) == 0: irc.error('No factoid matches that key.') return - (id, locked) = map(int, cursor.fetchone()) + (id, locked) = map(int, results[0]) cursor.execute("""SELECT added_by, added_at FROM factoids WHERE key_id=? ORDER BY id""", (id,)) @@ -413,16 +417,17 @@ class Factoids(callbacks.Plugin, plugins.ChannelDBHandler): criteria.append('TARGET LIKE ?') formats.append(glob.translate(self._sqlTrans)) cursor = db.cursor() - sql = """SELECT keys.key FROM ? WHERE ?""" % \ + sql = """SELECT keys.key FROM %s WHERE %s""" % \ (', '.join(tables), ' AND '.join(criteria)) sql = sql.replace('TARGET', target) cursor.execute(sql, formats) - if cursor.rowcount == 0: + results = cursor.fetchall() + if len(results) == 0: irc.reply('No keys matched that query.') - elif cursor.rowcount == 1 and \ + elif len(results) == 1 and \ self.registryValue('showFactoidIfOnlyOneMatch', channel): self.whatis(irc, msg, [cursor.fetchone()[0]]) - elif cursor.rowcount > 100: + elif len(results) > 100: irc.reply('More than 100 keys matched that query; ' 'please narrow your query.') else: From 5bf71395e71ab00a7ad25186c31b0c0394cbabdf Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Wed, 18 Nov 2009 02:27:48 -0500 Subject: [PATCH 003/243] another step toward sqlite3 --- plugins/Factoids/plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/Factoids/plugin.py b/plugins/Factoids/plugin.py index 89e4e8ae4..2f22ed482 100644 --- a/plugins/Factoids/plugin.py +++ b/plugins/Factoids/plugin.py @@ -426,7 +426,7 @@ class Factoids(callbacks.Plugin, plugins.ChannelDBHandler): irc.reply('No keys matched that query.') elif len(results) == 1 and \ self.registryValue('showFactoidIfOnlyOneMatch', channel): - self.whatis(irc, msg, [cursor.fetchone()[0]]) + self.whatis(irc, msg, [results[0]]) elif len(results) > 100: irc.reply('More than 100 keys matched that query; ' 'please narrow your query.') From b77c649c8a72ca5ce1292ddbf16b9e43537525ea Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Wed, 18 Nov 2009 12:04:52 -0500 Subject: [PATCH 004/243] factoids now works with sqlite3, all tests pass. --- plugins/Factoids/plugin.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/Factoids/plugin.py b/plugins/Factoids/plugin.py index 2f22ed482..3ab3df4ad 100644 --- a/plugins/Factoids/plugin.py +++ b/plugins/Factoids/plugin.py @@ -426,12 +426,12 @@ class Factoids(callbacks.Plugin, plugins.ChannelDBHandler): irc.reply('No keys matched that query.') elif len(results) == 1 and \ self.registryValue('showFactoidIfOnlyOneMatch', channel): - self.whatis(irc, msg, [results[0]]) + self.whatis(irc, msg, [results[0][0]]) elif len(results) > 100: irc.reply('More than 100 keys matched that query; ' 'please narrow your query.') else: - keys = [repr(t[0]) for t in cursor.fetchall()] + keys = [repr(t[0]) for t in results] s = format('%L', keys) irc.reply(s) search = wrap(search, ['channel', From e303cab7ae933d7a87eb2ee83f89449e29143698 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Wed, 18 Nov 2009 22:47:56 -0500 Subject: [PATCH 005/243] work on getting moobotfactoids to work with sqlite3 note: needs sqlite3 version > 3.3.1, so that it is not restricted against cross-thread usage. --- plugins/MoobotFactoids/plugin.py | 99 +++++++++++++++----------------- 1 file changed, 47 insertions(+), 52 deletions(-) diff --git a/plugins/MoobotFactoids/plugin.py b/plugins/MoobotFactoids/plugin.py index c0818bc77..a3ac8194e 100644 --- a/plugins/MoobotFactoids/plugin.py +++ b/plugins/MoobotFactoids/plugin.py @@ -98,7 +98,7 @@ class SqliteMoobotDB(object): def _getDb(self, channel): try: - import sqlite + import sqlite3 as sqlite except ImportError: raise callbacks.Error, \ 'You need to have PySQLite installed to use this ' \ @@ -133,11 +133,12 @@ class SqliteMoobotDB(object): db = self._getDb(channel) cursor = db.cursor() cursor.execute("""SELECT fact FROM factoids - WHERE key LIKE %s""", key) - if cursor.rowcount == 0: + WHERE key LIKE ?""", (key,)) + results = cursor.fetchall() + if len(results) == 0: return None else: - return cursor.fetchall()[0] + return results[0] def getFactinfo(self, channel, key): db = self._getDb(channel) @@ -147,63 +148,65 @@ class SqliteMoobotDB(object): last_requested_by, last_requested_at, requested_count, locked_by, locked_at FROM factoids - WHERE key LIKE %s""", key) - if cursor.rowcount == 0: + WHERE key LIKE ?""", (key,)) + results = cursor.fetchall() + if len(results) == 0: return None else: - return cursor.fetchone() + return results[0] 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: + results = cursor.fetchall() + if len(results) == 0: return None else: - return cursor.fetchone() + return results[0] 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) + (?, ?, ?, NULL, NULL, NULL, NULL, + NULL, NULL, ?, 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) + SET fact=?, modified_by=?, + modified_at=? WHERE key LIKE ?""", + (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, + last_requested_by = ?, + last_requested_at = ?, requested_count = requested_count + 1 - WHERE key = %s""", - hostmask, int(time.time()), key) + WHERE key = ?""", + (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) + cursor.execute("""DELETE FROM factoids WHERE key LIKE ?""", + (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) + WHERE key LIKE ?""", (key,)) if cursor.fetchone()[0] is None: return False else: @@ -213,17 +216,17 @@ class SqliteMoobotDB(object): 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) + SET locked_by=?, locked_at=? + WHERE key LIKE ?""", + (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) + SET locked_by=?, locked_at=? + WHERE key LIKE ?""", (None, None, key)) db.commit() def mostAuthored(self, channel, limit): @@ -231,14 +234,14 @@ class SqliteMoobotDB(object): cursor = db.cursor() cursor.execute("""SELECT created_by, count(key) FROM factoids GROUP BY created_by - ORDER BY count(key) DESC LIMIT %s""", limit) + ORDER BY count(key) DESC LIMIT ?""", (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) + ORDER BY created_at DESC LIMIT ?""", (limit,)) return cursor.fetchall() def mostPopular(self, channel, limit): @@ -246,43 +249,35 @@ class SqliteMoobotDB(object): 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() + ORDER BY requested_count DESC LIMIT ?""", (limit,)) + results = cursor.fetchall() + return results 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() + cursor.execute("""SELECT key FROM factoids WHERE created_by=? + ORDER BY key""", (authorId,)) + results = cursor.fetchall() + return results 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() + cursor.execute("""SELECT key FROM factoids WHERE key LIKE ? + ORDER BY key""", (glob,)) + results = cursor.fetchall() + return results 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() + cursor.execute("""SELECT key FROM factoids WHERE fact LIKE ? + ORDER BY key""", (glob,)) + results = cursor.fetchall() + return results MoobotDB = plugins.DB('MoobotFactoids', {'sqlite': SqliteMoobotDB}) From fcd262cd4bb9df7f8319fe7034db3fde5210752e Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Fri, 5 Mar 2010 13:03:08 -0500 Subject: [PATCH 006/243] implement factoid usage counter --- plugins/Factoids/config.py | 7 ++++- plugins/Factoids/plugin.py | 60 +++++++++++++++++++++++++++++++------- 2 files changed, 55 insertions(+), 12 deletions(-) diff --git a/plugins/Factoids/config.py b/plugins/Factoids/config.py index 79b43b654..2e6f5b3c8 100644 --- a/plugins/Factoids/config.py +++ b/plugins/Factoids/config.py @@ -63,5 +63,10 @@ conf.registerChannelValue(Factoids, 'format', the response given when a factoid's value is requested. All the standard substitutes apply, in addition to "$key" for the factoid's key and "$value" for the factoid's value.""")) - +conf.registerChannelValue(Factoids, 'keepRankInfo', + registry.Boolean(True, """Determines whether we keep updating the usage + count for each factoid, for popularity ranking.""")) +conf.registerChannelValue(Factoids, 'rankListLength', + registry.Integer(20, """Determines the number of factoid keys returned + by the factrank command.""")) # vim:set shiftwidth=4 softtabstop=4 expandtab textwidth=79: diff --git a/plugins/Factoids/plugin.py b/plugins/Factoids/plugin.py index e83ffd1b8..2de1da61d 100644 --- a/plugins/Factoids/plugin.py +++ b/plugins/Factoids/plugin.py @@ -96,6 +96,7 @@ class Factoids(callbacks.Plugin, plugins.ChannelDBHandler): key_id INTEGER, added_by TEXT, added_at TIMESTAMP, + usage_count INTEGER, fact TEXT )""") cursor.execute("""CREATE TRIGGER remove_factoids @@ -141,8 +142,8 @@ class Factoids(callbacks.Plugin, plugins.ChannelDBHandler): else: name = msg.nick cursor.execute("""INSERT INTO factoids VALUES - (NULL, %s, %s, %s, %s)""", - id, name, int(time.time()), factoid) + (NULL, %s, %s, %s, %s, %s)""", + id, name, int(time.time()), 0, factoid) db.commit() irc.replySuccess() else: @@ -161,18 +162,33 @@ class Factoids(callbacks.Plugin, plugins.ChannelDBHandler): def _lookupFactoid(self, channel, key): db = self.getDb(channel) cursor = db.cursor() - cursor.execute("""SELECT factoids.fact FROM factoids, keys + cursor.execute("""SELECT factoids.fact, factoids.id FROM factoids, keys WHERE keys.key LIKE %s AND factoids.key_id=keys.id ORDER BY factoids.id LIMIT 20""", key) - return [t[0] for t in cursor.fetchall()] - - def _replyFactoids(self, irc, msg, key, factoids, + return cursor.fetchall() + #return [t[0] for t in cursor.fetchall()] + + def _updateRank(self, channel, factoids): + if self.registryValue('keepRankInfo', channel): + db = self.getDb(channel) + cursor = db.cursor() + for (fact,id) in factoids: + cursor.execute("""SELECT factoids.usage_count + FROM factoids + WHERE factoids.id=%s""", id) + old_count = cursor.fetchall()[0][0] + cursor.execute("UPDATE factoids SET usage_count=%s WHERE id=%s", old_count + 1, id) + db.commit() + + def _replyFactoids(self, irc, msg, key, channel, factoids, number=0, error=True): if factoids: + print factoids if number: try: - irc.reply(factoids[number-1]) + irc.reply(factoids[number-1][0]) + self._updateRank(channel, [factoids[number-1]]) except IndexError: irc.error('That\'s not a valid number for that key.') return @@ -184,15 +200,16 @@ class Factoids(callbacks.Plugin, plugins.ChannelDBHandler): return ircutils.standardSubstitute(irc, msg, formatter, env) if len(factoids) == 1: - irc.reply(prefixer(factoids[0])) + irc.reply(prefixer(factoids[0][0])) else: factoidsS = [] counter = 1 for factoid in factoids: - factoidsS.append(format('(#%i) %s', counter, factoid)) + factoidsS.append(format('(#%i) %s', counter, factoid[0])) counter += 1 irc.replies(factoidsS, prefixer=prefixer, joiner=', or ', onlyPrefixFirst=True) + self._updateRank(channel, factoids) elif error: irc.error('No factoid matches that key.') @@ -202,7 +219,7 @@ class Factoids(callbacks.Plugin, plugins.ChannelDBHandler): if self.registryValue('replyWhenInvalidCommand', channel): key = ' '.join(tokens) factoids = self._lookupFactoid(channel, key) - self._replyFactoids(irc, msg, key, factoids, error=False) + self._replyFactoids(irc, msg, key, channel, factoids, error=False) def whatis(self, irc, msg, args, channel, words): """[] [] @@ -219,9 +236,30 @@ class Factoids(callbacks.Plugin, plugins.ChannelDBHandler): irc.errorInvalid('key id') key = ' '.join(words) factoids = self._lookupFactoid(channel, key) - self._replyFactoids(irc, msg, key, factoids, number) + self._replyFactoids(irc, msg, key, channel, factoids, number) whatis = wrap(whatis, ['channel', many('something')]) + def factrank(self, irc, msg, args, channel): + """[] + + Returns a list of top-ranked factoid keys, sorted by usage count + (rank). The number of factoid keys returned is set by the + rankListLength registry value. is only necessary if the + message isn't sent in the channel itself. + """ + numfacts = self.registryValue('rankListLength', channel) + db = self.getDb(channel) + cursor = db.cursor() + cursor.execute("""SELECT keys.key, factoids.usage_count + FROM keys, factoids + WHERE factoids.key_id=keys.id + ORDER BY factoids.usage_count DESC + LIMIT %s""", numfacts) + factkeys = cursor.fetchall() + s = [ "#%d %s (%d)" % (i, key[0], key[1]) for i, key in enumerate(factkeys) ] + irc.reply(", ".join(s)) + factrank = wrap(factrank, ['channel']) + def lock(self, irc, msg, args, channel, key): """[] From 9db1598a0e0e65a6749d49c9fbf230b31e19d1b9 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Fri, 5 Mar 2010 14:51:25 -0500 Subject: [PATCH 007/243] sort keys in factoid search output by alphabetically by key name. --- plugins/Factoids/plugin.py | 1 + 1 file changed, 1 insertion(+) diff --git a/plugins/Factoids/plugin.py b/plugins/Factoids/plugin.py index 2de1da61d..910d809e6 100644 --- a/plugins/Factoids/plugin.py +++ b/plugins/Factoids/plugin.py @@ -453,6 +453,7 @@ class Factoids(callbacks.Plugin, plugins.ChannelDBHandler): cursor = db.cursor() sql = """SELECT keys.key FROM %s WHERE %s""" % \ (', '.join(tables), ' AND '.join(criteria)) + sql = sql + " ORDER BY keys.key" sql = sql.replace('TARGET', target) cursor.execute(sql, formats) if cursor.rowcount == 0: From d1c00ccbaf1609f6e53e0e89fe714ec383a430e6 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Mon, 8 Mar 2010 14:47:27 -0500 Subject: [PATCH 008/243] remove rogue test-print --- plugins/Factoids/plugin.py | 1 - 1 file changed, 1 deletion(-) diff --git a/plugins/Factoids/plugin.py b/plugins/Factoids/plugin.py index 910d809e6..787097493 100644 --- a/plugins/Factoids/plugin.py +++ b/plugins/Factoids/plugin.py @@ -184,7 +184,6 @@ class Factoids(callbacks.Plugin, plugins.ChannelDBHandler): def _replyFactoids(self, irc, msg, key, channel, factoids, number=0, error=True): if factoids: - print factoids if number: try: irc.reply(factoids[number-1][0]) From 13c244c9a7fafc6ba4cc7db4e7c17124b1eb7dbe Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Mon, 8 Mar 2010 16:39:45 -0500 Subject: [PATCH 009/243] fix some time display issues in standardsubstitute: first, use explicit time.strftime() instead of time.ctime, since ctime() leaves an extra space between month and date, if date is single-digit. second, use stftime('%Z') for timezone, old code was a bug which always displayed the daylight saving timezone name, even when it wasn't in effect. time.daylight is not a dst flag, it is a flag for whether a dst timezone is /defined/, not if it is in effect. --- src/ircutils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ircutils.py b/src/ircutils.py index 4c0aa8a88..5d782f14d 100644 --- a/src/ircutils.py +++ b/src/ircutils.py @@ -642,7 +642,7 @@ def standardSubstitute(irc, msg, text, env=None): return msg.nick else: return 'someone' - ctime = time.ctime() + ctime = time.strftime("%a %b %d %H:%M:%S %Y") localtime = time.localtime() vars = CallableValueIrcDict({ 'who': msg.nick, @@ -664,7 +664,7 @@ def standardSubstitute(irc, msg, text, env=None): '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], + 'tz': time.strftime('%Z', localtime), }) if env is not None: vars.update(env) From 3ea6e58365fcdf1153c77c638163241e8e8c82d3 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Mon, 8 Mar 2010 17:24:00 -0500 Subject: [PATCH 010/243] add standardsubstitute vars 'utc' and 'gmt' which output current time in UTC. --- src/ircutils.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/ircutils.py b/src/ircutils.py index 5d782f14d..de13fbffe 100644 --- a/src/ircutils.py +++ b/src/ircutils.py @@ -644,6 +644,7 @@ def standardSubstitute(irc, msg, text, env=None): return 'someone' ctime = time.strftime("%a %b %d %H:%M:%S %Y") localtime = time.localtime() + gmtime = time.strftime("%a %b %d %H:%M:%S %Y", time.gmtime()) vars = CallableValueIrcDict({ 'who': msg.nick, 'nick': msg.nick, @@ -652,6 +653,7 @@ def standardSubstitute(irc, msg, text, env=None): 'channel': channel, 'botnick': irc.nick, 'now': ctime, 'ctime': ctime, + 'utc': gmtime, 'gmt': gmtime, 'randnick': randNick, 'randomnick': randNick, 'randdate': randDate, 'randomdate': randDate, 'rand': randInt, 'randint': randInt, 'randomint': randInt, From 5b059448385850053bc32310a5ee42ccff7710fd Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Wed, 10 Mar 2010 01:27:00 -0500 Subject: [PATCH 011/243] create stub of messageparser plugin --- plugins/MessageParser/README.txt | 1 + plugins/MessageParser/__init__.py | 66 +++++++++++++++++++++++++ plugins/MessageParser/config.py | 53 ++++++++++++++++++++ plugins/MessageParser/local/__init__.py | 1 + plugins/MessageParser/plugin.py | 64 ++++++++++++++++++++++++ plugins/MessageParser/test.py | 37 ++++++++++++++ 6 files changed, 222 insertions(+) create mode 100644 plugins/MessageParser/README.txt create mode 100644 plugins/MessageParser/__init__.py create mode 100644 plugins/MessageParser/config.py create mode 100644 plugins/MessageParser/local/__init__.py create mode 100644 plugins/MessageParser/plugin.py create mode 100644 plugins/MessageParser/test.py diff --git a/plugins/MessageParser/README.txt b/plugins/MessageParser/README.txt new file mode 100644 index 000000000..d60b47a97 --- /dev/null +++ b/plugins/MessageParser/README.txt @@ -0,0 +1 @@ +Insert a description of your plugin here, with any notes, etc. about using it. diff --git a/plugins/MessageParser/__init__.py b/plugins/MessageParser/__init__.py new file mode 100644 index 000000000..c2d0efb5a --- /dev/null +++ b/plugins/MessageParser/__init__.py @@ -0,0 +1,66 @@ +### +# Copyright (c) 2010, Daniel Folkinshteyn +# 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. + +### + +""" +Add a description of the plugin (to be presented to the user inside the wizard) +here. This should describe *what* the plugin does. +""" + +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__ = "" + +# 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__ = {} + +# This is a url where the most recent plugin package can be downloaded. +__url__ = '' # 'http://supybot.com/Members/yourname/MessageParser/download' + +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=4 expandtab textwidth=79: diff --git a/plugins/MessageParser/config.py b/plugins/MessageParser/config.py new file mode 100644 index 000000000..6003bc121 --- /dev/null +++ b/plugins/MessageParser/config.py @@ -0,0 +1,53 @@ +### +# Copyright (c) 2010, Daniel Folkinshteyn +# 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('MessageParser', True) + + +MessageParser = conf.registerPlugin('MessageParser') +# This is where your configuration variables (if any) should go. For example: +# conf.registerGlobalValue(MessageParser, 'someConfigVariableName', +# registry.Boolean(False, """Help for someConfigVariableName.""")) +conf.registerChannelValue(MessageParser, 'enable', + registry.Boolean(True, """Determines whether the + message parser is enabled. If enabled, will trigger on regexps + added to the regexp db.""")) + + +# vim:set shiftwidth=4 tabstop=4 expandtab textwidth=79: diff --git a/plugins/MessageParser/local/__init__.py b/plugins/MessageParser/local/__init__.py new file mode 100644 index 000000000..e86e97b86 --- /dev/null +++ b/plugins/MessageParser/local/__init__.py @@ -0,0 +1 @@ +# Stub so local is a module, used for third-party modules diff --git a/plugins/MessageParser/plugin.py b/plugins/MessageParser/plugin.py new file mode 100644 index 000000000..bef351690 --- /dev/null +++ b/plugins/MessageParser/plugin.py @@ -0,0 +1,64 @@ +### +# Copyright (c) 2010, Daniel Folkinshteyn +# 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.utils as utils +from supybot.commands import * +import supybot.plugins as plugins +import supybot.ircutils as ircutils +import supybot.callbacks as callbacks + +import re + +## will add db stuff later + +class MessageParser(callbacks.PluginRegexp): + """This plugin can set regexp triggers to activate the bot. + Use 'add' command to add regexp trigger, 'remove' to remove.""" + threaded = True + regexps = ['regexpSnarfer'] + def __init__(self, irc): + self.__parent = super(MessageParser, self) + self.__parent.__init__(irc) + + def regexpSnarfer(self, irc, msg, match): + r".*" + channel = msg.args[0] + msgtext = match.group(0) + if not irc.isChannel(channel): + return + if self.registryValue('enable', channel): + if re.search('some stuff', msgtext): + irc.reply('some stuff detected', prefixNick=False) + regexpSnarfer = urlSnarfer(regexpSnarfer) + +Class = MessageParser + + +# vim:set shiftwidth=4 softtabstop=4 expandtab textwidth=79: diff --git a/plugins/MessageParser/test.py b/plugins/MessageParser/test.py new file mode 100644 index 000000000..3a7a8989f --- /dev/null +++ b/plugins/MessageParser/test.py @@ -0,0 +1,37 @@ +### +# Copyright (c) 2010, Daniel Folkinshteyn +# 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 MessageParserTestCase(PluginTestCase): + plugins = ('MessageParser',) + + +# vim:set shiftwidth=4 tabstop=4 expandtab textwidth=79: From f62be4b4a747fd251c7ed1daee249481630f3f72 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Thu, 11 Mar 2010 01:59:37 -0500 Subject: [PATCH 012/243] implement the messageparser plugin. at the moment, it is constrained only to echo messages, rather than arbitrary commands, for triggers maybe that'll change in the future... --- plugins/MessageParser/config.py | 7 +- plugins/MessageParser/plugin.py | 184 ++++++++++++++++++++++++++++++-- 2 files changed, 179 insertions(+), 12 deletions(-) diff --git a/plugins/MessageParser/config.py b/plugins/MessageParser/config.py index 6003bc121..f0ee0c4ba 100644 --- a/plugins/MessageParser/config.py +++ b/plugins/MessageParser/config.py @@ -48,6 +48,11 @@ conf.registerChannelValue(MessageParser, 'enable', registry.Boolean(True, """Determines whether the message parser is enabled. If enabled, will trigger on regexps added to the regexp db.""")) - +conf.registerChannelValue(MessageParser, 'keepRankInfo', + registry.Boolean(True, """Determines whether we keep updating the usage + count for each regexp, for popularity ranking.""")) +conf.registerChannelValue(MessageParser, 'rankListLength', + registry.Integer(20, """Determines the number of regexps returned + by the triggerrank command.""")) # vim:set shiftwidth=4 tabstop=4 expandtab textwidth=79: diff --git a/plugins/MessageParser/plugin.py b/plugins/MessageParser/plugin.py index bef351690..acf808075 100644 --- a/plugins/MessageParser/plugin.py +++ b/plugins/MessageParser/plugin.py @@ -34,29 +34,191 @@ import supybot.plugins as plugins import supybot.ircutils as ircutils import supybot.callbacks as callbacks +import supybot.conf as conf +import supybot.ircdb as ircdb + import re +import os +import time -## will add db stuff later +try: + import sqlite +except ImportError: + raise callbacks.Error, 'You need to have PySQLite installed to use this ' \ + 'plugin. Download it at ' \ + '' -class MessageParser(callbacks.PluginRegexp): + +class MessageParser(callbacks.Plugin, plugins.ChannelDBHandler): """This plugin can set regexp triggers to activate the bot. Use 'add' command to add regexp trigger, 'remove' to remove.""" threaded = True - regexps = ['regexpSnarfer'] def __init__(self, irc): - self.__parent = super(MessageParser, self) - self.__parent.__init__(irc) + callbacks.Plugin.__init__(self, irc) + plugins.ChannelDBHandler.__init__(self) - def regexpSnarfer(self, irc, msg, match): - r".*" + def makeDb(self, filename): + if os.path.exists(filename): + return sqlite.connect(filename) + db = sqlite.connect(filename) + cursor = db.cursor() + cursor.execute("""CREATE TABLE triggers ( + id INTEGER PRIMARY KEY, + regexp TEXT UNIQUE ON CONFLICT REPLACE, + added_by TEXT, + added_at TIMESTAMP, + usage_count INTEGER, + action TEXT, + locked BOOLEAN + )""") + db.commit() + return db + + def _updateRank(self, channel, regexp): + if self.registryValue('keepRankInfo', channel): + db = self.getDb(channel) + cursor = db.cursor() + cursor.execute("""SELECT usage_count + FROM triggers + WHERE regexp=%s""", regexp) + old_count = cursor.fetchall()[0][0] + cursor.execute("UPDATE triggers SET usage_count=%s WHERE regexp=%s", old_count + 1, regexp) + db.commit() + + def doPrivmsg(self, irc, msg): channel = msg.args[0] - msgtext = match.group(0) if not irc.isChannel(channel): return if self.registryValue('enable', channel): - if re.search('some stuff', msgtext): - irc.reply('some stuff detected', prefixNick=False) - regexpSnarfer = urlSnarfer(regexpSnarfer) + actions = [] + db = self.getDb(channel) + cursor = db.cursor() + cursor.execute("SELECT regexp, action FROM triggers") + if cursor.rowcount == 0: + return + for (regexp, action) in cursor.fetchall(): + match = re.search(regexp, msg.args[1]) + if match is not None: + self._updateRank(channel, regexp) + for (i, j) in enumerate(match.groups()): + action = re.sub(r'\$' + str(i+1), match.group(i+1), action) + actions.append(action) + + if len(actions) > 0: + irc.replies(actions, prefixNick=False) + + #if re.search('some stuff', msgtext): + # irc.reply('some stuff detected', prefixNick=False) + + def add(self, irc, msg, args, channel, regexp, action): + """[] + + Associates with . is only + necessary if the message isn't sent on the channel + itself. Action is echoed upon regexp match, with variables $1, $2, + etc. being interpolated from the regexp match groups.""" + db = self.getDb(channel) + cursor = db.cursor() + cursor.execute("SELECT id, locked FROM triggers WHERE regexp=%s", regexp) + if cursor.rowcount != 0: + (id, locked) = map(int, cursor.fetchone()) + else: + locked = False + #capability = ircdb.makeChannelCapability(channel, 'factoids') + if not locked: + if ircdb.users.hasUser(msg.prefix): + name = ircdb.users.getUser(msg.prefix).name + else: + name = msg.nick + cursor.execute("""INSERT INTO triggers VALUES + (NULL, %s, %s, %s, %s, %s, %s)""", + regexp, name, int(time.time()), 0, action, 0) + db.commit() + irc.replySuccess() + else: + irc.error('That trigger is locked.') + add = wrap(add, ['channel', 'something', 'something']) + + def remove(self, irc, msg, args, channel, regexp): + """[] ] + + Removes the trigger for from the triggers database. + is only necessary if + the message isn't sent in the channel itself. + """ + db = self.getDb(channel) + cursor = db.cursor() + cursor.execute("SELECT id, locked FROM triggers WHERE regexp=%s", regexp) + if cursor.rowcount != 0: + (id, locked) = map(int, cursor.fetchone()) + else: + irc.error('There is no such regexp trigger.') + + if locked: + irc.error('This regexp trigger is locked.') + + cursor.execute("""DELETE FROM triggers WHERE id=%s""", id) + db.commit() + irc.replySuccess() + remove = wrap(remove, ['channel', 'something']) + + def show(self, irc, msg, args, channel, regexp): + """[] + + Looks up the value of in the triggers database. + is only necessary if the message isn't sent in the channel + itself. + """ + db = self.getDb(channel) + cursor = db.cursor() + cursor.execute("SELECT regexp, action FROM triggers WHERE regexp=%s", regexp) + if cursor.rowcount != 0: + (regexp, action) = cursor.fetchone() + else: + irc.error('There is no such regexp trigger.') + + irc.reply("The trigger for regexp '%s' is '%s'" % (regexp, action)) + show = wrap(show, ['channel', 'something']) + + def listall(self, irc, msg, args, channel): + """[] + + Lists regexps present in the triggers database. + is only necessary if the message isn't sent in the channel + itself. + """ + db = self.getDb(channel) + cursor = db.cursor() + cursor.execute("SELECT regexp FROM triggers") + if cursor.rowcount != 0: + regexps = cursor.fetchall() + else: + irc.error('There is no available regexp triggers.') + + s = [ regexp[0] for regexp in regexps ] + irc.reply("'" + "','".join(s) + "'") + listall = wrap(listall, ['channel']) + + def triggerrank(self, irc, msg, args, channel): + """[] + + Returns a list of top-ranked regexps, sorted by usage count + (rank). The number of regexps returned is set by the + rankListLength registry value. is only necessary if the + message isn't sent in the channel itself. + """ + numregexps = self.registryValue('rankListLength', channel) + db = self.getDb(channel) + cursor = db.cursor() + cursor.execute("""SELECT regexp, usage_count + FROM triggers + ORDER BY usage_count DESC + LIMIT %s""", numregexps) + regexps = cursor.fetchall() + s = [ "#%d %s (%d)" % (i+1, regexp[0], regexp[1]) for i, regexp in enumerate(regexps) ] + irc.reply(", ".join(s)) + triggerrank = wrap(triggerrank, ['channel']) + Class = MessageParser From f6a86a81cee9720c30bf569aa8b6d2c175ac4956 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Mon, 15 Mar 2010 17:17:11 -0400 Subject: [PATCH 013/243] populate the readme with useful tutorial! :) --- plugins/MessageParser/README.txt | 46 +++++++++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/plugins/MessageParser/README.txt b/plugins/MessageParser/README.txt index d60b47a97..aabde3eff 100644 --- a/plugins/MessageParser/README.txt +++ b/plugins/MessageParser/README.txt @@ -1 +1,45 @@ -Insert a description of your plugin here, with any notes, etc. about using it. +The MessageParser plugin allows you to set custom regexp triggers, which will trigger the bot to respond if they match anywhere in the message. This is useful for those cases when you want a bot response even when the bot was not explicitly addressed by name or prefix character. + +== Commands == + +The main commands of the plugin are 'add', 'remove', and 'show'. There are also 'listall' and 'triggerrank'. We will discuss them all below. + +=== messageparser add === + +To add a trigger, use the obviously-named "messageparser add" command. It takes two arguments, the regexp (using standard python style regular expressions), and the output message response string. If either of those contain spaces, they must be quoted. + +Here is a basic example command: +messageparser add "some stuff" "I saw some stuff!" +Once that is added, any message that contains the string "some stuff" will cause the bot to respond with "I saw some stuff!". + +The response string can contain placeholders for regexp match groups. These will be interpolated into the string. Here's an example: +messageparser add "my name is (\w+)" "hello, $1!" +If you then send a message "hi, my name is bla", the bot will respond with "hello, bla!". + +The regexp triggers are set to be unique - if you add the same regexp on top of an existing one, its response string will be overwritten. + +If more than one regexp trigger matches, their responses will be concatenated into one response message, separated by " and ". + +=== messageparser remove === + +You can remove a trigger using the remove command, by specifying the verbatim regexp you want to remove the trigger for. Here's a simple example: +messageparser remove "some stuff" +This would remove the trigger for "some stuff" that we have set in the section above. + +=== messageparser show === + +You can show the contents of the response string for a particular trigger by using the show command, and specifying the verbatim regexp you want to display. Here's an example: +messageparser show "my name is (\w+)" +Will display the trigger with its associated response string. + +=== messageparser listall === + +The listall command will list all the regexps which are currently in the database. It takes no agruments. + +=== messageparser triggerrank === + +The plugin by default keeps statistics on how many times each regexp was triggered. Using the triggerrank command you can see the regexps sorted in descending order of number of trigger times. The number in parentheses after each regexp is the count of trigger occurrences for each. + +== Configuration == + +Supybot configuration is self-documenting. Run 'config list plugins.messageparser' for list of config keys, and 'config help ' for help on each one. \ No newline at end of file From 0c87c523d2314399192081bf66133fb39ce6adf7 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Mon, 15 Mar 2010 17:32:02 -0400 Subject: [PATCH 014/243] use irc.reply instead of irc.error for conditions that are expected to normally occur on occasion, also, add returns after error conditions (i assumed earlier that irc.error returned) - this fixes some bugs. --- plugins/MessageParser/plugin.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/plugins/MessageParser/plugin.py b/plugins/MessageParser/plugin.py index acf808075..a7aabaabb 100644 --- a/plugins/MessageParser/plugin.py +++ b/plugins/MessageParser/plugin.py @@ -106,9 +106,6 @@ class MessageParser(callbacks.Plugin, plugins.ChannelDBHandler): if len(actions) > 0: irc.replies(actions, prefixNick=False) - - #if re.search('some stuff', msgtext): - # irc.reply('some stuff detected', prefixNick=False) def add(self, irc, msg, args, channel, regexp, action): """[] @@ -136,7 +133,8 @@ class MessageParser(callbacks.Plugin, plugins.ChannelDBHandler): db.commit() irc.replySuccess() else: - irc.error('That trigger is locked.') + irc.reply('That trigger is locked.') + return add = wrap(add, ['channel', 'something', 'something']) def remove(self, irc, msg, args, channel, regexp): @@ -152,10 +150,12 @@ class MessageParser(callbacks.Plugin, plugins.ChannelDBHandler): if cursor.rowcount != 0: (id, locked) = map(int, cursor.fetchone()) else: - irc.error('There is no such regexp trigger.') + irc.reply('There is no such regexp trigger.') + return if locked: - irc.error('This regexp trigger is locked.') + irc.reply('This regexp trigger is locked.') + return cursor.execute("""DELETE FROM triggers WHERE id=%s""", id) db.commit() @@ -175,7 +175,8 @@ class MessageParser(callbacks.Plugin, plugins.ChannelDBHandler): if cursor.rowcount != 0: (regexp, action) = cursor.fetchone() else: - irc.error('There is no such regexp trigger.') + irc.reply('There is no such regexp trigger.') + return irc.reply("The trigger for regexp '%s' is '%s'" % (regexp, action)) show = wrap(show, ['channel', 'something']) @@ -193,7 +194,8 @@ class MessageParser(callbacks.Plugin, plugins.ChannelDBHandler): if cursor.rowcount != 0: regexps = cursor.fetchall() else: - irc.error('There is no available regexp triggers.') + irc.reply('There are no regexp triggers in the database.') + return s = [ regexp[0] for regexp in regexps ] irc.reply("'" + "','".join(s) + "'") From 3326212d55da8465809c774105ff6ecb54d2e788 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Mon, 15 Mar 2010 17:40:57 -0400 Subject: [PATCH 015/243] fix typo (write-o, really) in string for show command --- plugins/MessageParser/plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/MessageParser/plugin.py b/plugins/MessageParser/plugin.py index a7aabaabb..639c57d2d 100644 --- a/plugins/MessageParser/plugin.py +++ b/plugins/MessageParser/plugin.py @@ -178,7 +178,7 @@ class MessageParser(callbacks.Plugin, plugins.ChannelDBHandler): irc.reply('There is no such regexp trigger.') return - irc.reply("The trigger for regexp '%s' is '%s'" % (regexp, action)) + irc.reply("The action for regexp trigger '%s' is '%s'" % (regexp, action)) show = wrap(show, ['channel', 'something']) def listall(self, irc, msg, args, channel): From f8ddba0d155545b3dcc9772b38fb705529f973bf Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Mon, 15 Mar 2010 20:06:24 -0400 Subject: [PATCH 016/243] get messageparser to use sqlite3. should work now.... --- plugins/MessageParser/plugin.py | 79 ++++++++++++++++++++++----------- 1 file changed, 52 insertions(+), 27 deletions(-) diff --git a/plugins/MessageParser/plugin.py b/plugins/MessageParser/plugin.py index 639c57d2d..36b2d3141 100644 --- a/plugins/MessageParser/plugin.py +++ b/plugins/MessageParser/plugin.py @@ -41,13 +41,18 @@ import re import os import time -try: - import sqlite -except ImportError: - raise callbacks.Error, 'You need to have PySQLite installed to use this ' \ - 'plugin. Download it at ' \ - '' +#try: + #import sqlite +#except ImportError: + #raise callbacks.Error, 'You need to have PySQLite installed to use this ' \ + #'plugin. Download it at ' \ + #'' +import sqlite3 + +# these are needed cuz we are overriding getdb +import threading +import supybot.world as world class MessageParser(callbacks.Plugin, plugins.ChannelDBHandler): """This plugin can set regexp triggers to activate the bot. @@ -58,9 +63,10 @@ class MessageParser(callbacks.Plugin, plugins.ChannelDBHandler): plugins.ChannelDBHandler.__init__(self) def makeDb(self, filename): + """Create the database and connect to it.""" if os.path.exists(filename): - return sqlite.connect(filename) - db = sqlite.connect(filename) + return sqlite3.connect(filename) + db = sqlite3.connect(filename) cursor = db.cursor() cursor.execute("""CREATE TABLE triggers ( id INTEGER PRIMARY KEY, @@ -74,15 +80,29 @@ class MessageParser(callbacks.Plugin, plugins.ChannelDBHandler): db.commit() return db + # override this because sqlite3 doesn't have autocommit + # use isolation_level instead. + def getDb(self, channel): + """Use this to get a database for a specific channel.""" + currentThread = threading.currentThread() + if channel not in self.dbCache and currentThread == world.mainThread: + self.dbCache[channel] = self.makeDb(self.makeFilename(channel)) + if currentThread != world.mainThread: + db = self.makeDb(self.makeFilename(channel)) + else: + db = self.dbCache[channel] + db.isolation_level = None + return db + def _updateRank(self, channel, regexp): if self.registryValue('keepRankInfo', channel): db = self.getDb(channel) cursor = db.cursor() cursor.execute("""SELECT usage_count FROM triggers - WHERE regexp=%s""", regexp) + WHERE regexp=?""", (regexp,)) old_count = cursor.fetchall()[0][0] - cursor.execute("UPDATE triggers SET usage_count=%s WHERE regexp=%s", old_count + 1, regexp) + cursor.execute("UPDATE triggers SET usage_count=? WHERE regexp=?", (old_count + 1, regexp,)) db.commit() def doPrivmsg(self, irc, msg): @@ -94,9 +114,10 @@ class MessageParser(callbacks.Plugin, plugins.ChannelDBHandler): db = self.getDb(channel) cursor = db.cursor() cursor.execute("SELECT regexp, action FROM triggers") - if cursor.rowcount == 0: + results = cursor.fetchall() + if len(results) == 0: return - for (regexp, action) in cursor.fetchall(): + for (regexp, action) in results: match = re.search(regexp, msg.args[1]) if match is not None: self._updateRank(channel, regexp) @@ -116,9 +137,10 @@ class MessageParser(callbacks.Plugin, plugins.ChannelDBHandler): etc. being interpolated from the regexp match groups.""" db = self.getDb(channel) cursor = db.cursor() - cursor.execute("SELECT id, locked FROM triggers WHERE regexp=%s", regexp) - if cursor.rowcount != 0: - (id, locked) = map(int, cursor.fetchone()) + cursor.execute("SELECT id, locked FROM triggers WHERE regexp=?", (regexp,)) + results = cursor.fetchall() + if len(results) != 0: + (id, locked) = map(int, results[0]) else: locked = False #capability = ircdb.makeChannelCapability(channel, 'factoids') @@ -128,8 +150,8 @@ class MessageParser(callbacks.Plugin, plugins.ChannelDBHandler): else: name = msg.nick cursor.execute("""INSERT INTO triggers VALUES - (NULL, %s, %s, %s, %s, %s, %s)""", - regexp, name, int(time.time()), 0, action, 0) + (NULL, ?, ?, ?, ?, ?, ?)""", + (regexp, name, int(time.time()), 0, action, 0,)) db.commit() irc.replySuccess() else: @@ -146,9 +168,10 @@ class MessageParser(callbacks.Plugin, plugins.ChannelDBHandler): """ db = self.getDb(channel) cursor = db.cursor() - cursor.execute("SELECT id, locked FROM triggers WHERE regexp=%s", regexp) - if cursor.rowcount != 0: - (id, locked) = map(int, cursor.fetchone()) + cursor.execute("SELECT id, locked FROM triggers WHERE regexp=?", (regexp,)) + results = cursor.fetchall() + if len(results) != 0: + (id, locked) = map(int, results[0]) else: irc.reply('There is no such regexp trigger.') return @@ -157,7 +180,7 @@ class MessageParser(callbacks.Plugin, plugins.ChannelDBHandler): irc.reply('This regexp trigger is locked.') return - cursor.execute("""DELETE FROM triggers WHERE id=%s""", id) + cursor.execute("""DELETE FROM triggers WHERE id=?""", (id,)) db.commit() irc.replySuccess() remove = wrap(remove, ['channel', 'something']) @@ -171,9 +194,10 @@ class MessageParser(callbacks.Plugin, plugins.ChannelDBHandler): """ db = self.getDb(channel) cursor = db.cursor() - cursor.execute("SELECT regexp, action FROM triggers WHERE regexp=%s", regexp) - if cursor.rowcount != 0: - (regexp, action) = cursor.fetchone() + cursor.execute("SELECT regexp, action FROM triggers WHERE regexp=?", (regexp,)) + results = cursor.fetchall() + if len(results) != 0: + (regexp, action) = results[0] else: irc.reply('There is no such regexp trigger.') return @@ -191,8 +215,9 @@ class MessageParser(callbacks.Plugin, plugins.ChannelDBHandler): db = self.getDb(channel) cursor = db.cursor() cursor.execute("SELECT regexp FROM triggers") - if cursor.rowcount != 0: - regexps = cursor.fetchall() + results = cursor.fetchall() + if len(results) != 0: + regexps = results else: irc.reply('There are no regexp triggers in the database.') return @@ -215,7 +240,7 @@ class MessageParser(callbacks.Plugin, plugins.ChannelDBHandler): cursor.execute("""SELECT regexp, usage_count FROM triggers ORDER BY usage_count DESC - LIMIT %s""", numregexps) + LIMIT ?""", (numregexps,)) regexps = cursor.fetchall() s = [ "#%d %s (%d)" % (i+1, regexp[0], regexp[1]) for i, regexp in enumerate(regexps) ] irc.reply(", ".join(s)) From 0e5024925691e6bc6848328e08451ec4d5eac704 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Mon, 15 Mar 2010 23:12:35 -0400 Subject: [PATCH 017/243] start factoid rankings from 1 not from 0 --- plugins/Factoids/plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/Factoids/plugin.py b/plugins/Factoids/plugin.py index 787097493..befe54cc3 100644 --- a/plugins/Factoids/plugin.py +++ b/plugins/Factoids/plugin.py @@ -255,7 +255,7 @@ class Factoids(callbacks.Plugin, plugins.ChannelDBHandler): ORDER BY factoids.usage_count DESC LIMIT %s""", numfacts) factkeys = cursor.fetchall() - s = [ "#%d %s (%d)" % (i, key[0], key[1]) for i, key in enumerate(factkeys) ] + s = [ "#%d %s (%d)" % (i+1, key[0], key[1]) for i, key in enumerate(factkeys) ] irc.reply(", ".join(s)) factrank = wrap(factrank, ['channel']) From d72649c5c592d94a8110602a7031630a0999e834 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Tue, 16 Mar 2010 16:49:55 -0400 Subject: [PATCH 018/243] give messageparser ability to use arbitrary commands as trigger responses. --- plugins/MessageParser/plugin.py | 20 +++++++++++++++++--- plugins/Scheduler/plugin.py | 2 ++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/plugins/MessageParser/plugin.py b/plugins/MessageParser/plugin.py index 36b2d3141..f28e35b20 100644 --- a/plugins/MessageParser/plugin.py +++ b/plugins/MessageParser/plugin.py @@ -54,6 +54,10 @@ import sqlite3 import threading import supybot.world as world + +import supybot.log as log + + class MessageParser(callbacks.Plugin, plugins.ChannelDBHandler): """This plugin can set regexp triggers to activate the bot. Use 'add' command to add regexp trigger, 'remove' to remove.""" @@ -105,6 +109,15 @@ class MessageParser(callbacks.Plugin, plugins.ChannelDBHandler): cursor.execute("UPDATE triggers SET usage_count=? WHERE regexp=?", (old_count + 1, regexp,)) db.commit() + def _runCommandFunction(self, irc, msg, command): + """Run a command from message, as if command was sent over IRC.""" + # need to encode it from unicode, since sqlite stores text as unicode. + tokens = callbacks.tokenize(unicode.encode(command, 'utf8')) + try: + self.Proxy(irc.irc, msg, tokens) + except Exception, e: + log.exception('Uncaught exception in scheduled function:') + def doPrivmsg(self, irc, msg): channel = msg.args[0] if not irc.isChannel(channel): @@ -125,8 +138,10 @@ class MessageParser(callbacks.Plugin, plugins.ChannelDBHandler): action = re.sub(r'\$' + str(i+1), match.group(i+1), action) actions.append(action) - if len(actions) > 0: - irc.replies(actions, prefixNick=False) + #if len(actions) > 0: + # irc.replies(actions, prefixNick=False) + for action in actions: + self._runCommandFunction(irc, msg, action) def add(self, irc, msg, args, channel, regexp, action): """[] @@ -143,7 +158,6 @@ class MessageParser(callbacks.Plugin, plugins.ChannelDBHandler): (id, locked) = map(int, results[0]) else: locked = False - #capability = ircdb.makeChannelCapability(channel, 'factoids') if not locked: if ircdb.users.hasUser(msg.prefix): name = ircdb.users.getUser(msg.prefix).name diff --git a/plugins/Scheduler/plugin.py b/plugins/Scheduler/plugin.py index 5ad55dc7b..1f02c6eff 100644 --- a/plugins/Scheduler/plugin.py +++ b/plugins/Scheduler/plugin.py @@ -44,6 +44,8 @@ class Scheduler(callbacks.Plugin): def _makeCommandFunction(self, irc, msg, command, remove=True): """Makes a function suitable for scheduling from command.""" tokens = callbacks.tokenize(command) + print command + print tokens def f(): if remove: del self.events[str(f.eventId)] From dbbef9ec4313a5a81dc06bdc183c07688a27b743 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Tue, 16 Mar 2010 16:51:30 -0400 Subject: [PATCH 019/243] use double quotes in listall. --- plugins/MessageParser/plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/MessageParser/plugin.py b/plugins/MessageParser/plugin.py index f28e35b20..d6b529664 100644 --- a/plugins/MessageParser/plugin.py +++ b/plugins/MessageParser/plugin.py @@ -237,7 +237,7 @@ class MessageParser(callbacks.Plugin, plugins.ChannelDBHandler): return s = [ regexp[0] for regexp in regexps ] - irc.reply("'" + "','".join(s) + "'") + irc.reply('"' + '","'.join(s) + '"') listall = wrap(listall, ['channel']) def triggerrank(self, irc, msg, args, channel): From 95aa56c6948adea20f24906d317d7d4166ab47ba Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Wed, 17 Mar 2010 01:55:23 -0400 Subject: [PATCH 020/243] fix sqlite3 import for python 2.4 --- plugins/MessageParser/plugin.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/plugins/MessageParser/plugin.py b/plugins/MessageParser/plugin.py index d6b529664..269d98b05 100644 --- a/plugins/MessageParser/plugin.py +++ b/plugins/MessageParser/plugin.py @@ -48,7 +48,10 @@ import time #'plugin. Download it at ' \ #'' -import sqlite3 +try: + import sqlite3 +except: + from pysqlite2 import dbapi2 as sqlite3 # for python2.4 # these are needed cuz we are overriding getdb import threading From 6ceeace44deb229661efcc3302814e8db4fd5c33 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Wed, 17 Mar 2010 12:37:34 -0400 Subject: [PATCH 021/243] process multiple matches of a regexp per message --- plugins/MessageParser/plugin.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/plugins/MessageParser/plugin.py b/plugins/MessageParser/plugin.py index 269d98b05..73269fde9 100644 --- a/plugins/MessageParser/plugin.py +++ b/plugins/MessageParser/plugin.py @@ -134,15 +134,14 @@ class MessageParser(callbacks.Plugin, plugins.ChannelDBHandler): if len(results) == 0: return for (regexp, action) in results: - match = re.search(regexp, msg.args[1]) - if match is not None: - self._updateRank(channel, regexp) - for (i, j) in enumerate(match.groups()): - action = re.sub(r'\$' + str(i+1), match.group(i+1), action) - actions.append(action) + for match in re.finditer(regexp, msg.args[1]): + if match is not None: + thisaction = action + self._updateRank(channel, regexp) + for (i, j) in enumerate(match.groups()): + thisaction = re.sub(r'\$' + str(i+1), match.group(i+1), thisaction) + actions.append(thisaction) - #if len(actions) > 0: - # irc.replies(actions, prefixNick=False) for action in actions: self._runCommandFunction(irc, msg, action) From 629ede010affdb81f4b9ed460a2670fa2b7d33cb Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Wed, 17 Mar 2010 13:19:07 -0400 Subject: [PATCH 022/243] forget about unicode, and just use text_factory str for sqlite to retrieve raw bytes out of text fields without conversions. --- plugins/MessageParser/plugin.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/plugins/MessageParser/plugin.py b/plugins/MessageParser/plugin.py index 73269fde9..8c2c06472 100644 --- a/plugins/MessageParser/plugin.py +++ b/plugins/MessageParser/plugin.py @@ -72,8 +72,11 @@ class MessageParser(callbacks.Plugin, plugins.ChannelDBHandler): def makeDb(self, filename): """Create the database and connect to it.""" if os.path.exists(filename): - return sqlite3.connect(filename) + db = sqlite3.connect(filename) + db.text_factory = str + return db db = sqlite3.connect(filename) + db.text_factory = str cursor = db.cursor() cursor.execute("""CREATE TABLE triggers ( id INTEGER PRIMARY KEY, @@ -115,7 +118,7 @@ class MessageParser(callbacks.Plugin, plugins.ChannelDBHandler): def _runCommandFunction(self, irc, msg, command): """Run a command from message, as if command was sent over IRC.""" # need to encode it from unicode, since sqlite stores text as unicode. - tokens = callbacks.tokenize(unicode.encode(command, 'utf8')) + tokens = callbacks.tokenize(command) try: self.Proxy(irc.irc, msg, tokens) except Exception, e: From 76d25a193b04ca6fddc14834d4e712972c55c54c Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Wed, 17 Mar 2010 23:54:28 -0400 Subject: [PATCH 023/243] ignore messages addressed to bot directly, in the messageparser. --- plugins/MessageParser/plugin.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plugins/MessageParser/plugin.py b/plugins/MessageParser/plugin.py index 8c2c06472..6bf62c69c 100644 --- a/plugins/MessageParser/plugin.py +++ b/plugins/MessageParser/plugin.py @@ -129,6 +129,8 @@ class MessageParser(callbacks.Plugin, plugins.ChannelDBHandler): if not irc.isChannel(channel): return if self.registryValue('enable', channel): + if callbacks.addressed(irc.nick, msg): #message is direct command + return actions = [] db = self.getDb(channel) cursor = db.cursor() From 910ba732d2d85303c2c9f1ae5a8544c8cf8a129f Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Fri, 19 Mar 2010 00:06:37 -0400 Subject: [PATCH 024/243] update readme.txt for ability to use commands as trigger responses. --- plugins/MessageParser/README.txt | 29 +++++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/plugins/MessageParser/README.txt b/plugins/MessageParser/README.txt index aabde3eff..5cd9e7700 100644 --- a/plugins/MessageParser/README.txt +++ b/plugins/MessageParser/README.txt @@ -6,25 +6,40 @@ The main commands of the plugin are 'add', 'remove', and 'show'. There are also === messageparser add === -To add a trigger, use the obviously-named "messageparser add" command. It takes two arguments, the regexp (using standard python style regular expressions), and the output message response string. If either of those contain spaces, they must be quoted. +To add a trigger, use the obviously-named "messageparser add" command. It takes two arguments, the regexp (using standard python style regular expressions), and the command that is executed when it matches. If either of those contain spaces, they must be quoted. If they contain quotes, those quotes must be escaped. Here is a basic example command: -messageparser add "some stuff" "I saw some stuff!" +messageparser add "some stuff" "echo I saw some stuff!" Once that is added, any message that contains the string "some stuff" will cause the bot to respond with "I saw some stuff!". The response string can contain placeholders for regexp match groups. These will be interpolated into the string. Here's an example: -messageparser add "my name is (\w+)" "hello, $1!" +messageparser add "my name is (\w+)" "echo hello, $1!" If you then send a message "hi, my name is bla", the bot will respond with "hello, bla!". The regexp triggers are set to be unique - if you add the same regexp on top of an existing one, its response string will be overwritten. -If more than one regexp trigger matches, their responses will be concatenated into one response message, separated by " and ". +If more than one regexp trigger matches, each one will cause its respective response. If one regexp matches multiple times in a message, it will cause multiple responses. + +You can use arbitrary supybot commands as the action - be creative, and don't limit yourself to 'echo'. A couple of my favorites are: +messageparser add ",,(\w+)" "$1" +This one causes the bot to take one-word commands from in-message, if they're preceded by double-comma. So you could send a message like "Show me your ,,version and your ,,uptime", and you'd get two responses back, one with version, one with uptime. + +messageparser add ",,\(([^\)]*?)\)" "$1" +This one causes the bot to take multi-word commands from in-message, if they're preceded by double-comma and open-parenthesis, and closed with close-parenthesis. So you could send a message like "I'd like a ,,(factoids search *) please", and you'd the output of command 'factoids search *'. + +Your imagination is the limit! + +The trigger database is deliberately set to only allow unique regexps as triggers, to avoid accidental spam from multiple instances of the same regexp. If, however, you really want multiple responses to happen to one trigger, you can always tweak your regexp with some non-matching groups. My favorites for this are '(?i)', which causes regexp to be non-case-sensitive, but doesn't consume any characters, and '(?m)', which causes the regexp to be multiline, but also doesn't consume any characters. (See python documentation on the re module here: http://docs.python.org/library/re.html) + +So, for example, if you want to set multiple triggers on someone saying "stuff", you could add triggers for "stuff", "(?m)stuff", "(?m)(?m)stuff", "(?m)(?m)(?m)stuff", etc. If you want it to be case-sensitive, you can use (?i) to the same effect. + +But generally it's a good idea to avoid spamminess. :) === messageparser remove === You can remove a trigger using the remove command, by specifying the verbatim regexp you want to remove the trigger for. Here's a simple example: messageparser remove "some stuff" -This would remove the trigger for "some stuff" that we have set in the section above. +This would remove the trigger for "some stuff" if you have set one. === messageparser show === @@ -34,12 +49,14 @@ Will display the trigger with its associated response string. === messageparser listall === -The listall command will list all the regexps which are currently in the database. It takes no agruments. +The listall command will list all the regexps which are currently in the database. It takes no agruments. If you send this out of channel, specify channel name as argument. === messageparser triggerrank === The plugin by default keeps statistics on how many times each regexp was triggered. Using the triggerrank command you can see the regexps sorted in descending order of number of trigger times. The number in parentheses after each regexp is the count of trigger occurrences for each. +Note if you delete, or overwrite an existing, regexp, its count will be reset to 0. + == Configuration == Supybot configuration is self-documenting. Run 'config list plugins.messageparser' for list of config keys, and 'config help ' for help on each one. \ No newline at end of file From adb53a0a35634ce1e2c2ea4ef6a06f86ffab9fe3 Mon Sep 17 00:00:00 2001 From: Daniel F Date: Fri, 19 Mar 2010 09:55:43 -0400 Subject: [PATCH 025/243] preserve usage count upon overwriting an existing regexp entry. --- plugins/MessageParser/plugin.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/plugins/MessageParser/plugin.py b/plugins/MessageParser/plugin.py index 6bf62c69c..2cae23f87 100644 --- a/plugins/MessageParser/plugin.py +++ b/plugins/MessageParser/plugin.py @@ -159,12 +159,13 @@ class MessageParser(callbacks.Plugin, plugins.ChannelDBHandler): etc. being interpolated from the regexp match groups.""" db = self.getDb(channel) cursor = db.cursor() - cursor.execute("SELECT id, locked FROM triggers WHERE regexp=?", (regexp,)) + cursor.execute("SELECT id, usage_count, locked FROM triggers WHERE regexp=?", (regexp,)) results = cursor.fetchall() if len(results) != 0: - (id, locked) = map(int, results[0]) + (id, usage_count, locked) = map(int, results[0]) else: - locked = False + locked = 0 + usage_count = 0 if not locked: if ircdb.users.hasUser(msg.prefix): name = ircdb.users.getUser(msg.prefix).name @@ -172,7 +173,7 @@ class MessageParser(callbacks.Plugin, plugins.ChannelDBHandler): name = msg.nick cursor.execute("""INSERT INTO triggers VALUES (NULL, ?, ?, ?, ?, ?, ?)""", - (regexp, name, int(time.time()), 0, action, 0,)) + (regexp, name, int(time.time()), usage_count, action, locked,)) db.commit() irc.replySuccess() else: From f9cc5d566396207ed98a5310cd25aa485d449d79 Mon Sep 17 00:00:00 2001 From: Daniel F Date: Fri, 19 Mar 2010 10:44:23 -0400 Subject: [PATCH 026/243] add lock and unlock command methods --- plugins/MessageParser/plugin.py | 38 +++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/plugins/MessageParser/plugin.py b/plugins/MessageParser/plugin.py index 2cae23f87..b057e39f7 100644 --- a/plugins/MessageParser/plugin.py +++ b/plugins/MessageParser/plugin.py @@ -207,6 +207,44 @@ class MessageParser(callbacks.Plugin, plugins.ChannelDBHandler): irc.replySuccess() remove = wrap(remove, ['channel', 'something']) + def lock(self, irc, msg, args, channel, regexp): + """[] + + Locks the so that it cannot be + removed or overwritten to. is only necessary if the message isn't + sent in the channel itself. + """ + db = self.getDb(channel) + cursor = db.cursor() + cursor.execute("SELECT id FROM triggers WHERE regexp=?", (regexp,)) + results = cursor.fetchall() + if len(results) == 0: + irc.reply('There is no such regexp trigger.') + return + cursor.execute("UPDATE triggers SET locked=1 WHERE regexp=?", (regexp,)) + db.commit() + irc.replySuccess() + lock = wrap(lock, ['channel', 'text']) + + def unlock(self, irc, msg, args, channel, regexp): + """[] + + Unlocks the entry associated with so that it can be + removed or overwritten. is only necessary if the message isn't + sent in the channel itself. + """ + db = self.getDb(channel) + cursor = db.cursor() + cursor.execute("SELECT id FROM triggers WHERE regexp=?", (regexp,)) + results = cursor.fetchall() + if len(results) == 0: + irc.reply('There is no such regexp trigger.') + return + cursor.execute("UPDATE triggers SET locked=0 WHERE regexp=?", (regexp,)) + db.commit() + irc.replySuccess() + unlock = wrap(unlock, ['channel', 'text']) + def show(self, irc, msg, args, channel, regexp): """[] From e2d16cb3a777fcd2ecfeea0c6808d4805b235f31 Mon Sep 17 00:00:00 2001 From: Daniel F Date: Fri, 19 Mar 2010 13:20:50 -0400 Subject: [PATCH 027/243] test regexp for validity before adding it. --- plugins/MessageParser/plugin.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/plugins/MessageParser/plugin.py b/plugins/MessageParser/plugin.py index b057e39f7..835c66d79 100644 --- a/plugins/MessageParser/plugin.py +++ b/plugins/MessageParser/plugin.py @@ -167,6 +167,11 @@ class MessageParser(callbacks.Plugin, plugins.ChannelDBHandler): locked = 0 usage_count = 0 if not locked: + try: + re.compile(regexp) + except Exception, e: + irc.error('Invalid python regexp: %s' % (e,)) + return if ircdb.users.hasUser(msg.prefix): name = ircdb.users.getUser(msg.prefix).name else: From 92389f69ef3a5a350ea730a8cffab4f0fec9fb66 Mon Sep 17 00:00:00 2001 From: Daniel F Date: Fri, 19 Mar 2010 13:24:45 -0400 Subject: [PATCH 028/243] list regexp id in listall (to be used for showing/removing regexp by id) --- plugins/MessageParser/plugin.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/MessageParser/plugin.py b/plugins/MessageParser/plugin.py index 835c66d79..9232c823d 100644 --- a/plugins/MessageParser/plugin.py +++ b/plugins/MessageParser/plugin.py @@ -275,11 +275,11 @@ class MessageParser(callbacks.Plugin, plugins.ChannelDBHandler): Lists regexps present in the triggers database. is only necessary if the message isn't sent in the channel - itself. + itself. Regexp ID listed in paretheses. """ db = self.getDb(channel) cursor = db.cursor() - cursor.execute("SELECT regexp FROM triggers") + cursor.execute("SELECT regexp, id FROM triggers") results = cursor.fetchall() if len(results) != 0: regexps = results @@ -287,7 +287,7 @@ class MessageParser(callbacks.Plugin, plugins.ChannelDBHandler): irc.reply('There are no regexp triggers in the database.') return - s = [ regexp[0] for regexp in regexps ] + s = [ "%s (%d)" % (regexp[0], regexp[1]) for regexp in regexps ] irc.reply('"' + '","'.join(s) + '"') listall = wrap(listall, ['channel']) From 790901528826388735565cd093642145a82b2cba Mon Sep 17 00:00:00 2001 From: Daniel F Date: Fri, 19 Mar 2010 13:34:50 -0400 Subject: [PATCH 029/243] allow show by id with option --id --- plugins/MessageParser/plugin.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/plugins/MessageParser/plugin.py b/plugins/MessageParser/plugin.py index 9232c823d..a44497db4 100644 --- a/plugins/MessageParser/plugin.py +++ b/plugins/MessageParser/plugin.py @@ -250,16 +250,22 @@ class MessageParser(callbacks.Plugin, plugins.ChannelDBHandler): irc.replySuccess() unlock = wrap(unlock, ['channel', 'text']) - def show(self, irc, msg, args, channel, regexp): - """[] + def show(self, irc, msg, args, channel, optlist, regexp): + """[] [--id] Looks up the value of in the triggers database. is only necessary if the message isn't sent in the channel itself. + If option --id specified, will retrieve by regexp id, not content. """ db = self.getDb(channel) cursor = db.cursor() - cursor.execute("SELECT regexp, action FROM triggers WHERE regexp=?", (regexp,)) + target = 'regexp' + for (option, arg) in optlist: + if option == 'id': + target = 'id' + sql = "SELECT regexp, action FROM triggers WHERE %s=?" % (target,) + cursor.execute(sql, (regexp,)) results = cursor.fetchall() if len(results) != 0: (regexp, action) = results[0] @@ -267,8 +273,10 @@ class MessageParser(callbacks.Plugin, plugins.ChannelDBHandler): irc.reply('There is no such regexp trigger.') return - irc.reply("The action for regexp trigger '%s' is '%s'" % (regexp, action)) - show = wrap(show, ['channel', 'something']) + irc.reply("The action for regexp trigger \"%s\" is \"%s\"" % (regexp, action)) + show = wrap(show, ['channel', + getopts({'id': '',}), + 'something']) def listall(self, irc, msg, args, channel): """[] From 4972472764812e8b1c0b82fc69c723df7d29f5e3 Mon Sep 17 00:00:00 2001 From: Daniel F Date: Fri, 19 Mar 2010 13:40:36 -0400 Subject: [PATCH 030/243] allow remove by id, if --id is specified. --- plugins/MessageParser/plugin.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/plugins/MessageParser/plugin.py b/plugins/MessageParser/plugin.py index a44497db4..ca66db936 100644 --- a/plugins/MessageParser/plugin.py +++ b/plugins/MessageParser/plugin.py @@ -186,16 +186,22 @@ class MessageParser(callbacks.Plugin, plugins.ChannelDBHandler): return add = wrap(add, ['channel', 'something', 'something']) - def remove(self, irc, msg, args, channel, regexp): - """[] ] + def remove(self, irc, msg, args, channel, optlist, regexp): + """[] [--id] ] Removes the trigger for from the triggers database. is only necessary if the message isn't sent in the channel itself. + If option --id specified, will retrieve by regexp id, not content. """ db = self.getDb(channel) cursor = db.cursor() - cursor.execute("SELECT id, locked FROM triggers WHERE regexp=?", (regexp,)) + target = 'regexp' + for (option, arg) in optlist: + if option == 'id': + target = 'id' + sql = "SELECT id, locked FROM triggers WHERE %s=?" % (target,) + cursor.execute(sql, (regexp,)) results = cursor.fetchall() if len(results) != 0: (id, locked) = map(int, results[0]) @@ -210,7 +216,9 @@ class MessageParser(callbacks.Plugin, plugins.ChannelDBHandler): cursor.execute("""DELETE FROM triggers WHERE id=?""", (id,)) db.commit() irc.replySuccess() - remove = wrap(remove, ['channel', 'something']) + remove = wrap(remove, ['channel', + getopts({'id': '',}), + 'something']) def lock(self, irc, msg, args, channel, regexp): """[] From 65ed84a45a1635ebe07d31dcfda4777efb95156d Mon Sep 17 00:00:00 2001 From: nanotube Date: Fri, 19 Mar 2010 15:34:35 -0400 Subject: [PATCH 031/243] create info command for messageparser --- plugins/MessageParser/plugin.py | 35 +++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/plugins/MessageParser/plugin.py b/plugins/MessageParser/plugin.py index ca66db936..d3e658fc3 100644 --- a/plugins/MessageParser/plugin.py +++ b/plugins/MessageParser/plugin.py @@ -286,6 +286,41 @@ class MessageParser(callbacks.Plugin, plugins.ChannelDBHandler): getopts({'id': '',}), 'something']) + def info(self, irc, msg, args, channel, optlist, regexp): + """[] [--id] + + Display information about in the triggers database. + is only necessary if the message isn't sent in the channel + itself. + If option --id specified, will retrieve by regexp id, not content. + """ + db = self.getDb(channel) + cursor = db.cursor() + target = 'regexp' + for (option, arg) in optlist: + if option == 'id': + target = 'id' + sql = "SELECT * FROM triggers WHERE %s=?" % (target,) + cursor.execute(sql, (regexp,)) + results = cursor.fetchall() + if len(results) != 0: + (id, regexp, added_by, added_at, usage_count, action, locked) = results[0] + else: + irc.reply('There is no such regexp trigger.') + return + + irc.reply("The regexp trigger id is %d, regexp is \"%s\", and action is \"%s\". It was added by user %s on %s, has been triggered %d times, and is %s." % (id, + regexp, + action, + added_by, + time.strftime(conf.supybot.reply.format.time(), + time.localtime(int(added_at))), + usage_count, + locked and "locked" or "not locked",)) + info = wrap(info, ['channel', + getopts({'id': '',}), + 'something']) + def listall(self, irc, msg, args, channel): """[] From 770d407d1c0378efa1c24b352ef2196638ff67cf Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Fri, 19 Mar 2010 15:54:54 -0400 Subject: [PATCH 032/243] in listall, put id in parentheses /outside/ the quotes. --- plugins/MessageParser/plugin.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/MessageParser/plugin.py b/plugins/MessageParser/plugin.py index d3e658fc3..3347245cf 100644 --- a/plugins/MessageParser/plugin.py +++ b/plugins/MessageParser/plugin.py @@ -338,8 +338,8 @@ class MessageParser(callbacks.Plugin, plugins.ChannelDBHandler): irc.reply('There are no regexp triggers in the database.') return - s = [ "%s (%d)" % (regexp[0], regexp[1]) for regexp in regexps ] - irc.reply('"' + '","'.join(s) + '"') + s = [ "\"%s\" (%d)" % (regexp[0], regexp[1]) for regexp in regexps ] + irc.reply(', '.join(s)) listall = wrap(listall, ['channel']) def triggerrank(self, irc, msg, args, channel): From 15a4b45801ac7103c26d20142573a33ea35786c5 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Fri, 19 Mar 2010 15:58:59 -0400 Subject: [PATCH 033/243] rename listall and triggerrank to list and rank, to be more in conformance with normal plugin command naming practice. --- plugins/MessageParser/plugin.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/plugins/MessageParser/plugin.py b/plugins/MessageParser/plugin.py index 3347245cf..2c43b6f46 100644 --- a/plugins/MessageParser/plugin.py +++ b/plugins/MessageParser/plugin.py @@ -321,7 +321,7 @@ class MessageParser(callbacks.Plugin, plugins.ChannelDBHandler): getopts({'id': '',}), 'something']) - def listall(self, irc, msg, args, channel): + def list(self, irc, msg, args, channel): """[] Lists regexps present in the triggers database. @@ -340,9 +340,9 @@ class MessageParser(callbacks.Plugin, plugins.ChannelDBHandler): s = [ "\"%s\" (%d)" % (regexp[0], regexp[1]) for regexp in regexps ] irc.reply(', '.join(s)) - listall = wrap(listall, ['channel']) + list = wrap(list, ['channel']) - def triggerrank(self, irc, msg, args, channel): + def rank(self, irc, msg, args, channel): """[] Returns a list of top-ranked regexps, sorted by usage count @@ -360,7 +360,7 @@ class MessageParser(callbacks.Plugin, plugins.ChannelDBHandler): regexps = cursor.fetchall() s = [ "#%d %s (%d)" % (i+1, regexp[0], regexp[1]) for i, regexp in enumerate(regexps) ] irc.reply(", ".join(s)) - triggerrank = wrap(triggerrank, ['channel']) + rank = wrap(rank, ['channel']) Class = MessageParser From 1a3d6c3821c00af2a0c4e526b9b2b7f48859ed26 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Fri, 19 Mar 2010 16:45:59 -0400 Subject: [PATCH 034/243] quote regexp in rank output. --- plugins/MessageParser/plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/MessageParser/plugin.py b/plugins/MessageParser/plugin.py index 2c43b6f46..4c8b6d02d 100644 --- a/plugins/MessageParser/plugin.py +++ b/plugins/MessageParser/plugin.py @@ -358,7 +358,7 @@ class MessageParser(callbacks.Plugin, plugins.ChannelDBHandler): ORDER BY usage_count DESC LIMIT ?""", (numregexps,)) regexps = cursor.fetchall() - s = [ "#%d %s (%d)" % (i+1, regexp[0], regexp[1]) for i, regexp in enumerate(regexps) ] + s = [ "#%d \"%s\" (%d)" % (i+1, regexp[0], regexp[1]) for i, regexp in enumerate(regexps) ] irc.reply(", ".join(s)) rank = wrap(rank, ['channel']) From 3eb6787f6d6d066467e491399fb5d578e6dbbf22 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Fri, 19 Mar 2010 18:51:49 -0400 Subject: [PATCH 035/243] add vacuum method to clean up db. require admin capability by default to do this. --- plugins/MessageParser/config.py | 3 +++ plugins/MessageParser/plugin.py | 20 ++++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/plugins/MessageParser/config.py b/plugins/MessageParser/config.py index f0ee0c4ba..8a89d8f5f 100644 --- a/plugins/MessageParser/config.py +++ b/plugins/MessageParser/config.py @@ -54,5 +54,8 @@ conf.registerChannelValue(MessageParser, 'keepRankInfo', conf.registerChannelValue(MessageParser, 'rankListLength', registry.Integer(20, """Determines the number of regexps returned by the triggerrank command.""")) +conf.registerChannelValue(MessageParser, 'requireVacuumCapability', + registry.String('admin', """Determines the capability required (if any) to + vacuum the database.""")) # vim:set shiftwidth=4 tabstop=4 expandtab textwidth=79: diff --git a/plugins/MessageParser/plugin.py b/plugins/MessageParser/plugin.py index 4c8b6d02d..adbd82339 100644 --- a/plugins/MessageParser/plugin.py +++ b/plugins/MessageParser/plugin.py @@ -362,6 +362,26 @@ class MessageParser(callbacks.Plugin, plugins.ChannelDBHandler): irc.reply(", ".join(s)) rank = wrap(rank, ['channel']) + def vacuum(self, irc, msg, args, channel): + """[] + + Vacuums the database for . + See SQLite vacuum doc here: http://www.sqlite.org/lang_vacuum.html + is only necessary if the message isn't sent in + the channel itself. + First check if user has the required capability specified in plugin + config requireVacuumCapability. + """ + capability = self.registryValue('requireVacuumCapability') + if capability: + if not ircdb.checkCapability(msg.prefix, capability): + irc.errorNoCapability(capability, Raise=True) + db = self.getDb(channel) + cursor = db.cursor() + cursor.execute("""VACUUM""") + db.commit() + irc.replySuccess() + vacuum = wrap(vacuum, ['channel']) Class = MessageParser From db81745d71b2090e1fea194e5ccab8ad2994db5c Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Sun, 21 Mar 2010 15:45:06 -0400 Subject: [PATCH 036/243] update messageparser readme to point to the web doc. --- plugins/MessageParser/README.txt | 61 +------------------------------- 1 file changed, 1 insertion(+), 60 deletions(-) diff --git a/plugins/MessageParser/README.txt b/plugins/MessageParser/README.txt index 5cd9e7700..25159d3cf 100644 --- a/plugins/MessageParser/README.txt +++ b/plugins/MessageParser/README.txt @@ -1,62 +1,3 @@ The MessageParser plugin allows you to set custom regexp triggers, which will trigger the bot to respond if they match anywhere in the message. This is useful for those cases when you want a bot response even when the bot was not explicitly addressed by name or prefix character. -== Commands == - -The main commands of the plugin are 'add', 'remove', and 'show'. There are also 'listall' and 'triggerrank'. We will discuss them all below. - -=== messageparser add === - -To add a trigger, use the obviously-named "messageparser add" command. It takes two arguments, the regexp (using standard python style regular expressions), and the command that is executed when it matches. If either of those contain spaces, they must be quoted. If they contain quotes, those quotes must be escaped. - -Here is a basic example command: -messageparser add "some stuff" "echo I saw some stuff!" -Once that is added, any message that contains the string "some stuff" will cause the bot to respond with "I saw some stuff!". - -The response string can contain placeholders for regexp match groups. These will be interpolated into the string. Here's an example: -messageparser add "my name is (\w+)" "echo hello, $1!" -If you then send a message "hi, my name is bla", the bot will respond with "hello, bla!". - -The regexp triggers are set to be unique - if you add the same regexp on top of an existing one, its response string will be overwritten. - -If more than one regexp trigger matches, each one will cause its respective response. If one regexp matches multiple times in a message, it will cause multiple responses. - -You can use arbitrary supybot commands as the action - be creative, and don't limit yourself to 'echo'. A couple of my favorites are: -messageparser add ",,(\w+)" "$1" -This one causes the bot to take one-word commands from in-message, if they're preceded by double-comma. So you could send a message like "Show me your ,,version and your ,,uptime", and you'd get two responses back, one with version, one with uptime. - -messageparser add ",,\(([^\)]*?)\)" "$1" -This one causes the bot to take multi-word commands from in-message, if they're preceded by double-comma and open-parenthesis, and closed with close-parenthesis. So you could send a message like "I'd like a ,,(factoids search *) please", and you'd the output of command 'factoids search *'. - -Your imagination is the limit! - -The trigger database is deliberately set to only allow unique regexps as triggers, to avoid accidental spam from multiple instances of the same regexp. If, however, you really want multiple responses to happen to one trigger, you can always tweak your regexp with some non-matching groups. My favorites for this are '(?i)', which causes regexp to be non-case-sensitive, but doesn't consume any characters, and '(?m)', which causes the regexp to be multiline, but also doesn't consume any characters. (See python documentation on the re module here: http://docs.python.org/library/re.html) - -So, for example, if you want to set multiple triggers on someone saying "stuff", you could add triggers for "stuff", "(?m)stuff", "(?m)(?m)stuff", "(?m)(?m)(?m)stuff", etc. If you want it to be case-sensitive, you can use (?i) to the same effect. - -But generally it's a good idea to avoid spamminess. :) - -=== messageparser remove === - -You can remove a trigger using the remove command, by specifying the verbatim regexp you want to remove the trigger for. Here's a simple example: -messageparser remove "some stuff" -This would remove the trigger for "some stuff" if you have set one. - -=== messageparser show === - -You can show the contents of the response string for a particular trigger by using the show command, and specifying the verbatim regexp you want to display. Here's an example: -messageparser show "my name is (\w+)" -Will display the trigger with its associated response string. - -=== messageparser listall === - -The listall command will list all the regexps which are currently in the database. It takes no agruments. If you send this out of channel, specify channel name as argument. - -=== messageparser triggerrank === - -The plugin by default keeps statistics on how many times each regexp was triggered. Using the triggerrank command you can see the regexps sorted in descending order of number of trigger times. The number in parentheses after each regexp is the count of trigger occurrences for each. - -Note if you delete, or overwrite an existing, regexp, its count will be reset to 0. - -== Configuration == - -Supybot configuration is self-documenting. Run 'config list plugins.messageparser' for list of config keys, and 'config help ' for help on each one. \ No newline at end of file +An updated page of this plugin's documentation is located here: http://sourceforge.net/apps/mediawiki/gribble/index.php?title=MessageParser_Plugin From de4936d452e9389b5c09a1074045a858a98d9978 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Sun, 21 Mar 2010 17:43:37 -0400 Subject: [PATCH 037/243] write some test code for messageparser --- plugins/MessageParser/test.py | 42 +++++++++++++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/plugins/MessageParser/test.py b/plugins/MessageParser/test.py index 3a7a8989f..310a84e68 100644 --- a/plugins/MessageParser/test.py +++ b/plugins/MessageParser/test.py @@ -30,8 +30,46 @@ from supybot.test import * -class MessageParserTestCase(PluginTestCase): - plugins = ('MessageParser',) +try: + import sqlite3 +except ImportError: + from pysqlite2 import dbapi2 as sqlite3 # for python2.4 +class MessageParserTestCase(ChannelPluginTestCase): + plugins = ('MessageParser','Utilities',) #utilities for the 'echo' + + def testAdd(self): + self.assertError('messageparser add') #no args + self.assertError('messageparser add "stuff"') #no action arg + self.assertNotError('messageparser add "stuff" "echo i saw some stuff"') + self.assertRegexp('messageparser show "stuff"', '.*i saw some stuff.*') + + self.assertError('messageparser add "[a" "echo stuff"') #invalid regexp + self.assertError('messageparser add "(a" "echo stuff"') #invalid regexp + self.assertNotError('messageparser add "stuff" "echo i saw no stuff"') #overwrite existing regexp + self.assertRegexp('messageparser show "stuff"', '.*i saw no stuff.*') + + def testShow(self): + self.assertNotError('messageparser add "stuff" "echo i saw some stuff"') + self.assertRegexp('messageparser show "nostuff"', 'there is no such regexp trigger') + self.assertRegexp('messageparser show "stuff"', '.*i saw some stuff.*') + self.assertRegexp('messageparser show --id 1', '.*i saw some stuff.*') + + def testInfo(self): + self.assertNotError('messageparser add "stuff" "echo i saw some stuff"') + self.assertRegexp('messageparser info "nostuff"', 'there is no such regexp trigger') + self.assertRegexp('messageparser info "stuff"', '.*i saw some stuff.*') + self.assertRegexp('messageparser info --id 1', '.*i saw some stuff.*') + self.assertRegexp('messageparser info "stuff"', 'has been triggered 0 times') + self.feedMsg('this message has some stuff in it') + self.getMsg(' ') + self.assertRegexp('messageparser info "stuff"', 'has been triggered 1 times') + + def testTrigger(self): + self.assertNotError('messageparser add "stuff" "echo i saw some stuff"') + self.feedMsg('this message has some stuff in it') + m = self.getMsg(' ') + self.failUnless(str(m).startswith('PRIVMSG #test :i saw some stuff')) + # vim:set shiftwidth=4 tabstop=4 expandtab textwidth=79: From 45e2aa5ca757d9451224bfb04319d5310d1b5e28 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Sun, 21 Mar 2010 19:06:04 -0400 Subject: [PATCH 038/243] write more test code, and in the process add some minor mods to the plugin code --- plugins/MessageParser/plugin.py | 24 ++++++++++------- plugins/MessageParser/test.py | 47 +++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 9 deletions(-) diff --git a/plugins/MessageParser/plugin.py b/plugins/MessageParser/plugin.py index b14d0aad4..b63c1c202 100644 --- a/plugins/MessageParser/plugin.py +++ b/plugins/MessageParser/plugin.py @@ -182,7 +182,7 @@ class MessageParser(callbacks.Plugin, plugins.ChannelDBHandler): db.commit() irc.replySuccess() else: - irc.reply('That trigger is locked.') + irc.error('That trigger is locked.') return add = wrap(add, ['channel', 'something', 'something']) @@ -206,11 +206,11 @@ class MessageParser(callbacks.Plugin, plugins.ChannelDBHandler): if len(results) != 0: (id, locked) = map(int, results[0]) else: - irc.reply('There is no such regexp trigger.') + irc.error('There is no such regexp trigger.') return if locked: - irc.reply('This regexp trigger is locked.') + irc.error('This regexp trigger is locked.') return cursor.execute("""DELETE FROM triggers WHERE id=?""", (id,)) @@ -232,7 +232,7 @@ class MessageParser(callbacks.Plugin, plugins.ChannelDBHandler): cursor.execute("SELECT id FROM triggers WHERE regexp=?", (regexp,)) results = cursor.fetchall() if len(results) == 0: - irc.reply('There is no such regexp trigger.') + irc.error('There is no such regexp trigger.') return cursor.execute("UPDATE triggers SET locked=1 WHERE regexp=?", (regexp,)) db.commit() @@ -251,7 +251,7 @@ class MessageParser(callbacks.Plugin, plugins.ChannelDBHandler): cursor.execute("SELECT id FROM triggers WHERE regexp=?", (regexp,)) results = cursor.fetchall() if len(results) == 0: - irc.reply('There is no such regexp trigger.') + irc.error('There is no such regexp trigger.') return cursor.execute("UPDATE triggers SET locked=0 WHERE regexp=?", (regexp,)) db.commit() @@ -278,7 +278,7 @@ class MessageParser(callbacks.Plugin, plugins.ChannelDBHandler): if len(results) != 0: (regexp, action) = results[0] else: - irc.reply('There is no such regexp trigger.') + irc.error('There is no such regexp trigger.') return irc.reply("The action for regexp trigger \"%s\" is \"%s\"" % (regexp, action)) @@ -304,12 +304,15 @@ class MessageParser(callbacks.Plugin, plugins.ChannelDBHandler): cursor.execute(sql, (regexp,)) results = cursor.fetchall() if len(results) != 0: - (id, regexp, added_by, added_at, usage_count, action, locked) = results[0] + (id, regexp, added_by, added_at, usage_count, + action, locked) = results[0] else: - irc.reply('There is no such regexp trigger.') + irc.error('There is no such regexp trigger.') return - irc.reply("The regexp trigger id is %d, regexp is \"%s\", and action is \"%s\". It was added by user %s on %s, has been triggered %d times, and is %s." % (id, + irc.reply("The regexp id is %d, regexp is \"%s\", and action is" + " \"%s\". It was added by user %s on %s, has been " + "triggered %d times, and is %s." % (id, regexp, action, added_by, @@ -358,6 +361,9 @@ class MessageParser(callbacks.Plugin, plugins.ChannelDBHandler): ORDER BY usage_count DESC LIMIT ?""", (numregexps,)) regexps = cursor.fetchall() + if len(regexps) == 0: + irc.reply('There are no regexp triggers in the database.') + return s = [ "#%d \"%s\" (%d)" % (i+1, regexp[0], regexp[1]) for i, regexp in enumerate(regexps) ] irc.reply(", ".join(s)) rank = wrap(rank, ['channel']) diff --git a/plugins/MessageParser/test.py b/plugins/MessageParser/test.py index 310a84e68..b03092f9c 100644 --- a/plugins/MessageParser/test.py +++ b/plugins/MessageParser/test.py @@ -71,5 +71,52 @@ class MessageParserTestCase(ChannelPluginTestCase): self.feedMsg('this message has some stuff in it') m = self.getMsg(' ') self.failUnless(str(m).startswith('PRIVMSG #test :i saw some stuff')) + + def testLock(self): + self.assertNotError('messageparser add "stuff" "echo i saw some stuff"') + self.assertNotError('messageparser lock "stuff"') + self.assertError('messageparser add "stuff" "echo some other stuff"') + self.assertError('messageparser remove "stuff"') + self.assertRegexp('messageparser info "stuff"', 'is locked') + + def testUnlock(self): + self.assertNotError('messageparser add "stuff" "echo i saw some stuff"') + self.assertNotError('messageparser lock "stuff"') + self.assertError('messageparser remove "stuff"') + self.assertNotError('messageparser unlock "stuff"') + self.assertRegexp('messageparser info "stuff"', 'is not locked') + self.assertNotError('messageparser remove "stuff"') + + def testRank(self): + self.assertRegexp('messageparser rank', + 'There are no regexp triggers in the database\.') + self.assertNotError('messageparser add "stuff" "echo i saw some stuff"') + self.assertRegexp('messageparser rank', '#1 "stuff" \(0\)') + self.assertNotError('messageparser add "aoeu" "echo vowels are nice!"') + self.assertRegexp('messageparser rank', '#1 "stuff" \(0\), #2 "aoeu" \(0\)') + self.feedMsg('instead of asdf, dvorak has aoeu') + self.getMsg(' ') + self.assertRegexp('messageparser rank', '#1 "aoeu" \(1\), #2 "stuff" \(0\)') + + def testList(self): + self.assertRegexp('messageparser list', + 'There are no regexp triggers in the database\.') + self.assertNotError('messageparser add "stuff" "echo i saw some stuff"') + self.assertRegexp('messageparser list', '"stuff" \(1\)') + self.assertNotError('messageparser add "aoeu" "echo vowels are nice!"') + self.assertRegexp('messageparser list', '"stuff" \(1\), "aoeu" \(2\)') + + def testRemove(self): + self.assertError('messageparser remove "stuff"') + self.assertNotError('messageparser add "stuff" "echo i saw some stuff"') + self.assertNotError('messageparser lock "stuff"') + self.assertError('messageparser remove "stuff"') + self.assertNotError('messageparser unlock "stuff"') + self.assertNotError('messageparser remove "stuff"') + self.assertNotError('messageparser add "stuff" "echo i saw some stuff"') + self.assertNotError('messageparser remove --id 1') + + def testVacuum(self): + pass # vim:set shiftwidth=4 tabstop=4 expandtab textwidth=79: From 56862da549b680f5775f4ae21aeb41347cad2551 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Sun, 21 Mar 2010 22:30:30 -0400 Subject: [PATCH 039/243] add more messageparser tests --- plugins/MessageParser/test.py | 40 +++++++++++++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/plugins/MessageParser/test.py b/plugins/MessageParser/test.py index b03092f9c..949bedac5 100644 --- a/plugins/MessageParser/test.py +++ b/plugins/MessageParser/test.py @@ -37,7 +37,9 @@ except ImportError: class MessageParserTestCase(ChannelPluginTestCase): - plugins = ('MessageParser','Utilities',) #utilities for the 'echo' + plugins = ('MessageParser','Utilities','User') + #utilities for the 'echo' + #user for register for testVacuum def testAdd(self): self.assertError('messageparser add') #no args @@ -117,6 +119,40 @@ class MessageParserTestCase(ChannelPluginTestCase): self.assertNotError('messageparser remove --id 1') def testVacuum(self): - pass + self.assertNotError('messageparser add "stuff" "echo i saw some stuff"') + self.assertNotError('messageparser remove "stuff"') + self.assertNotError('messageparser vacuum') + # disable world.testing since we want new users to not + # magically be endowed with the admin capability + try: + world.testing = False + original = self.prefix + self.prefix = 'stuff!stuff@stuff' + self.assertNotError('register nottester stuff', private=True) + self.assertError('messageparser vacuum') + + orig = conf.supybot.plugins.MessageParser.requireVacuumCapability() + conf.supybot.plugins.MessageParser.requireVacuumCapability.setValue('') + self.assertNotError('messageparser vacuum') + finally: + world.testing = True + self.prefix = original + conf.supybot.plugins.MessageParser.requireVacuumCapability.setValue(orig) + + def testKeepRankInfo(self): + orig = conf.supybot.plugins.MessageParser.keepRankInfo() + + try: + conf.supybot.plugins.MessageParser.keepRankInfo.setValue(False) + self.assertNotError('messageparser add "stuff" "echo i saw some stuff"') + self.feedMsg('instead of asdf, dvorak has aoeu') + self.getMsg(' ') + self.assertRegexp('messageparser info "stuff"', 'has been triggered 0 times') + finally: + conf.supybot.plugins.MessageParser.keepRankInfo.setValue(orig) + + self.feedMsg('this message has some stuff in it') + self.getMsg(' ') + self.assertRegexp('messageparser info "stuff"', 'has been triggered 1 times') # vim:set shiftwidth=4 tabstop=4 expandtab textwidth=79: From bb44d433f54ceb9a779ed4549ed6c8b70493f71d Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Mon, 22 Mar 2010 01:06:02 -0400 Subject: [PATCH 040/243] add replies function to reply plugin, which makes multiple replies, if supybot.reply.oneToOne is false. --- plugins/Reply/plugin.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/plugins/Reply/plugin.py b/plugins/Reply/plugin.py index 9b0a6db4a..a49018a98 100644 --- a/plugins/Reply/plugin.py +++ b/plugins/Reply/plugin.py @@ -73,7 +73,16 @@ class Reply(callbacks.Plugin): """ irc.reply(text, prefixNick=True) reply = wrap(reply, ['text']) - + + def replies(self, irc, msg, args, strings): + """ [ ...] + + Replies with each of its arguments in separate replies, depending + the configuration of supybot.reply.oneToOne. + """ + irc.replies(strings) + replies = wrap(replies, [many('something')]) + Class = Reply From 69c7774431571443a8bfe5bd6438762f68ec3e0a Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Mon, 22 Mar 2010 16:37:06 -0400 Subject: [PATCH 041/243] add config for required capabilities to manage the regexp db, and check for them before taking action. --- plugins/MessageParser/config.py | 11 +++++++++-- plugins/MessageParser/plugin.py | 28 +++++++++++++++++++++++++++- plugins/MessageParser/test.py | 16 ++++++++++++++++ 3 files changed, 52 insertions(+), 3 deletions(-) diff --git a/plugins/MessageParser/config.py b/plugins/MessageParser/config.py index 8a89d8f5f..456feb023 100644 --- a/plugins/MessageParser/config.py +++ b/plugins/MessageParser/config.py @@ -39,7 +39,6 @@ def configure(advanced): from supybot.questions import expect, anything, something, yn conf.registerPlugin('MessageParser', True) - MessageParser = conf.registerPlugin('MessageParser') # This is where your configuration variables (if any) should go. For example: # conf.registerGlobalValue(MessageParser, 'someConfigVariableName', @@ -57,5 +56,13 @@ conf.registerChannelValue(MessageParser, 'rankListLength', conf.registerChannelValue(MessageParser, 'requireVacuumCapability', registry.String('admin', """Determines the capability required (if any) to vacuum the database.""")) - +conf.registerChannelValue(MessageParser, 'requireManageCapability', + registry.String('admin; channel,op', + """Determines the + capabilities required (if any) to manage the regexp database, + including add, remove, lock, unlock. Use 'channel,capab' for + channel-level capabilities. + Note that absence of an explicit anticapability means user has + capability.""")) + # vim:set shiftwidth=4 tabstop=4 expandtab textwidth=79: diff --git a/plugins/MessageParser/plugin.py b/plugins/MessageParser/plugin.py index b63c1c202..c7e13d19b 100644 --- a/plugins/MessageParser/plugin.py +++ b/plugins/MessageParser/plugin.py @@ -117,13 +117,27 @@ class MessageParser(callbacks.Plugin, plugins.ChannelDBHandler): def _runCommandFunction(self, irc, msg, command): """Run a command from message, as if command was sent over IRC.""" - # need to encode it from unicode, since sqlite stores text as unicode. tokens = callbacks.tokenize(command) try: self.Proxy(irc.irc, msg, tokens) except Exception, e: log.exception('Uncaught exception in scheduled function:') + def _checkManageCapabilities(self, irc, msg, channel): + """Check if the user has any of the required capabilities to manage + the regexp database.""" + capabilities = self.registryValue('requireManageCapability') + if capabilities: + for capability in re.split(r'\s*;\s*', capabilities): + if capability.startswith('channel,'): + capability = ircdb.makeChannelCapability(channel, capability[8:]) + if capability and ircdb.checkCapability(msg.prefix, capability): + #print "has capability:", capability + return True + return False + else: + return True + def doPrivmsg(self, irc, msg): channel = msg.args[0] if not irc.isChannel(channel): @@ -157,6 +171,9 @@ class MessageParser(callbacks.Plugin, plugins.ChannelDBHandler): necessary if the message isn't sent on the channel itself. Action is echoed upon regexp match, with variables $1, $2, etc. being interpolated from the regexp match groups.""" + if not self._checkManageCapabilities(irc, msg, channel): + capabilities = self.registryValue('requireManageCapability') + irc.errorNoCapability(capabilities, Raise=True) db = self.getDb(channel) cursor = db.cursor() cursor.execute("SELECT id, usage_count, locked FROM triggers WHERE regexp=?", (regexp,)) @@ -194,6 +211,9 @@ class MessageParser(callbacks.Plugin, plugins.ChannelDBHandler): the message isn't sent in the channel itself. If option --id specified, will retrieve by regexp id, not content. """ + if not self._checkManageCapabilities(irc, msg, channel): + capabilities = self.registryValue('requireManageCapability') + irc.errorNoCapability(capabilities, Raise=True) db = self.getDb(channel) cursor = db.cursor() target = 'regexp' @@ -227,6 +247,9 @@ class MessageParser(callbacks.Plugin, plugins.ChannelDBHandler): removed or overwritten to. is only necessary if the message isn't sent in the channel itself. """ + if not self._checkManageCapabilities(irc, msg, channel): + capabilities = self.registryValue('requireManageCapability') + irc.errorNoCapability(capabilities, Raise=True) db = self.getDb(channel) cursor = db.cursor() cursor.execute("SELECT id FROM triggers WHERE regexp=?", (regexp,)) @@ -246,6 +269,9 @@ class MessageParser(callbacks.Plugin, plugins.ChannelDBHandler): removed or overwritten. is only necessary if the message isn't sent in the channel itself. """ + if not self._checkManageCapabilities(irc, msg, channel): + capabilities = self.registryValue('requireManageCapability') + irc.errorNoCapability(capabilities, Raise=True) db = self.getDb(channel) cursor = db.cursor() cursor.execute("SELECT id FROM triggers WHERE regexp=?", (regexp,)) diff --git a/plugins/MessageParser/test.py b/plugins/MessageParser/test.py index 949bedac5..870f83ebd 100644 --- a/plugins/MessageParser/test.py +++ b/plugins/MessageParser/test.py @@ -52,6 +52,22 @@ class MessageParserTestCase(ChannelPluginTestCase): self.assertNotError('messageparser add "stuff" "echo i saw no stuff"') #overwrite existing regexp self.assertRegexp('messageparser show "stuff"', '.*i saw no stuff.*') + + try: + world.testing = False + origuser = self.prefix + self.prefix = 'stuff!stuff@stuff' + self.assertNotError('register nottester stuff', private=True) + + self.assertError('messageparser add "aoeu" "echo vowels are nice"') + origconf = conf.supybot.plugins.MessageParser.requireManageCapability() + conf.supybot.plugins.MessageParser.requireManageCapability.setValue('') + self.assertNotError('messageparser add "aoeu" "echo vowels are nice"') + finally: + world.testing = True + self.prefix = origuser + conf.supybot.plugins.MessageParser.requireManageCapability.setValue(origconf) + def testShow(self): self.assertNotError('messageparser add "stuff" "echo i saw some stuff"') self.assertRegexp('messageparser show "nostuff"', 'there is no such regexp trigger') From 0fb4dd6dff8fe3106abfc938426b84326899c035 Mon Sep 17 00:00:00 2001 From: nanotube Date: Tue, 23 Mar 2010 13:34:50 -0400 Subject: [PATCH 042/243] rename factrank to just rank for consistency --- plugins/Factoids/plugin.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/Factoids/plugin.py b/plugins/Factoids/plugin.py index bc41205f7..0718ca610 100644 --- a/plugins/Factoids/plugin.py +++ b/plugins/Factoids/plugin.py @@ -266,7 +266,7 @@ class Factoids(callbacks.Plugin, plugins.ChannelDBHandler): self._replyFactoids(irc, msg, key, channel, factoids, number) whatis = wrap(whatis, ['channel', many('something')]) - def factrank(self, irc, msg, args, channel): + def rank(self, irc, msg, args, channel): """[] Returns a list of top-ranked factoid keys, sorted by usage count @@ -285,7 +285,7 @@ class Factoids(callbacks.Plugin, plugins.ChannelDBHandler): factkeys = cursor.fetchall() s = [ "#%d %s (%d)" % (i+1, key[0], key[1]) for i, key in enumerate(factkeys) ] irc.reply(", ".join(s)) - factrank = wrap(factrank, ['channel']) + rank = wrap(rank, ['channel']) def lock(self, irc, msg, args, channel, key): """[] From 2e043ce19ff2fa790237d6b2dfbbc113a598225b Mon Sep 17 00:00:00 2001 From: nanotube Date: Tue, 23 Mar 2010 13:54:31 -0400 Subject: [PATCH 043/243] add rank test to factoids, update factoids test code to sqlite3 --- plugins/Factoids/test.py | 254 ++++++++++++++++++++------------------- 1 file changed, 130 insertions(+), 124 deletions(-) diff --git a/plugins/Factoids/test.py b/plugins/Factoids/test.py index 30a8a9f06..5cafe3ce9 100644 --- a/plugins/Factoids/test.py +++ b/plugins/Factoids/test.py @@ -31,137 +31,143 @@ from supybot.test import * try: - import sqlite + import sqlite3 except ImportError: - sqlite = None + from pysqlite2 import dbapi2 as sqlite3 # for python2.4 -if sqlite: - class FactoidsTestCase(ChannelPluginTestCase): - plugins = ('Factoids',) - def testRandomfactoid(self): - self.assertError('random') - self.assertNotError('learn jemfinch as my primary author') - self.assertRegexp('random', 'primary author') +class FactoidsTestCase(ChannelPluginTestCase): + plugins = ('Factoids',) + def testRandomfactoid(self): + self.assertError('random') + self.assertNotError('learn jemfinch as my primary author') + self.assertRegexp('random', 'primary author') - def testLearn(self): - self.assertError('learn as my primary author') - self.assertError('learn jemfinch as') - self.assertNotError('learn jemfinch as my primary author') - self.assertNotError('info jemfinch') - self.assertRegexp('whatis jemfinch', 'my primary author') - self.assertRegexp('whatis JEMFINCH', 'my primary author') - self.assertRegexp('whatis JEMFINCH 1', 'my primary author') - self.assertNotError('learn jemfinch as a bad assembly programmer') - self.assertRegexp('whatis jemfinch 2', 'bad assembly') - self.assertNotRegexp('whatis jemfinch 2', 'primary author') - self.assertRegexp('whatis jemfinch', r'.*primary author.*assembly') - self.assertError('forget jemfinch') - self.assertError('forget jemfinch 3') - self.assertError('forget jemfinch 0') - self.assertNotError('forget jemfinch 2') - self.assertNotError('forget jemfinch 1') - self.assertError('whatis jemfinch') - self.assertError('info jemfinch') + def testLearn(self): + self.assertError('learn as my primary author') + self.assertError('learn jemfinch as') + self.assertNotError('learn jemfinch as my primary author') + self.assertNotError('info jemfinch') + self.assertRegexp('whatis jemfinch', 'my primary author') + self.assertRegexp('whatis JEMFINCH', 'my primary author') + self.assertRegexp('whatis JEMFINCH 1', 'my primary author') + self.assertNotError('learn jemfinch as a bad assembly programmer') + self.assertRegexp('whatis jemfinch 2', 'bad assembly') + self.assertNotRegexp('whatis jemfinch 2', 'primary author') + self.assertRegexp('whatis jemfinch', r'.*primary author.*assembly') + self.assertError('forget jemfinch') + self.assertError('forget jemfinch 3') + self.assertError('forget jemfinch 0') + self.assertNotError('forget jemfinch 2') + self.assertNotError('forget jemfinch 1') + self.assertError('whatis jemfinch') + self.assertError('info jemfinch') - self.assertNotError('learn foo bar as baz') - self.assertNotError('info foo bar') - self.assertRegexp('whatis foo bar', 'baz') - self.assertNotError('learn foo bar as quux') - self.assertRegexp('whatis foo bar', '.*baz.*quux') - self.assertError('forget foo bar') - self.assertNotError('forget foo bar 2') - self.assertNotError('forget foo bar 1') - self.assertError('whatis foo bar') - self.assertError('info foo bar') + self.assertNotError('learn foo bar as baz') + self.assertNotError('info foo bar') + self.assertRegexp('whatis foo bar', 'baz') + self.assertNotError('learn foo bar as quux') + self.assertRegexp('whatis foo bar', '.*baz.*quux') + self.assertError('forget foo bar') + self.assertNotError('forget foo bar 2') + self.assertNotError('forget foo bar 1') + self.assertError('whatis foo bar') + self.assertError('info foo bar') - self.assertError('learn foo bar baz') # No 'as' - self.assertError('learn foo bar') # No 'as' + self.assertError('learn foo bar baz') # No 'as' + self.assertError('learn foo bar') # No 'as' - def testChangeFactoid(self): + def testChangeFactoid(self): + self.assertNotError('learn foo as bar') + self.assertNotError('change foo 1 s/bar/baz/') + self.assertRegexp('whatis foo', 'baz') + self.assertError('change foo 2 s/bar/baz/') + self.assertError('change foo 0 s/bar/baz/') + + def testSearchFactoids(self): + self.assertNotError('learn jemfinch as my primary author') + self.assertNotError('learn strike as a cool guy working on me') + self.assertNotError('learn inkedmn as another of my developers') + self.assertNotError('learn jamessan as a developer of much python') + self.assertNotError('learn bwp as author of my weather command') + self.assertRegexp('factoids search --regexp /.w./', 'bwp') + self.assertRegexp('factoids search --regexp /^.+i/', + 'jemfinch.*strike') + self.assertNotRegexp('factoids search --regexp /^.+i/', 'inkedmn') + self.assertRegexp('factoids search --regexp m/j/ --regexp m/ss/', + 'jamessan') + self.assertRegexp('factoids search --regexp m/^j/ *ss*', + 'jamessan') + self.assertRegexp('factoids search --regexp /^j/', + 'jamessan.*jemfinch') + self.assertRegexp('factoids search j*', 'jamessan.*jemfinch') + self.assertRegexp('factoids search *ke*', + 'inkedmn.*strike|strike.*inkedmn') + self.assertRegexp('factoids search ke', + 'inkedmn.*strike|strike.*inkedmn') + self.assertRegexp('factoids search jemfinch', + 'my primary author') + self.assertRegexp('factoids search --values primary author', + 'my primary author') + + def testWhatisOnNumbers(self): + self.assertNotError('learn 911 as emergency number') + self.assertRegexp('whatis 911', 'emergency number') + + def testNotZeroIndexed(self): + self.assertNotError('learn foo as bar') + self.assertNotRegexp('info foo', '#0') + self.assertNotRegexp('whatis foo', '#0') + self.assertNotError('learn foo as baz') + self.assertNotRegexp('info foo', '#0') + self.assertNotRegexp('whatis foo', '#0') + + def testInfoReturnsRightNumber(self): + self.assertNotError('learn foo as bar') + self.assertNotRegexp('info foo', '2 factoids') + + def testLearnSeparator(self): + self.assertError('learn foo is bar') + self.assertNotError('learn foo as bar') + self.assertRegexp('whatis foo', 'bar') + orig = conf.supybot.plugins.Factoids.learnSeparator() + try: + conf.supybot.plugins.Factoids.learnSeparator.setValue('is') + self.assertError('learn bar as baz') + self.assertNotError('learn bar is baz') + self.assertRegexp('whatis bar', 'baz') + finally: + conf.supybot.plugins.Factoids.learnSeparator.setValue(orig) + + def testShowFactoidIfOnlyOneMatch(self): + m1 = self.assertNotError('factoids search m/foo|bar/') + orig = conf.supybot.plugins.Factoids.showFactoidIfOnlyOneMatch() + try: + conf.supybot.plugins.Factoids. \ + showFactoidIfOnlyOneMatch.setValue(False) + m2 = self.assertNotError('factoids search m/foo/') + self.failUnless(m1.args[1].startswith(m2.args[1])) + finally: + conf.supybot.plugins.Factoids. \ + showFactoidIfOnlyOneMatch.setValue(orig) + + def testInvalidCommand(self): + orig = conf.supybot.plugins.Factoids.replyWhenInvalidCommand() + try: + conf.supybot.plugins.Factoids.\ + replyWhenInvalidCommand.setValue(True) self.assertNotError('learn foo as bar') - self.assertNotError('change foo 1 s/bar/baz/') - self.assertRegexp('whatis foo', 'baz') - self.assertError('change foo 2 s/bar/baz/') - self.assertError('change foo 0 s/bar/baz/') - - def testSearchFactoids(self): - self.assertNotError('learn jemfinch as my primary author') - self.assertNotError('learn strike as a cool guy working on me') - self.assertNotError('learn inkedmn as another of my developers') - self.assertNotError('learn jamessan as a developer of much python') - self.assertNotError('learn bwp as author of my weather command') - self.assertRegexp('factoids search --regexp /.w./', 'bwp') - self.assertRegexp('factoids search --regexp /^.+i/', - 'jemfinch.*strike') - self.assertNotRegexp('factoids search --regexp /^.+i/', 'inkedmn') - self.assertRegexp('factoids search --regexp m/j/ --regexp m/ss/', - 'jamessan') - self.assertRegexp('factoids search --regexp m/^j/ *ss*', - 'jamessan') - self.assertRegexp('factoids search --regexp /^j/', - 'jamessan.*jemfinch') - self.assertRegexp('factoids search j*', 'jamessan.*jemfinch') - self.assertRegexp('factoids search *ke*', - 'inkedmn.*strike|strike.*inkedmn') - self.assertRegexp('factoids search ke', - 'inkedmn.*strike|strike.*inkedmn') - self.assertRegexp('factoids search jemfinch', - 'my primary author') - self.assertRegexp('factoids search --values primary author', - 'my primary author') - - def testWhatisOnNumbers(self): - self.assertNotError('learn 911 as emergency number') - self.assertRegexp('whatis 911', 'emergency number') - - def testNotZeroIndexed(self): - self.assertNotError('learn foo as bar') - self.assertNotRegexp('info foo', '#0') - self.assertNotRegexp('whatis foo', '#0') - self.assertNotError('learn foo as baz') - self.assertNotRegexp('info foo', '#0') - self.assertNotRegexp('whatis foo', '#0') - - def testInfoReturnsRightNumber(self): - self.assertNotError('learn foo as bar') - self.assertNotRegexp('info foo', '2 factoids') - - def testLearnSeparator(self): - self.assertError('learn foo is bar') - self.assertNotError('learn foo as bar') - self.assertRegexp('whatis foo', 'bar') - orig = conf.supybot.plugins.Factoids.learnSeparator() - try: - conf.supybot.plugins.Factoids.learnSeparator.setValue('is') - self.assertError('learn bar as baz') - self.assertNotError('learn bar is baz') - self.assertRegexp('whatis bar', 'baz') - finally: - conf.supybot.plugins.Factoids.learnSeparator.setValue(orig) - - def testShowFactoidIfOnlyOneMatch(self): - m1 = self.assertNotError('factoids search m/foo|bar/') - orig = conf.supybot.plugins.Factoids.showFactoidIfOnlyOneMatch() - try: - conf.supybot.plugins.Factoids. \ - showFactoidIfOnlyOneMatch.setValue(False) - m2 = self.assertNotError('factoids search m/foo/') - self.failUnless(m1.args[1].startswith(m2.args[1])) - finally: - conf.supybot.plugins.Factoids. \ - showFactoidIfOnlyOneMatch.setValue(orig) - - def testInvalidCommand(self): - orig = conf.supybot.plugins.Factoids.replyWhenInvalidCommand() - try: - conf.supybot.plugins.Factoids.\ - replyWhenInvalidCommand.setValue(True) - self.assertNotError('learn foo as bar') - self.assertRegexp('foo', 'bar') - finally: - conf.supybot.plugins.Factoids.\ - replyWhenInvalidCommand.setValue(orig) - + self.assertRegexp('foo', 'bar') + finally: + conf.supybot.plugins.Factoids.\ + replyWhenInvalidCommand.setValue(orig) + + def testRank(self): + self.assertNotError('learn foo as bar') + self.assertNotError('learn moo as cow') + self.assertRegexp('factoids rank', '#1 foo \(0\), #2 moo \(0\)') + self.assertRegexp('whatis moo', '.*cow.*') + self.assertRegexp('factoids rank', '#1 moo \(1\), #2 foo \(0\)') + def testQuoteHandling(self): self.assertNotError('learn foo as "\\"bar\\""') self.assertRegexp('whatis foo', r'"bar"') From 32c718ca666038c869d027cc7d130370c6c48fac Mon Sep 17 00:00:00 2001 From: nanotube Date: Tue, 23 Mar 2010 15:46:22 -0400 Subject: [PATCH 044/243] don't give up too easily with invalid command, instead search factoid keys with wildcard first. --- plugins/Factoids/config.py | 5 +++++ plugins/Factoids/plugin.py | 21 ++++++++++++++++++++- plugins/Factoids/test.py | 3 +++ 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/plugins/Factoids/config.py b/plugins/Factoids/config.py index 2e6f5b3c8..374a84978 100644 --- a/plugins/Factoids/config.py +++ b/plugins/Factoids/config.py @@ -58,6 +58,11 @@ conf.registerChannelValue(Factoids, 'replyWhenInvalidCommand', registry.Boolean(True, """Determines whether the bot will reply to invalid commands by searching for a factoid; basically making the whatis unnecessary when you want all factoids for a given key.""")) +conf.registerChannelValue(Factoids, 'replyWhenInvalidCommandSearchKeys', + registry.Boolean(True, """If replyWhenInvalidCommand is True, and you + supply a nonexistent factoid as a command, this setting make the bot try a + wildcard search for factoid keys, returning a list of matching keys, + before giving up with an invalid command error.""")) conf.registerChannelValue(Factoids, 'format', FactoidFormat('$key could be $value.', """Determines the format of the response given when a factoid's value is requested. All the standard diff --git a/plugins/Factoids/plugin.py b/plugins/Factoids/plugin.py index 0718ca610..37f655aba 100644 --- a/plugins/Factoids/plugin.py +++ b/plugins/Factoids/plugin.py @@ -197,6 +197,16 @@ class Factoids(callbacks.Plugin, plugins.ChannelDBHandler): return cursor.fetchall() #return [t[0] for t in cursor.fetchall()] + def _searchFactoid(self, channel, key): + db = self.getDb(channel) + cursor = db.cursor() + key = '%' + key + '%' + cursor.execute("""SELECT key FROM keys + WHERE key LIKE ? + LIMIT 20""", (key,)) + return cursor.fetchall() + + def _updateRank(self, channel, factoids): if self.registryValue('keepRankInfo', channel): db = self.getDb(channel) @@ -246,7 +256,16 @@ class Factoids(callbacks.Plugin, plugins.ChannelDBHandler): if self.registryValue('replyWhenInvalidCommand', channel): key = ' '.join(tokens) factoids = self._lookupFactoid(channel, key) - self._replyFactoids(irc, msg, key, channel, factoids, error=False) + if factoids: + self._replyFactoids(irc, msg, key, channel, factoids, error=False) + else: + if self.registryValue('replyWhenInvalidCommandSearchKeys'): + factoids = self._searchFactoid(channel, key) + #print 'searchfactoids result:', factoids, '>' + if factoids: + keylist = ["'%s'" % (fact[0],) for fact in factoids] + keylist = ', '.join(keylist) + irc.reply("I do not know about '%s', but I do know about these similar topics: %s" % (key, keylist)) def whatis(self, irc, msg, args, channel, words): """[] [] diff --git a/plugins/Factoids/test.py b/plugins/Factoids/test.py index 5cafe3ce9..ae35271a9 100644 --- a/plugins/Factoids/test.py +++ b/plugins/Factoids/test.py @@ -157,6 +157,9 @@ class FactoidsTestCase(ChannelPluginTestCase): replyWhenInvalidCommand.setValue(True) self.assertNotError('learn foo as bar') self.assertRegexp('foo', 'bar') + self.assertNotError('learn mooz as cowz') + self.assertRegexp('moo', 'mooz') + self.assertError('nosuchthing') finally: conf.supybot.plugins.Factoids.\ replyWhenInvalidCommand.setValue(orig) From 97149b403a84ffde4b7fbc3abba58c8b77854c9e Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Thu, 1 Apr 2010 00:51:25 -0400 Subject: [PATCH 045/243] make 'factoids info' include usage count in output. add test for same. --- plugins/Factoids/plugin.py | 9 +++++---- plugins/Factoids/test.py | 6 ++++++ 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/plugins/Factoids/plugin.py b/plugins/Factoids/plugin.py index 37f655aba..88789445f 100644 --- a/plugins/Factoids/plugin.py +++ b/plugins/Factoids/plugin.py @@ -424,18 +424,19 @@ class Factoids(callbacks.Plugin, plugins.ChannelDBHandler): irc.error('No factoid matches that key.') return (id, locked) = map(int, results[0]) - cursor.execute("""SELECT added_by, added_at FROM factoids + cursor.execute("""SELECT added_by, added_at, usage_count FROM factoids WHERE key_id=? ORDER BY id""", (id,)) factoids = cursor.fetchall() L = [] counter = 0 - for (added_by, added_at) in factoids: + for (added_by, added_at, usage_count) in factoids: counter += 1 added_at = time.strftime(conf.supybot.reply.format.time(), time.localtime(int(added_at))) - L.append(format('#%i was added by %s at %s', - counter, added_by, added_at)) + L.append(format('#%i was added by %s at %s, and has been recalled ' + '%n', + counter, added_by, added_at, (usage_count, 'time'))) factoids = '; '.join(L) s = format('Key %q is %s and has %n associated with it: %s', key, locked and 'locked' or 'not locked', diff --git a/plugins/Factoids/test.py b/plugins/Factoids/test.py index ae35271a9..8127c801d 100644 --- a/plugins/Factoids/test.py +++ b/plugins/Factoids/test.py @@ -125,6 +125,12 @@ class FactoidsTestCase(ChannelPluginTestCase): self.assertNotError('learn foo as bar') self.assertNotRegexp('info foo', '2 factoids') + def testInfoUsageCount(self): + self.assertNotError('learn moo as cow') + self.assertRegexp('info moo', 'recalled 0 times') + self.assertNotError('whatis moo') + self.assertRegexp('info moo', 'recalled 1 time') + def testLearnSeparator(self): self.assertError('learn foo is bar') self.assertNotError('learn foo as bar') From 58886bd1f25b9c6b4139a48461594a41c0c1399c Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Fri, 2 Apr 2010 00:03:01 -0400 Subject: [PATCH 046/243] mod factoids plugin to use a separate key-value relationship table this avoids duplication, and allows one to set a bunch of aliases for a factoid, without creating duplicates of the same fact content. --- plugins/Factoids/plugin.py | 193 ++++++++++++++++++++++++------------- 1 file changed, 126 insertions(+), 67 deletions(-) diff --git a/plugins/Factoids/plugin.py b/plugins/Factoids/plugin.py index 88789445f..28dc665cd 100644 --- a/plugins/Factoids/plugin.py +++ b/plugins/Factoids/plugin.py @@ -100,23 +100,21 @@ class Factoids(callbacks.Plugin, plugins.ChannelDBHandler): cursor = db.cursor() cursor.execute("""CREATE TABLE keys ( id INTEGER PRIMARY KEY, - key TEXT UNIQUE ON CONFLICT IGNORE, - locked BOOLEAN + key TEXT UNIQUE ON CONFLICT REPLACE )""") cursor.execute("""CREATE TABLE factoids ( id INTEGER PRIMARY KEY, - key_id INTEGER, added_by TEXT, added_at TIMESTAMP, - usage_count INTEGER, - fact TEXT + fact TEXT UNIQUE ON CONFLICT REPLACE, + locked BOOLEAN + )""") + cursor.execute("""CREATE TABLE relations ( + id INTEGER PRIMARY KEY, + key_id INTEGER, + fact_id INTEGER, + usage_count INTEGER )""") - cursor.execute("""CREATE TRIGGER remove_factoids - BEFORE DELETE ON keys - BEGIN - DELETE FROM factoids WHERE key_id = old.id; - END - """) db.commit() return db @@ -151,31 +149,52 @@ class Factoids(callbacks.Plugin, plugins.ChannelDBHandler): doc=method._fake__doc__ % (s, s), name=callbacks.formatCommand(command)) return super(Factoids, self).getCommandHelp(command, simpleSyntax) - - def learn(self, irc, msg, args, channel, key, factoid): + + def _getKeyAndFactId(self, channel, key, factoid): db = self.getDb(channel) cursor = db.cursor() - cursor.execute("SELECT id, locked FROM keys WHERE key LIKE ?", (key,)) - results = cursor.fetchall() - if len(results) == 0: - cursor.execute("""INSERT INTO keys VALUES (NULL, ?, 0)""", (key,)) + cursor.execute("SELECT id FROM keys WHERE key=?", (key,)) + keyresults = cursor.fetchall() + cursor.execute("SELECT id FROM factoids WHERE fact=?", (factoid,)) + factresults = cursor.fetchall() + return (keyresults, factresults,) + + def learn(self, irc, msg, args, channel, key, factoid): + + # if neither key nor factoid exist, add them. + # if key exists but factoid doesn't, add factoid, link it to existing key + # if factoid exists but key doesn't, add key, link it to existing factoid + # if both key and factoid already exist, and are linked, do nothing, print nice message + db = self.getDb(channel) + cursor = db.cursor() + (keyid, factid) = self._getKeyAndFactId(channel, key, factoid) + + if len(keyid) == 0: + cursor.execute("""INSERT INTO keys VALUES (NULL, ?)""", (key,)) db.commit() - cursor.execute("SELECT id, locked FROM keys WHERE key LIKE ?", (key,)) - results = cursor.fetchall() - (id, locked) = map(int, results[0]) - capability = ircdb.makeChannelCapability(channel, 'factoids') - if not locked: + if len(factid) == 0: if ircdb.users.hasUser(msg.prefix): name = ircdb.users.getUser(msg.prefix).name else: name = msg.nick cursor.execute("""INSERT INTO factoids VALUES - (NULL, ?, ?, ?, ?, ?)""", - (id, name, int(time.time()), 0, factoid)) + (NULL, ?, ?, ?, ?)""", + (name, int(time.time()), factoid, 0)) + db.commit() + (keyid, factid) = self._getKeyAndFactId(channel, key, factoid) + + cursor.execute("""SELECT id, key_id, fact_id from relations + WHERE key_id=? AND fact_id=?""", + (keyid[0][0], factid[0][0],)) + existingrelation = cursor.fetchall() + if len(existingrelation) == 0: + cursor.execute("""INSERT INTO relations VALUES (NULL, ?, ?, ?)""", + (keyid[0][0],factid[0][0],0,)) db.commit() irc.replySuccess() else: - irc.error('That factoid is locked.') + irc.error("This key-factoid relationship already exists.") + learn = wrap(learn, ['factoid']) learn._fake__doc__ = """[] %s @@ -190,8 +209,8 @@ class Factoids(callbacks.Plugin, plugins.ChannelDBHandler): def _lookupFactoid(self, channel, key): db = self.getDb(channel) cursor = db.cursor() - cursor.execute("""SELECT factoids.fact, factoids.id FROM factoids, keys - WHERE keys.key LIKE ? AND factoids.key_id=keys.id + cursor.execute("""SELECT factoids.fact, factoids.id, relations.id FROM factoids, keys, relations + WHERE keys.key LIKE ? AND relations.key_id=keys.id AND relations.fact_id=factoids.id ORDER BY factoids.id LIMIT 20""", (key,)) return cursor.fetchall() @@ -211,12 +230,13 @@ class Factoids(callbacks.Plugin, plugins.ChannelDBHandler): if self.registryValue('keepRankInfo', channel): db = self.getDb(channel) cursor = db.cursor() - for (fact,id) in factoids: - cursor.execute("""SELECT factoids.usage_count - FROM factoids - WHERE factoids.id=?""", (id,)) + for (fact,factid,relationid) in factoids: + cursor.execute("""SELECT relations.usage_count + FROM relations + WHERE relations.id=?""", (relationid,)) old_count = cursor.fetchall()[0][0] - cursor.execute("UPDATE factoids SET usage_count=? WHERE id=?", (old_count + 1, id,)) + cursor.execute("UPDATE relations SET usage_count=? WHERE id=?", + (old_count + 1, relationid,)) db.commit() def _replyFactoids(self, irc, msg, key, channel, factoids, @@ -296,10 +316,10 @@ class Factoids(callbacks.Plugin, plugins.ChannelDBHandler): numfacts = self.registryValue('rankListLength', channel) db = self.getDb(channel) cursor = db.cursor() - cursor.execute("""SELECT keys.key, factoids.usage_count - FROM keys, factoids - WHERE factoids.key_id=keys.id - ORDER BY factoids.usage_count DESC + cursor.execute("""SELECT keys.key, relations.usage_count + FROM keys, relations + WHERE relations.key_id=keys.id + ORDER BY relations.usage_count DESC LIMIT ?""", (numfacts,)) factkeys = cursor.fetchall() s = [ "#%d %s (%d)" % (i+1, key[0], key[1]) for i, key in enumerate(factkeys) ] @@ -315,7 +335,10 @@ class Factoids(callbacks.Plugin, plugins.ChannelDBHandler): """ db = self.getDb(channel) cursor = db.cursor() - cursor.execute("UPDATE keys SET locked=1 WHERE key LIKE ?", (key,)) + cursor.execute("UPDATE factoids, keys, relations " + "SET factoids.locked=1 WHERE key LIKE ? AND " + "factoids.id=relations.fact_id AND " + "keys.id=relations.key_id", (key,)) db.commit() irc.replySuccess() lock = wrap(lock, ['channel', 'text']) @@ -329,18 +352,47 @@ class Factoids(callbacks.Plugin, plugins.ChannelDBHandler): """ db = self.getDb(channel) cursor = db.cursor() - cursor.execute("UPDATE keys SET locked=0 WHERE key LIKE ?", (key,)) + cursor.execute("""UPDATE factoids, keys, relations + SET factoids.locked=1 WHERE key LIKE ? AND + factoids.id=relations.fact_id AND + keys.id=relations.key_id""", (key,)) db.commit() irc.replySuccess() unlock = wrap(unlock, ['channel', 'text']) + def _deleteRelation(self, channel, relationlist): + db = self.getDb(channel) + cursor = db.cursor() + for (keyid, factid, relationid) in relationlist: + cursor.execute("""DELETE FROM relations where relations.id=?""", + (relationid,)) + db.commit() + + cursor.execute("""SELECT id FROM relations + WHERE relations.key_id=?""", (keyid,)) + remaining_key_relations = cursor.fetchall() + if len(remaining_key_relations) == 0: + cursor.execute("""DELETE FROM keys where id=?""", (keyid,)) + + cursor.execute("""SELECT id FROM relations + WHERE relations.fact_id=?""", (factid,)) + remaining_fact_relations = cursor.fetchall() + if len(remaining_fact_relations) == 0: + cursor.execute("""DELETE FROM factoids where id=?""", (factid,)) + db.commit() + def forget(self, irc, msg, args, channel, words): """[] [|*] - Removes the factoid from the factoids database. If there are - more than one factoid with such a key, a number is necessary to - determine which one should be removed. A * can be used to remove all - factoids associated with a key. is only necessary if + Removes a key-fact relationship for key from the factoids + database. If there is more than one such relationship for this key, + a number is necessary to determine which one should be removed. + A * can be used to remove all relationships for . + + If as a result, the key (factoid) remains without any relationships to + a factoid (key), it shall be removed from the database. + + is only necessary if the message isn't sent in the channel itself. """ number = None @@ -355,29 +407,26 @@ class Factoids(callbacks.Plugin, plugins.ChannelDBHandler): key = ' '.join(words) db = self.getDb(channel) cursor = db.cursor() - cursor.execute("""SELECT keys.id, factoids.id - FROM keys, factoids - WHERE key LIKE ? AND - factoids.key_id=keys.id""", (key,)) + cursor.execute("""SELECT keys.id, factoids.id, relations.id + FROM keys, factoids, relations + WHERE key LIKE ? AND + relations.key_id=keys.id AND + relations.fact_id=factoids.id""", (key,)) results = cursor.fetchall() if len(results) == 0: irc.error('There is no such factoid.') elif len(results) == 1 or number is True: - (id, _) = results[0] - cursor.execute("""DELETE FROM factoids WHERE key_id=?""", (id,)) - cursor.execute("""DELETE FROM keys WHERE key LIKE ?""", (key,)) - db.commit() + self._deleteRelation(channel, results) irc.replySuccess() else: if number is not None: #results = cursor.fetchall() try: - (_, id) = results[number-1] + arelation = results[number-1] except IndexError: irc.error('Invalid factoid number.') return - cursor.execute("DELETE FROM factoids WHERE id=?", (id,)) - db.commit() + self._deleteRelation(channel, [arelation,]) irc.replySuccess() else: irc.error('%s factoids have that key. ' @@ -394,15 +443,18 @@ class Factoids(callbacks.Plugin, plugins.ChannelDBHandler): """ db = self.getDb(channel) cursor = db.cursor() - cursor.execute("""SELECT fact, key_id FROM factoids + cursor.execute("""SELECT id, key_id, fact_id FROM relations ORDER BY random() LIMIT 3""") results = cursor.fetchall() if len(results) != 0: L = [] - for (factoid, id) in results: - cursor.execute("""SELECT key FROM keys WHERE id=?""", (id,)) - (key,) = cursor.fetchone() + for (relationid, keyid, factid) in results: + cursor.execute("""SELECT keys.key, factoids.fact + FROM keys, factoids + WHERE factoids.id=? AND + keys.id=?""", (factid,keyid,)) + (key,factoid) = cursor.fetchall()[0] L.append('"%s": %s' % (ircutils.bold(key), factoid)) irc.reply('; '.join(L)) else: @@ -418,19 +470,21 @@ class Factoids(callbacks.Plugin, plugins.ChannelDBHandler): """ db = self.getDb(channel) cursor = db.cursor() - cursor.execute("SELECT id, locked FROM keys WHERE key LIKE ?", (key,)) + cursor.execute("SELECT id FROM keys WHERE key LIKE ?", (key,)) results = cursor.fetchall() if len(results) == 0: irc.error('No factoid matches that key.') return - (id, locked) = map(int, results[0]) - cursor.execute("""SELECT added_by, added_at, usage_count FROM factoids - WHERE key_id=? - ORDER BY id""", (id,)) + id = results[0][0] + cursor.execute("""SELECT factoids.added_by, factoids.added_at, factoids.locked, relations.usage_count + FROM factoids, relations + WHERE relations.key_id=? AND + relations.fact_id=factoids.id + ORDER BY relations.id""", (id,)) factoids = cursor.fetchall() L = [] counter = 0 - for (added_by, added_at, usage_count) in factoids: + for (added_by, added_at, locked, usage_count) in factoids: counter += 1 added_at = time.strftime(conf.supybot.reply.format.time(), time.localtime(int(added_at))) @@ -453,9 +507,10 @@ class Factoids(callbacks.Plugin, plugins.ChannelDBHandler): db = self.getDb(channel) cursor = db.cursor() cursor.execute("""SELECT factoids.id, factoids.fact - FROM keys, factoids - WHERE keys.key LIKE ? AND - keys.id=factoids.key_id""", (key,)) + FROM keys, factoids, relations + WHERE keys.key LIKE ? AND + keys.id=relations.key_id AND + factoids.id=relations.fact_id""", (key,)) results = cursor.fetchall() if len(results) == 0: irc.error(format('I couldn\'t find any key %q', key)) @@ -491,7 +546,8 @@ class Factoids(callbacks.Plugin, plugins.ChannelDBHandler): target = 'factoids.fact' if 'factoids' not in tables: tables.append('factoids') - criteria.append('factoids.key_id=keys.id') + tables.append('relations') + criteria.append('factoids.id=relations.fact_id AND keys.id=relations.key_id') elif option == 'regexp': criteria.append('%s(TARGET)' % predicateName) def p(s, r=arg): @@ -505,7 +561,10 @@ class Factoids(callbacks.Plugin, plugins.ChannelDBHandler): sql = """SELECT keys.key FROM %s WHERE %s""" % \ (', '.join(tables), ' AND '.join(criteria)) sql = sql + " ORDER BY keys.key" + print sql sql = sql.replace('TARGET', target) + print sql + print formats cursor.execute(sql, formats) results = cursor.fetchall() if len(results) == 0: From 203308647b74c9a4ca87b2de2a7d5ec6cb4c1f35 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Fri, 2 Apr 2010 00:49:43 -0400 Subject: [PATCH 047/243] add factoids alias function, to link more keys to existing factoids. --- plugins/Factoids/plugin.py | 71 +++++++++++++++++++++++++++++++++++++- 1 file changed, 70 insertions(+), 1 deletion(-) diff --git a/plugins/Factoids/plugin.py b/plugins/Factoids/plugin.py index 28dc665cd..e976b7eda 100644 --- a/plugins/Factoids/plugin.py +++ b/plugins/Factoids/plugin.py @@ -304,7 +304,76 @@ class Factoids(callbacks.Plugin, plugins.ChannelDBHandler): factoids = self._lookupFactoid(channel, key) self._replyFactoids(irc, msg, key, channel, factoids, number) whatis = wrap(whatis, ['channel', many('something')]) - + + def alias(self, irc, msg, args, channel, oldkey, newkey, number): + """[] [] + + Adds a new key for factoid associated with . + is only necessary if there's more than one factoid associated + with . + + The same action can be accomplished by using the 'learn' function with + a new key but an existing (verbatim) factoid content. + """ + def _getNewKey(channel, newkey, arelation): + db = self.getDb(channel) + cursor = db.cursor() + cursor.execute("""SELECT id FROM keys WHERE key=?""", (newkey,)) + newkey_info = cursor.fetchall() + if len(newkey_info) == 1: + # check if we already have the requested relation + cursor.execute("""SELECT id FROM relations WHERE + key_id=? and fact_id=?""", + (arelation[1], arelation[2])) + existentrelation = cursor.fetchall() + if len(existentrelation) != 0: + newkey_info = False + if len(newkey_info) == 0: + cursor.execute("""INSERT INTO keys VALUES (NULL, ?)""", + (newkey,)) + db.commit() + cursor.execute("""SELECT id FROM keys WHERE key=?""", (newkey,)) + newkey_info = cursor.fetchall() + return newkey_info + + db = self.getDb(channel) + cursor = db.cursor() + cursor.execute("""SELECT relations.id, relations.key_id, relations.fact_id + FROM keys, relations + WHERE keys.key=? AND + relations.key_id=keys.id""", (oldkey,)) + results = cursor.fetchall() + if len(results) == 0: + irc.error('No factoid matches that key.') + return + elif len(results) == 1: + newkey_info = _getNewKey(channel, newkey, results[0]) + if newkey_info is not False: + cursor.execute("""INSERT INTO relations VALUES(NULL, ?, ?, ?)""", + (newkey_info[0][0], results[0][2], 0,)) + irc.replySuccess() + else: + irc.error('This key-factoid relationship already exists.') + elif len(results) > 1: + try: + arelation = results[number-1] + except IndexError: + irc.error("That's not a valid number for that key.") + return + except TypeError: + irc.error("This key has more than one factoid associated with " + "it, but you have not provided a number.") + return + newkey_info = _getNewKey(channel, newkey, arelation) + if newkey_info is not False: + cursor.execute("""INSERT INTO relations VALUES(NULL, ?, ?, ?)""", + (newkey_info[0][0], arelation[2], 0,)) + irc.replySuccess() + else: + irc.error('This key-factoid relationship already exists.') + + alias = wrap(alias, ['channel', 'something', 'something', optional('int')]) + def rank(self, irc, msg, args, channel): """[] From a3f7adaa8ccba510d4d2cb48646a01508eda953a Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Fri, 2 Apr 2010 00:51:06 -0400 Subject: [PATCH 048/243] delete leftover debug-prints --- plugins/Factoids/plugin.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/plugins/Factoids/plugin.py b/plugins/Factoids/plugin.py index e976b7eda..324bda662 100644 --- a/plugins/Factoids/plugin.py +++ b/plugins/Factoids/plugin.py @@ -630,10 +630,7 @@ class Factoids(callbacks.Plugin, plugins.ChannelDBHandler): sql = """SELECT keys.key FROM %s WHERE %s""" % \ (', '.join(tables), ' AND '.join(criteria)) sql = sql + " ORDER BY keys.key" - print sql sql = sql.replace('TARGET', target) - print sql - print formats cursor.execute(sql, formats) results = cursor.fetchall() if len(results) == 0: From f988736ca65c2e9f7f681a54f98bbdc055f69c1d Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Fri, 2 Apr 2010 00:55:02 -0400 Subject: [PATCH 049/243] add tests for factoids.alias --- plugins/Factoids/test.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/plugins/Factoids/test.py b/plugins/Factoids/test.py index 8127c801d..ef9ab6cca 100644 --- a/plugins/Factoids/test.py +++ b/plugins/Factoids/test.py @@ -170,6 +170,15 @@ class FactoidsTestCase(ChannelPluginTestCase): conf.supybot.plugins.Factoids.\ replyWhenInvalidCommand.setValue(orig) + def testAlias(self): + self.assertNotError('learn foo as bar') + self.assertNotError('alias foo zoog') + self.assertRegexp('whatis zoog', 'bar') + self.assertNotError('learn foo as snorp') + self.assertError('alias foo gnoop') + self.assertNotError('alias foo gnoop 2') + self.assertRegexp('whatis gnoop', 'snorp') + def testRank(self): self.assertNotError('learn foo as bar') self.assertNotError('learn moo as cow') From b8f8cec6f5aae9d7ad1ad9132be455594002e3a5 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Fri, 2 Apr 2010 01:57:00 -0400 Subject: [PATCH 050/243] enable google translate to autodetect language with 'auto' fromlang. --- plugins/Google/config.py | 3 ++- plugins/Google/plugin.py | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/plugins/Google/config.py b/plugins/Google/config.py index d34f530a1..9b7c1b5b9 100644 --- a/plugins/Google/config.py +++ b/plugins/Google/config.py @@ -76,7 +76,8 @@ class Language(registry.OnlySomeStrings): 'Tamil': 'ta', 'Tagalog': 'tl', 'Telugu': 'te', 'Thai': 'th', 'Tibetan': 'bo', 'Turkish': 'tr', 'Ukranian': 'uk', 'Urdu': 'ur', 'Uzbek': 'uz', - 'Uighur': 'ug', 'Vietnamese': 'vi'} + 'Uighur': 'ug', 'Vietnamese': 'vi', + 'Detect language': 'auto'} validStrings = ['lang_' + s for s in transLangs.values()] validStrings.append('') def normalize(self, s): diff --git a/plugins/Google/plugin.py b/plugins/Google/plugin.py index a78b1d981..28fc90c01 100644 --- a/plugins/Google/plugin.py +++ b/plugins/Google/plugin.py @@ -266,6 +266,8 @@ class Google(callbacks.PluginRegexp): lang.transLangs.keys())) else: toLang = lang.normalize('lang_'+toLang)[5:] + if fromLang == 'auto': + fromLang = '' opts['langpair'] = '%s|%s' % (fromLang, toLang) fd = utils.web.getUrlFd('%s?%s' % (self._gtranslateUrl, urllib.urlencode(opts)), From 34cab7f4c9bebafb121bb61aee54ef378223d24e Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Sat, 3 Apr 2010 23:31:13 -0400 Subject: [PATCH 051/243] add detected source language display for 'auto' google translate, add test for auto translate. --- plugins/Google/plugin.py | 13 ++++++++++++- plugins/Google/test.py | 1 + 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/plugins/Google/plugin.py b/plugins/Google/plugin.py index 28fc90c01..1fd41c263 100644 --- a/plugins/Google/plugin.py +++ b/plugins/Google/plugin.py @@ -276,7 +276,18 @@ class Google(callbacks.PluginRegexp): fd.close() if json['responseStatus'] != 200: raise callbacks.Error, 'We broke The Google!' - irc.reply(json['responseData']['translatedText'].encode('utf-8')) + if fromLang != '': + irc.reply(json['responseData']['translatedText'].encode('utf-8')) + else: + detected_language = json['responseData']['detectedSourceLanguage'] + try: + long_lang_name = [k for k,v in lang.transLangs.iteritems() if v == detected_language][0] + except IndexError: #just in case google adds langs we don't know about + long_lang_name = detected_language + responsestring = "(Detected source language: %s) %s" % \ + (long_lang_name, + json['responseData']['translatedText'].encode('utf-8')) + irc.reply(responsestring) translate = wrap(translate, ['something', 'to', 'something', 'text']) def googleSnarfer(self, irc, msg, match): diff --git a/plugins/Google/test.py b/plugins/Google/test.py index 695e95037..ca9b78207 100644 --- a/plugins/Google/test.py +++ b/plugins/Google/test.py @@ -59,6 +59,7 @@ class GoogleTestCase(ChannelPluginTestCase): def testTranslate(self): self.assertRegexp('translate en es hello world', 'mundo') + self.assertRegexp('translate auto en ciao', 'Italian.*hello') def testCalcDoesNotHaveExtraSpaces(self): self.assertNotRegexp('google calc 1000^2', r'\s+,\s+') From f4d47876d490238a963cdb055831f4bf2f557427 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Sun, 4 Apr 2010 01:12:50 -0400 Subject: [PATCH 052/243] fix some encoding error for non-ascii langs --- plugins/Google/plugin.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/Google/plugin.py b/plugins/Google/plugin.py index 1fd41c263..cbb95978f 100644 --- a/plugins/Google/plugin.py +++ b/plugins/Google/plugin.py @@ -279,14 +279,14 @@ class Google(callbacks.PluginRegexp): if fromLang != '': irc.reply(json['responseData']['translatedText'].encode('utf-8')) else: - detected_language = json['responseData']['detectedSourceLanguage'] + detected_language = json['responseData']['detectedSourceLanguage'].encode('utf-8') + translation = json['responseData']['translatedText'].encode('utf-8') try: long_lang_name = [k for k,v in lang.transLangs.iteritems() if v == detected_language][0] except IndexError: #just in case google adds langs we don't know about long_lang_name = detected_language responsestring = "(Detected source language: %s) %s" % \ - (long_lang_name, - json['responseData']['translatedText'].encode('utf-8')) + (long_lang_name, translation) irc.reply(responsestring) translate = wrap(translate, ['something', 'to', 'something', 'text']) From 5d9273cd5a80e4d57cc8e69f55f4de4271db724c Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Wed, 7 Apr 2010 12:33:28 -0400 Subject: [PATCH 053/243] add damerau-levenshtein distance to supybot.utils.seq use it in factoids invalid command to match possible typos write tests for same. --- plugins/Factoids/plugin.py | 42 ++++++++++++++++++++++++++++-------- plugins/Factoids/test.py | 4 ++++ src/utils/seq.py | 44 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 81 insertions(+), 9 deletions(-) diff --git a/plugins/Factoids/plugin.py b/plugins/Factoids/plugin.py index 324bda662..707e72023 100644 --- a/plugins/Factoids/plugin.py +++ b/plugins/Factoids/plugin.py @@ -52,6 +52,9 @@ try: except ImportError: from pysqlite2 import dbapi2 as sqlite3 # for python2.4 +import re +from supybot.utils.seq import dameraulevenshtein + # these are needed cuz we are overriding getdb import threading import supybot.world as world @@ -217,15 +220,37 @@ class Factoids(callbacks.Plugin, plugins.ChannelDBHandler): #return [t[0] for t in cursor.fetchall()] def _searchFactoid(self, channel, key): + """Try to typo-match input to possible factoids. + + Assume first letter is correct, to reduce processing time. + First, try a simple wildcard search. + If that fails, use the Damerau-Levenshtein edit-distance metric. + """ + # if you made a typo in a two-character key, boo on you. + if len(key) < 3: + return [] + db = self.getDb(channel) cursor = db.cursor() - key = '%' + key + '%' - cursor.execute("""SELECT key FROM keys - WHERE key LIKE ? - LIMIT 20""", (key,)) - return cursor.fetchall() - - + cursor.execute("""SELECT key FROM keys WHERE key LIKE ?""", ('%' + key + '%',)) + wildcardkeys = cursor.fetchall() + if len(wildcardkeys) > 0: + return [line[0] for line in wildcardkeys] + + cursor.execute("""SELECT key FROM keys WHERE key LIKE ?""", (key[0] + '%',)) + flkeys = cursor.fetchall() + if len(flkeys) == 0: + return [] + flkeys = [line[0] for line in flkeys] + dl_metrics = [dameraulevenshtein(key, sourcekey) for sourcekey in flkeys] + dict_metrics = dict(zip(flkeys, dl_metrics)) + if min(dl_metrics) <= 2: + return [key for key,item in dict_metrics.iteritems() if item <= 2] + if min(dl_metrics) <= 3: + return [key for key,item in dict_metrics.iteritems() if item <= 3] + + return [] + def _updateRank(self, channel, factoids): if self.registryValue('keepRankInfo', channel): db = self.getDb(channel) @@ -281,9 +306,8 @@ class Factoids(callbacks.Plugin, plugins.ChannelDBHandler): else: if self.registryValue('replyWhenInvalidCommandSearchKeys'): factoids = self._searchFactoid(channel, key) - #print 'searchfactoids result:', factoids, '>' if factoids: - keylist = ["'%s'" % (fact[0],) for fact in factoids] + keylist = ["'%s'" % (fact,) for fact in factoids] keylist = ', '.join(keylist) irc.reply("I do not know about '%s', but I do know about these similar topics: %s" % (key, keylist)) diff --git a/plugins/Factoids/test.py b/plugins/Factoids/test.py index ef9ab6cca..1dddfb19c 100644 --- a/plugins/Factoids/test.py +++ b/plugins/Factoids/test.py @@ -165,6 +165,10 @@ class FactoidsTestCase(ChannelPluginTestCase): self.assertRegexp('foo', 'bar') self.assertNotError('learn mooz as cowz') self.assertRegexp('moo', 'mooz') + self.assertRegexp('mzo', 'mooz') + self.assertRegexp('moz', 'mooz') + self.assertNotError('learn moped as pretty fast') + self.assertRegexp('moe', 'mooz.*moped') self.assertError('nosuchthing') finally: conf.supybot.plugins.Factoids.\ diff --git a/src/utils/seq.py b/src/utils/seq.py index 8df15b226..67b0272be 100644 --- a/src/utils/seq.py +++ b/src/utils/seq.py @@ -45,7 +45,51 @@ def renumerate(L): for i in xrange(len(L)-1, -1, -1): yield (i, L[i]) +def dameraulevenshtein(seq1, seq2): + """Calculate the Damerau-Levenshtein distance between sequences. + This distance is the number of additions, deletions, substitutions, + and transpositions needed to transform the first sequence into the + second. Although generally used with strings, any sequences of + comparable objects will work. + + Transpositions are exchanges of *consecutive* characters; all other + operations are self-explanatory. + + This implementation is O(N*M) time and O(M) space, for N and M the + lengths of the two sequences. + + >>> dameraulevenshtein('ba', 'abc') + 2 + >>> dameraulevenshtein('fee', 'deed') + 2 + + It works with arbitrary sequences too: + >>> dameraulevenshtein('abcd', ['b', 'a', 'c', 'd', 'e']) + 2 + """ + # codesnippet:D0DE4716-B6E6-4161-9219-2903BF8F547F + # Conceptually, this is based on a len(seq1) + 1 * len(seq2) + 1 matrix. + # However, only the current and two previous rows are needed at once, + # so we only store those. + # Sourced from http://mwh.geek.nz/2009/04/26/python-damerau-levenshtein-distance/ + oneago = None + thisrow = range(1, len(seq2) + 1) + [0] + for x in xrange(len(seq1)): + # Python lists wrap around for negative indices, so put the + # leftmost column at the *end* of the list. This matches with + # the zero-indexed strings and saves extra calculation. + twoago, oneago, thisrow = oneago, thisrow, [0] * len(seq2) + [x + 1] + for y in xrange(len(seq2)): + delcost = oneago[y] + 1 + addcost = thisrow[y - 1] + 1 + subcost = oneago[y - 1] + (seq1[x] != seq2[y]) + thisrow[y] = min(delcost, addcost, subcost) + # This block deals with transpositions + if (x > 0 and y > 0 and seq1[x] == seq2[y - 1] + and seq1[x-1] == seq2[y] and seq1[x] != seq2[y]): + thisrow[y] = min(thisrow[y], twoago[y - 2] + 1) + return thisrow[len(seq2) - 1] # vim:set shiftwidth=4 softtabstop=4 expandtab textwidth=79: From afe1a2124d30f1f1785d0f9b85ac4e58150c64bb Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Thu, 8 Apr 2010 00:04:44 -0400 Subject: [PATCH 054/243] add a random-synonym getting function to Dict, add tests for it. using the moby-thes database from dict.org. --- plugins/Dict/plugin.py | 29 +++++++++++++++++++++++++++++ plugins/Dict/test.py | 5 +++++ 2 files changed, 34 insertions(+) diff --git a/plugins/Dict/plugin.py b/plugins/Dict/plugin.py index 1ceeafec4..1c0085926 100644 --- a/plugins/Dict/plugin.py +++ b/plugins/Dict/plugin.py @@ -36,6 +36,8 @@ from supybot.commands import * import supybot.ircutils as ircutils import supybot.callbacks as callbacks +import random + try: dictclient = utils.python.universalImport('dictclient', 'local.dictclient') except ImportError: @@ -123,6 +125,33 @@ class Dict(callbacks.Plugin): irc.reply(s) dict = wrap(dict, [many('something')]) + def synonym(self, irc, msg, args, words): + """ [ ...] + Gets a random synonym from the Moby Thesaurus (moby-thes) database. + + If given many words, gets a random synonym for each of them. + + Quote phrases to have them treated as one lookup word. + """ + try: + server = conf.supybot.plugins.Dict.server() + conn = dictclient.Connection(server) + except socket.error, e: + irc.error(utils.web.strError(e), Raise=True) + + dictionary = 'moby-thes' + response = [] + for word in words: + definitions = conn.define(dictionary, word) + if not definitions: + asynonym = word + else: + defstr = definitions[0].getdefstr() + synlist = ' '.join(defstr.split('\n')).split(': ', 1)[1].split(',') + asynonym = random.choice(synlist).strip() + response.append(asynonym) + irc.reply(' '.join(response)) + synonym = wrap(synonym, [many('something')]) Class = Dict diff --git a/plugins/Dict/test.py b/plugins/Dict/test.py index 81c9c52e5..b853b21e8 100644 --- a/plugins/Dict/test.py +++ b/plugins/Dict/test.py @@ -43,5 +43,10 @@ class DictTestCase(PluginTestCase): def testRandomDictionary(self): self.assertNotError('random') self.assertNotError('dict [random] moo') + + def testSynonym(self): + self.assertNotError('synonym stuff') + self.assertNotError('synonym someone goes home') + self.assertRegexp('synonym nanotube', 'nanotube') # vim:set shiftwidth=4 softtabstop=4 expandtab textwidth=79: From 01c8f3445a1a295ce283e4670d6d6cfa8f169611 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Thu, 8 Apr 2010 20:02:39 -0400 Subject: [PATCH 055/243] create conditional plugin with associated tests. includes string and numeric comparisons, simple string matching. --- plugins/Conditional/README.txt | 1 + plugins/Conditional/__init__.py | 66 ++++++ plugins/Conditional/config.py | 49 +++++ plugins/Conditional/local/__init__.py | 1 + plugins/Conditional/plugin.py | 276 ++++++++++++++++++++++++++ plugins/Conditional/test.py | 143 +++++++++++++ 6 files changed, 536 insertions(+) create mode 100644 plugins/Conditional/README.txt create mode 100644 plugins/Conditional/__init__.py create mode 100644 plugins/Conditional/config.py create mode 100644 plugins/Conditional/local/__init__.py create mode 100644 plugins/Conditional/plugin.py create mode 100644 plugins/Conditional/test.py diff --git a/plugins/Conditional/README.txt b/plugins/Conditional/README.txt new file mode 100644 index 000000000..d60b47a97 --- /dev/null +++ b/plugins/Conditional/README.txt @@ -0,0 +1 @@ +Insert a description of your plugin here, with any notes, etc. about using it. diff --git a/plugins/Conditional/__init__.py b/plugins/Conditional/__init__.py new file mode 100644 index 000000000..8a48b27be --- /dev/null +++ b/plugins/Conditional/__init__.py @@ -0,0 +1,66 @@ +### +# Copyright (c) 2010, Daniel Folkinshteyn +# 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. + +### + +""" +Add a description of the plugin (to be presented to the user inside the wizard) +here. This should describe *what* the plugin does. +""" + +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__ = "" + +# 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__ = {} + +# This is a url where the most recent plugin package can be downloaded. +__url__ = '' # 'http://supybot.com/Members/yourname/Conditional/download' + +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=4 expandtab textwidth=79: diff --git a/plugins/Conditional/config.py b/plugins/Conditional/config.py new file mode 100644 index 000000000..ab69968bd --- /dev/null +++ b/plugins/Conditional/config.py @@ -0,0 +1,49 @@ +### +# Copyright (c) 2010, Daniel Folkinshteyn +# 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('Conditional', True) + + +Conditional = conf.registerPlugin('Conditional') +# This is where your configuration variables (if any) should go. For example: +# conf.registerGlobalValue(Conditional, 'someConfigVariableName', +# registry.Boolean(False, """Help for someConfigVariableName.""")) + + +# vim:set shiftwidth=4 tabstop=4 expandtab textwidth=79: diff --git a/plugins/Conditional/local/__init__.py b/plugins/Conditional/local/__init__.py new file mode 100644 index 000000000..e86e97b86 --- /dev/null +++ b/plugins/Conditional/local/__init__.py @@ -0,0 +1 @@ +# Stub so local is a module, used for third-party modules diff --git a/plugins/Conditional/plugin.py b/plugins/Conditional/plugin.py new file mode 100644 index 000000000..347b50f7a --- /dev/null +++ b/plugins/Conditional/plugin.py @@ -0,0 +1,276 @@ +### +# Copyright (c) 2010, Daniel Folkinshteyn +# 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.utils as utils +from supybot.commands import * +import supybot.plugins as plugins +import supybot.ircutils as ircutils +import supybot.callbacks as callbacks + +import re + +# builtin any is overwritten by callbacks... and python2.4 doesn't have it +def _any(iterable): + for element in iterable: + if element: + return True + return False +# for consistency with above, and for python2.4 +def _all(iterable): + for element in iterable: + if not element: + return False + return True + + +class Conditional(callbacks.Plugin): + """Add the help for "@plugin help Conditional" here + This should describe *how* to use this plugin.""" + threaded = True + def __init__(self, irc): + callbacks.Plugin.__init__(self, irc) + + def _runCommandFunction(self, irc, msg, command): + """Run a command from message, as if command was sent over IRC.""" + tokens = callbacks.tokenize(command) + try: + self.Proxy(irc.irc, msg, tokens) + except Exception, e: + log.exception('Uncaught exception in requested function:') + + def cif(self, irc, msg, args, condition, ifcommand, elsecommand): + """ + + Runs if evaluates to true, runs + if it evaluates to false. + + Use other logical operators defined in this plugin and command nesting + to your advantage here. + """ + if condition: + self._runCommandFunction(irc, msg, ifcommand) + else: + self._runCommandFunction(irc, msg, elsecommand) + irc.noReply() + cif = wrap(cif, ['boolean', 'something', 'something']) + + def cand(self, irc, msg, args, conds): + """ [ ... ] + + Returns true if all conditions supplied evaluate to true. + """ + if _all(conds): + irc.reply("true") + else: + irc.reply("false") + cand = wrap(cand, [many('boolean'),]) + + def cor(self, irc, msg, args, conds): + """ [ ... ] + + Returns true if any one of conditions supplied evaluates to true. + """ + if _any(conds): + irc.reply("true") + else: + irc.reply("false") + cor = wrap(cor, [many('boolean'),]) + + def cxor(self, irc, msg, args, conds): + """ [ ... ] + + Returns true if only one of conditions supplied evaluates to true. + """ + if sum(conds) == 1: + irc.reply("true") + else: + irc.reply("false") + cxor = wrap(cxor, [many('boolean'),]) + + def ceq(self, irc, msg, args, item1, item2): + """ + + Does a string comparison on and . + Returns true if they are equal. + """ + if item1 == item2: + irc.reply('true') + else: + irc.reply('false') + ceq = wrap(ceq, ['something', 'something']) + + def ne(self, irc, msg, args, item1, item2): + """ + + Does a string comparison on and . + Returns true if they are not equal. + """ + if item1 != item2: + irc.reply('true') + else: + irc.reply('false') + ne = wrap(ne, ['something', 'something']) + + def gt(self, irc, msg, args, item1, item2): + """ + + Does a string comparison on and . + Returns true if they is greater than . + """ + if item1 > item2: + irc.reply('true') + else: + irc.reply('false') + gt = wrap(gt, ['something', 'something']) + + def ge(self, irc, msg, args, item1, item2): + """ + + Does a string comparison on and . + Returns true if is greater than or equal to . + """ + if item1 >= item2: + irc.reply('true') + else: + irc.reply('false') + ge = wrap(ge, ['something', 'something']) + + def lt(self, irc, msg, args, item1, item2): + """ + + Does a string comparison on and . + Returns true if is less than . + """ + if item1 < item2: + irc.reply('true') + else: + irc.reply('false') + lt = wrap(lt, ['something', 'something']) + + def le(self, irc, msg, args, item1, item2): + """ + + Does a string comparison on and . + Returns true if is less than or equal to . + """ + if item1 <= item2: + irc.reply('true') + else: + irc.reply('false') + le = wrap(le, ['something', 'something']) + + def match(self, irc, msg, args, item1, item2): + """ + + Determines if is a substring of . + Returns true if is contained in . + """ + if item2.find(item1) != -1: + irc.reply('true') + else: + irc.reply('false') + match = wrap(match, ['something', 'something']) + + def nceq(self, irc, msg, args, item1, item2): + """ + + Does a numeric comparison on and . + Returns true if they are equal. + """ + if item1 == item2: + irc.reply('true') + else: + irc.reply('false') + nceq = wrap(nceq, ['float', 'float']) + + def nne(self, irc, msg, args, item1, item2): + """ + + Does a numeric comparison on and . + Returns true if they are not equal. + """ + if item1 != item2: + irc.reply('true') + else: + irc.reply('false') + nne = wrap(nne, ['float', 'float']) + + def ngt(self, irc, msg, args, item1, item2): + """ + + Does a numeric comparison on and . + Returns true if they is greater than . + """ + if item1 > item2: + irc.reply('true') + else: + irc.reply('false') + ngt = wrap(ngt, ['float', 'float']) + + def nge(self, irc, msg, args, item1, item2): + """ + + Does a numeric comparison on and . + Returns true if is greater than or equal to . + """ + if item1 >= item2: + irc.reply('true') + else: + irc.reply('false') + nge = wrap(nge, ['float', 'float']) + + def nlt(self, irc, msg, args, item1, item2): + """ + + Does a numeric comparison on and . + Returns true if is less than . + """ + if item1 < item2: + irc.reply('true') + else: + irc.reply('false') + nlt = wrap(nlt, ['float', 'float']) + + def nle(self, irc, msg, args, item1, item2): + """ + + Does a numeric comparison on and . + Returns true if is less than or equal to . + """ + if item1 <= item2: + irc.reply('true') + else: + irc.reply('false') + nle = wrap(nle, ['float', 'float']) + +Class = Conditional + + +# vim:set shiftwidth=4 softtabstop=4 expandtab textwidth=79: diff --git a/plugins/Conditional/test.py b/plugins/Conditional/test.py new file mode 100644 index 000000000..9dc856b94 --- /dev/null +++ b/plugins/Conditional/test.py @@ -0,0 +1,143 @@ +### +# Copyright (c) 2010, Daniel Folkinshteyn +# 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 ConditionalTestCase(PluginTestCase): + plugins = ('Conditional','Utilities',) + + def testCif(self): + self.assertError('cif stuff') + self.assertRegexp('cif [ceq bla bla] "echo moo" "echo foo"', 'moo') + self.assertRegexp('cif [ceq bla bar] "echo moo" "echo foo"', 'foo') + self.assertRegexp('cif [cand [ceq bla bla] [ne soo boo]] "echo moo" "echo foo"', 'moo') + self.assertRegexp('cif [ceq [echo $nick] "test"] "echo yay" "echo nay"', 'yay') + + def testCand(self): + self.assertRegexp('cand true true', 'true') + self.assertRegexp('cand false true', 'false') + self.assertRegexp('cand true false', 'false') + self.assertRegexp('cand false false', 'false') + self.assertRegexp('cand true true true', 'true') + + def testCor(self): + self.assertRegexp('cor true true', 'true') + self.assertRegexp('cor false true', 'true') + self.assertRegexp('cor true false', 'true') + self.assertRegexp('cor false false', 'false') + self.assertRegexp('cor true true true', 'true') + + def testCxor(self): + self.assertRegexp('cxor true true', 'false') + self.assertRegexp('cxor false true', 'true') + self.assertRegexp('cxor true false', 'true') + self.assertRegexp('cxor false false', 'false') + self.assertRegexp('cxor true true true', 'false') + + def testCeq(self): + self.assertRegexp('ceq bla bla', 'true') + self.assertRegexp('ceq bla moo', 'false') + self.assertError('ceq bla bla bla') + + def testNe(self): + self.assertRegexp('ne bla bla', 'false') + self.assertRegexp('ne bla moo', 'true') + self.assertError('ne bla bla bla') + + def testGt(self): + self.assertRegexp('gt bla bla', 'false') + self.assertRegexp('gt bla moo', 'false') + self.assertRegexp('gt moo bla', 'true') + self.assertError('gt bla bla bla') + + def testGe(self): + self.assertRegexp('ge bla bla', 'true') + self.assertRegexp('ge bla moo', 'false') + self.assertRegexp('ge moo bla', 'true') + self.assertError('ge bla bla bla') + + def testLt(self): + self.assertRegexp('lt bla bla', 'false') + self.assertRegexp('lt bla moo', 'true') + self.assertRegexp('lt moo bla', 'false') + self.assertError('lt bla bla bla') + + def testLe(self): + self.assertRegexp('le bla bla', 'true') + self.assertRegexp('le bla moo', 'true') + self.assertRegexp('le moo bla', 'false') + self.assertError('le bla bla bla') + + def testMatch(self): + self.assertRegexp('match bla mooblafoo', 'true') + self.assertRegexp('match bla mooblfoo', 'false') + self.assertError('match bla bla stuff') + + def testNceq(self): + self.assertRegexp('nceq 10.0 10', 'true') + self.assertRegexp('nceq 4 5', 'false') + self.assertError('nceq 1 2 3') + self.assertError('nceq bla 1') + + def testNne(self): + self.assertRegexp('nne 1 1', 'false') + self.assertRegexp('nne 2.2 3', 'true') + self.assertError('nne 1 2 3') + self.assertError('nne bla 3') + + def testNgt(self): + self.assertRegexp('ngt 3 3', 'false') + self.assertRegexp('ngt 2 3', 'false') + self.assertRegexp('ngt 4 3', 'true') + self.assertError('ngt 1 2 3') + self.assertError('ngt 3 bla') + + def testNge(self): + self.assertRegexp('nge 3 3', 'true') + self.assertRegexp('nge 3 4', 'false') + self.assertRegexp('nge 5 4.3', 'true') + self.assertError('nge 3 4.5 4') + self.assertError('nge 45 bla') + + def testNlt(self): + self.assertRegexp('nlt 3 3', 'false') + self.assertRegexp('nlt 3 4.5', 'true') + self.assertRegexp('nlt 5 3', 'false') + self.assertError('nlt 2 3 4') + self.assertError('nlt bla bla') + + def testNle(self): + self.assertRegexp('nle 2 2', 'true') + self.assertRegexp('nle 2 3.5', 'true') + self.assertRegexp('nle 4 3', 'false') + self.assertError('nle 3 4 5') + self.assertError('nle 1 bla') + +# vim:set shiftwidth=4 tabstop=4 expandtab textwidth=79: From 2125e6db8e4e8e31e0df52ef80936030a7045dfa Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Fri, 9 Apr 2010 00:45:14 -0400 Subject: [PATCH 056/243] add nick validation to later tell this avoids plugging the later db with messages for bogus nicks --- plugins/Later/plugin.py | 28 +++++++++++++++++++++++++--- plugins/Later/test.py | 5 +++++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/plugins/Later/plugin.py b/plugins/Later/plugin.py index 3babf6365..7e816c6ad 100644 --- a/plugins/Later/plugin.py +++ b/plugins/Later/plugin.py @@ -39,7 +39,6 @@ import supybot.ircmsgs as ircmsgs import supybot.ircutils as ircutils import supybot.callbacks as callbacks - class Later(callbacks.Plugin): """Used to do things later; currently, it only allows the sending of nick-based notes. Do note (haha!) that these notes are *not* private @@ -99,7 +98,26 @@ class Later(callbacks.Plugin): if '?' in nick or '*' in nick and nick not in self.wildcards: self.wildcards.append(nick) self._flushNotes() - + + def _validateNick(self, nick): + """Validate nick according to the IRC RFC 2812 spec. + + Reference: http://tools.ietf.org/rfcmarkup?doc=2812#section-2.3.1 + + Some irc clients' tab-completion feature appends 'address' characters + to nick, such as ':' or ','. We try correcting for that by trimming + a char off the end. + + If nick incorrigibly invalid, return False, otherwise, + return (possibly trimmed) nick. + """ + if not ircutils.isNick(nick, strictRfc=True): + if not ircutils.isNick(nick[:-1], strictRfc=True): + return False + else: + return nick[:-1] + return nick + def tell(self, irc, msg, args, nick, text): """ @@ -110,8 +128,12 @@ class Later(callbacks.Plugin): if ircutils.strEqual(nick, irc.nick): irc.error('I can\'t send notes to myself.') return + validnick = self._validateNick(nick) + if validnick is False: + irc.error('That is an invalid IRC nick. Please check your input.') + return try: - self._addNote(nick, msg.nick, text) + self._addNote(validnick, msg.nick, text) irc.replySuccess() except ValueError: irc.error('That person\'s message queue is already full.') diff --git a/plugins/Later/test.py b/plugins/Later/test.py index dc3a95069..d91070620 100644 --- a/plugins/Later/test.py +++ b/plugins/Later/test.py @@ -43,6 +43,11 @@ class LaterTestCase(PluginTestCase): self.assertNotRegexp('later notes', 'bar.*foo') self.assertRegexp('later notes', 'foo') + def testNickValidation(self): + self.assertError('later tell 1foo bar') + self.assertError('later tell foo$moo zoob') + self.assertNotError('later tell foo: baz') + self.assertRegexp('later notes', 'foo\.') # vim:set shiftwidth=4 softtabstop=4 expandtab textwidth=79: From 7ac4911f7857aa99cebb59ab41bb9754cd5fd3b0 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Fri, 9 Apr 2010 13:34:39 -0400 Subject: [PATCH 057/243] make later plugin send waiting messages on user join also, not just on privmsg. --- plugins/Later/plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/Later/plugin.py b/plugins/Later/plugin.py index 7e816c6ad..92e7298a2 100644 --- a/plugins/Later/plugin.py +++ b/plugins/Later/plugin.py @@ -198,7 +198,7 @@ class Later(callbacks.Plugin): def _formatNote(self, when, whence, note): return 'Sent %s: <%s> %s' % (self._timestamp(when), whence, note) - + doJoin = doPrivmsg Class = Later From b115e0d56f4eb6669d47b0ce5d63ea428cbfc4e4 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Fri, 9 Apr 2010 15:56:16 -0400 Subject: [PATCH 058/243] change Topic to have a default required capability set, for all write operations. by default, now only allows chanops, and users with admin or channel,op capability to change topics --- plugins/Topic/config.py | 9 ++++- plugins/Topic/plugin.py | 79 +++++++++++++++++++++++++++++++++++++++++ plugins/Topic/test.py | 20 +++++++++-- 3 files changed, 105 insertions(+), 3 deletions(-) diff --git a/plugins/Topic/config.py b/plugins/Topic/config.py index 6ab0ef3fc..f07870554 100644 --- a/plugins/Topic/config.py +++ b/plugins/Topic/config.py @@ -63,7 +63,14 @@ conf.registerGroup(Topic, 'undo') conf.registerChannelValue(Topic.undo, 'max', registry.NonNegativeInteger(10, """Determines the number of previous topics to keep around in case the undo command is called.""")) - +conf.registerChannelValue(Topic, 'requireManageCapability', + registry.String('admin; channel,op', + """Determines the + capabilities required (if any) to make any topic changes, + (everything except for read-only operations). Use 'channel,capab' for + channel-level capabilities. + Note that absence of an explicit anticapability means user has + capability.""")) # vim:set shiftwidth=4 softtabstop=4 expandtab textwidth=79: diff --git a/plugins/Topic/plugin.py b/plugins/Topic/plugin.py index ac9fef272..3c843aa6d 100644 --- a/plugins/Topic/plugin.py +++ b/plugins/Topic/plugin.py @@ -37,6 +37,7 @@ import supybot.ircmsgs as ircmsgs import supybot.plugins as plugins import supybot.ircutils as ircutils import supybot.callbacks as callbacks +import supybot.ircdb as ircdb def canChangeTopic(irc, msg, args, state): assert not state.channel @@ -161,6 +162,30 @@ class Topic(callbacks.Plugin): irc.queueMsg(ircmsgs.topic(channel, newTopic)) irc.noReply() + def _checkManageCapabilities(self, irc, msg, channel): + """Check if the user has any of the required capabilities to manage + the channel topic. + + The list of required capabilities is in requireManageCapability + channel config. + + Also allow if the user is a chanop. Since he can change the topic + manually anyway. + """ + c = irc.state.channels[channel] + if msg.nick in c.ops: + return True + capabilities = self.registryValue('requireManageCapability') + if capabilities: + for capability in re.split(r'\s*;\s*', capabilities): + if capability.startswith('channel,'): + capability = ircdb.makeChannelCapability(channel, capability[8:]) + if capability and ircdb.checkCapability(msg.prefix, capability): + return True + return False + else: + return True + def doJoin(self, irc, msg): if ircutils.strEqual(msg.nick, irc.nick): # We're joining a channel, let's watch for the topic. @@ -189,6 +214,9 @@ class Topic(callbacks.Plugin): Adds to the topics for . is only necessary if the message isn't sent in the channel itself. """ + if not self._checkManageCapabilities(irc, msg, channel): + capabilities = self.registryValue('requireManageCapability') + irc.errorNoCapability(capabilities, Raise=True) topics = self._splitTopic(irc.state.getTopic(channel), channel) topics.append(topic) self._sendTopics(irc, channel, topics) @@ -202,6 +230,9 @@ class Topic(callbacks.Plugin): is only necessary if the message isn't sent in the channel itself. """ + if not self._checkManageCapabilities(irc, msg, channel): + capabilities = self.registryValue('requireManageCapability') + irc.errorNoCapability(capabilities, Raise=True) topics = self._splitTopic(irc.state.getTopic(channel), channel) topics.append(topic) self._sendTopics(irc, channel, topics, fit=True) @@ -212,6 +243,9 @@ class Topic(callbacks.Plugin): Replaces topic with . """ + if not self._checkManageCapabilities(irc, msg, channel): + capabilities = self.registryValue('requireManageCapability') + irc.errorNoCapability(capabilities, Raise=True) topics = self._splitTopic(irc.state.getTopic(channel), channel) topics[i] = topic self._sendTopics(irc, channel, topics) @@ -224,6 +258,9 @@ class Topic(callbacks.Plugin): currently on . is only necessary if the message isn't sent in the channel itself. """ + if not self._checkManageCapabilities(irc, msg, channel): + capabilities = self.registryValue('requireManageCapability') + irc.errorNoCapability(capabilities, Raise=True) topics = self._splitTopic(irc.state.getTopic(channel), channel) topics.insert(0, topic) self._sendTopics(irc, channel, topics) @@ -235,6 +272,9 @@ class Topic(callbacks.Plugin): Shuffles the topics in . is only necessary if the message isn't sent in the channel itself. """ + if not self._checkManageCapabilities(irc, msg, channel): + capabilities = self.registryValue('requireManageCapability') + irc.errorNoCapability(capabilities, Raise=True) topics = self._splitTopic(irc.state.getTopic(channel), channel) if len(topics) == 0 or len(topics) == 1: irc.error('I can\'t shuffle 1 or fewer topics.', Raise=True) @@ -255,6 +295,9 @@ class Topic(callbacks.Plugin): is only necessary if the message isn't sent in the channel itself. """ + if not self._checkManageCapabilities(irc, msg, channel): + capabilities = self.registryValue('requireManageCapability') + irc.errorNoCapability(capabilities, Raise=True) topics = self._splitTopic(irc.state.getTopic(channel), channel) num = len(topics) if num == 0 or num == 1: @@ -290,6 +333,9 @@ class Topic(callbacks.Plugin): index into the topics. is only necessary if the message isn't sent in the channel itself. """ + if not self._checkManageCapabilities(irc, msg, channel): + capabilities = self.registryValue('requireManageCapability') + irc.errorNoCapability(capabilities, Raise=True) topics = self._splitTopic(irc.state.getTopic(channel), channel) irc.reply(topics[number]) get = wrap(get, ['inChannel', 'topicNumber']) @@ -303,6 +349,9 @@ class Topic(callbacks.Plugin): s/regexp/replacement/flags. is only necessary if the message isn't sent in the channel itself. """ + if not self._checkManageCapabilities(irc, msg, channel): + capabilities = self.registryValue('requireManageCapability') + irc.errorNoCapability(capabilities, Raise=True) topics = self._splitTopic(irc.state.getTopic(channel), channel) topics[number] = replacer(topics[number]) self._sendTopics(irc, channel, topics) @@ -315,6 +364,9 @@ class Topic(callbacks.Plugin): sets the entire topic. is only necessary if the message isn't sent in the channel itself. """ + if not self._checkManageCapabilities(irc, msg, channel): + capabilities = self.registryValue('requireManageCapability') + irc.errorNoCapability(capabilities, Raise=True) if number is not None: topics = self._splitTopic(irc.state.getTopic(channel), channel) topics[number] = topic @@ -333,6 +385,9 @@ class Topic(callbacks.Plugin): to topics starting the from the end of the topic. is only necessary if the message isn't sent in the channel itself. """ + if not self._checkManageCapabilities(irc, msg, channel): + capabilities = self.registryValue('requireManageCapability') + irc.errorNoCapability(capabilities, Raise=True) topics = self._splitTopic(irc.state.getTopic(channel), channel) topic = topics.pop(number) self._sendTopics(irc, channel, topics) @@ -344,6 +399,9 @@ class Topic(callbacks.Plugin): Locks the topic (sets the mode +t) in . is only necessary if the message isn't sent in the channel itself. """ + if not self._checkManageCapabilities(irc, msg, channel): + capabilities = self.registryValue('requireManageCapability') + irc.errorNoCapability(capabilities, Raise=True) irc.queueMsg(ircmsgs.mode(channel, '+t')) irc.noReply() lock = wrap(lock, ['channel', ('haveOp', 'lock the topic')]) @@ -354,6 +412,9 @@ class Topic(callbacks.Plugin): Locks the topic (sets the mode +t) in . is only necessary if the message isn't sent in the channel itself. """ + if not self._checkManageCapabilities(irc, msg, channel): + capabilities = self.registryValue('requireManageCapability') + irc.errorNoCapability(capabilities, Raise=True) irc.queueMsg(ircmsgs.mode(channel, '-t')) irc.noReply() unlock = wrap(unlock, ['channel', ('haveOp', 'unlock the topic')]) @@ -364,6 +425,9 @@ class Topic(callbacks.Plugin): Restores the topic to the last topic set by the bot. is only necessary if the message isn't sent in the channel itself. """ + if not self._checkManageCapabilities(irc, msg, channel): + capabilities = self.registryValue('requireManageCapability') + irc.errorNoCapability(capabilities, Raise=True) try: topics = self.lastTopics[channel] except KeyError: @@ -379,6 +443,9 @@ class Topic(callbacks.Plugin): set it. is only necessary if the message isn't sent in the channel itself. """ + if not self._checkManageCapabilities(irc, msg, channel): + capabilities = self.registryValue('requireManageCapability') + irc.errorNoCapability(capabilities, Raise=True) self._addRedo(channel, self._getUndo(channel)) # current topic. topics = self._getUndo(channel) # This is the topic list we want. if topics is not None: @@ -393,6 +460,9 @@ class Topic(callbacks.Plugin): Undoes the last undo. is only necessary if the message isn't sent in the channel itself. """ + if not self._checkManageCapabilities(irc, msg, channel): + capabilities = self.registryValue('requireManageCapability') + irc.errorNoCapability(capabilities, Raise=True) topics = self._getRedo(channel) if topics is not None: self._sendTopics(irc, channel, topics, isDo=True) @@ -407,6 +477,9 @@ class Topic(callbacks.Plugin): is only necessary if the message isn't sent in the channel itself. """ + if not self._checkManageCapabilities(irc, msg, channel): + capabilities = self.registryValue('requireManageCapability') + irc.errorNoCapability(capabilities, Raise=True) topics = self._splitTopic(irc.state.getTopic(channel), channel) if first == second: irc.error('I refuse to swap the same topic with itself.') @@ -424,6 +497,9 @@ class Topic(callbacks.Plugin): default topic for a channel may be configured via the configuration variable supybot.plugins.Topic.default. """ + if not self._checkManageCapabilities(irc, msg, channel): + capabilities = self.registryValue('requireManageCapability') + irc.errorNoCapability(capabilities, Raise=True) topic = self.registryValue('default', channel) if topic: self._sendTopics(irc, channel, [topic]) @@ -438,6 +514,9 @@ class Topic(callbacks.Plugin): Sets the topic separator for to Converts the current topic appropriately. """ + if not self._checkManageCapabilities(irc, msg, channel): + capabilities = self.registryValue('requireManageCapability') + irc.errorNoCapability(capabilities, Raise=True) topics = self._splitTopic(irc.state.getTopic(channel), channel) self.setRegistryValue('separator', separator, channel) self._sendTopics(irc, channel, topics) diff --git a/plugins/Topic/test.py b/plugins/Topic/test.py index 0f09ec07b..9d13b3264 100644 --- a/plugins/Topic/test.py +++ b/plugins/Topic/test.py @@ -30,7 +30,7 @@ from supybot.test import * class TopicTestCase(ChannelPluginTestCase): - plugins = ('Topic',) + plugins = ('Topic','User',) def testRemove(self): self.assertError('topic remove 1') _ = self.getMsg('topic add foo') @@ -70,7 +70,23 @@ class TopicTestCase(ChannelPluginTestCase): self.assertEqual(m.command, 'TOPIC') self.assertEqual(m.args[0], self.channel) self.assertEqual(m.args[1], 'foo (test) || bar (test)') - + + def testManageCapabilities(self): + try: + world.testing = False + origuser = self.prefix + self.prefix = 'stuff!stuff@stuff' + self.assertNotError('register nottester stuff', private=True) + + self.assertError('topic add foo') + origconf = conf.supybot.plugins.Topic.requireManageCapability() + conf.supybot.plugins.Topic.requireManageCapability.setValue('') + self.assertNotError('topic add foo') + finally: + world.testing = True + self.prefix = origuser + conf.supybot.plugins.Topic.requireManageCapability.setValue(origconf) + def testInsert(self): m = self.getMsg('topic add foo') self.assertEqual(m.args[1], 'foo (test)') From 7f9a1130605fb6b36967f062d3cbcdb73aff8df6 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Sun, 11 Apr 2010 00:42:55 -0400 Subject: [PATCH 059/243] commit mtughan's bugfix for this bug: https://sourceforge.net/tracker/?func=detail&aid=2985241&group_id=58965&atid=489447 --- src/registry.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/registry.py b/src/registry.py index 02289357a..b60c9c1c2 100644 --- a/src/registry.py +++ b/src/registry.py @@ -307,8 +307,10 @@ class Value(Group): if setDefault: self.setValue(default) - def error(self): - if self.__doc__: + def error(self, message=None): + if message: + s = message + elif self.__doc__: s = self.__doc__ else: s = """%s has no docstring. If you're getting this message, @@ -539,7 +541,7 @@ class Regexp(Value): self.__parent.__init__(*args, **kwargs) def error(self, e): - self.__parent.error('Value must be a regexp of the form %s' % e) + self.__parent.error('%s' % e) def set(self, s): try: From 643be4346677bb462813dce82d7c5bf119a1a686 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Sun, 11 Apr 2010 01:23:27 -0400 Subject: [PATCH 060/243] Fix factoids bug ,Factoids.showFactoidIfOnlyOneMatch feature is broken when used off-channel: https://sourceforge.net/tracker/?func=detail&aid=2965589&group_id=58965&atid=489447 --- plugins/Factoids/plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/Factoids/plugin.py b/plugins/Factoids/plugin.py index 707e72023..2526eb730 100644 --- a/plugins/Factoids/plugin.py +++ b/plugins/Factoids/plugin.py @@ -661,7 +661,7 @@ class Factoids(callbacks.Plugin, plugins.ChannelDBHandler): irc.reply('No keys matched that query.') elif len(results) == 1 and \ self.registryValue('showFactoidIfOnlyOneMatch', channel): - self.whatis(irc, msg, [results[0][0]]) + self.whatis(irc, msg, [channel, results[0][0]]) elif len(results) > 100: irc.reply('More than 100 keys matched that query; ' 'please narrow your query.') From 8e84da839113e82e469aa0ba2edaddca5c4c4e22 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Sun, 11 Apr 2010 02:27:22 -0400 Subject: [PATCH 061/243] add replace function to format that takes varying-length strings to replace. add test for it add error test for format.translate for different length translate args. --- plugins/Format/plugin.py | 9 +++++++++ plugins/Format/test.py | 4 ++++ 2 files changed, 13 insertions(+) diff --git a/plugins/Format/plugin.py b/plugins/Format/plugin.py index 83c19115d..ebe42c407 100644 --- a/plugins/Format/plugin.py +++ b/plugins/Format/plugin.py @@ -90,6 +90,15 @@ class Format(callbacks.Plugin): irc.reply(text.translate(string.maketrans(bad, good))) translate = wrap(translate, ['something', 'something', 'text']) + def replace(self, irc, msg, args, bad, good, text): + """ + + Replaces all non-overlapping occurrences of + with in . + """ + irc.reply(text.replace(bad, good)) + replace = wrap(replace, ['something', 'something', 'text']) + def upper(self, irc, msg, args, text): """ diff --git a/plugins/Format/test.py b/plugins/Format/test.py index 3bbf14602..f891e0dd7 100644 --- a/plugins/Format/test.py +++ b/plugins/Format/test.py @@ -54,6 +54,10 @@ class FormatTestCase(PluginTestCase): def testTranslate(self): self.assertResponse('translate 123 456 1234567890', '4564567890') + self.assertError('translate 123 1234 123125151') + + def testReplace(self): + self.assertResponse('replace # %23 bla#foo', 'bla%23foo') def testUpper(self): self.assertResponse('upper foo', 'FOO') From c4e5dbbe0b6831ebd8539b07c22cc9f1bd717f98 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Sun, 11 Apr 2010 12:48:08 -0400 Subject: [PATCH 062/243] topic now checks for +t mode before denying topic changes, and also allows halfops in addition to ops. default required capabilities for topic changes (if topic is +t and user is not an op or halfop), are chan,op and chan,halfop thanks jemfinch for the suggestions. --- plugins/Topic/config.py | 2 +- plugins/Topic/plugin.py | 2 +- plugins/Topic/test.py | 4 ++++ 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/plugins/Topic/config.py b/plugins/Topic/config.py index f07870554..25f3243b6 100644 --- a/plugins/Topic/config.py +++ b/plugins/Topic/config.py @@ -64,7 +64,7 @@ conf.registerChannelValue(Topic.undo, 'max', registry.NonNegativeInteger(10, """Determines the number of previous topics to keep around in case the undo command is called.""")) conf.registerChannelValue(Topic, 'requireManageCapability', - registry.String('admin; channel,op', + registry.String('channel,op; channel,halfop', """Determines the capabilities required (if any) to make any topic changes, (everything except for read-only operations). Use 'channel,capab' for diff --git a/plugins/Topic/plugin.py b/plugins/Topic/plugin.py index 3c843aa6d..098b55f55 100644 --- a/plugins/Topic/plugin.py +++ b/plugins/Topic/plugin.py @@ -173,7 +173,7 @@ class Topic(callbacks.Plugin): manually anyway. """ c = irc.state.channels[channel] - if msg.nick in c.ops: + if msg.nick in c.ops or msg.nick in c.halfops or 't' not in c.modes: return True capabilities = self.registryValue('requireManageCapability') if capabilities: diff --git a/plugins/Topic/test.py b/plugins/Topic/test.py index 9d13b3264..83adcfaee 100644 --- a/plugins/Topic/test.py +++ b/plugins/Topic/test.py @@ -73,6 +73,10 @@ class TopicTestCase(ChannelPluginTestCase): def testManageCapabilities(self): try: + self.irc.feedMsg(ircmsgs.mode(self.channel, args=('+o', self.nick), + prefix=self.prefix)) + self.irc.feedMsg(ircmsgs.mode(self.channel, args=('+t'), + prefix=self.prefix)) world.testing = False origuser = self.prefix self.prefix = 'stuff!stuff@stuff' From bd1fb9f9a66017d26e298cf9cacdf5bede724f01 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Sun, 11 Apr 2010 16:25:07 -0400 Subject: [PATCH 063/243] make on-join telling for later configurable. --- plugins/Later/config.py | 5 ++++- plugins/Later/plugin.py | 6 ++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/plugins/Later/config.py b/plugins/Later/config.py index 97ddde7ca..9ae208bc0 100644 --- a/plugins/Later/config.py +++ b/plugins/Later/config.py @@ -45,5 +45,8 @@ conf.registerGlobalValue(Later, 'maximum', conf.registerGlobalValue(Later, 'private', registry.Boolean(True, """Determines whether users will be notified in the first place in which they're seen, or in private.""")) - +conf.registerGlobalValue(Later, 'tellOnJoin', + registry.Boolean(True, """Determines whether users will be notified upon + joining any channel the bot is in, or only upon sending a message.""")) + # vim:set shiftwidth=4 softtabstop=4 expandtab textwidth=79: diff --git a/plugins/Later/plugin.py b/plugins/Later/plugin.py index 92e7298a2..bf89718b9 100644 --- a/plugins/Later/plugin.py +++ b/plugins/Later/plugin.py @@ -197,8 +197,10 @@ class Later(callbacks.Plugin): def _formatNote(self, when, whence, note): return 'Sent %s: <%s> %s' % (self._timestamp(when), whence, note) - - doJoin = doPrivmsg + + def doJoin(self, irc, msg): + if self.registryValue('tellOnJoin'): + self.doPrivmsg(irc, msg) Class = Later From 67ab067e6a15ee0a1c1d76f4a2772caa2a7f474a Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Sun, 11 Apr 2010 16:40:59 -0400 Subject: [PATCH 064/243] take out getDb override from factoids, since i have changed it upstream, in plugins.__init__.py, to use proper sqlite3 syntax. --- plugins/Factoids/plugin.py | 25 ------------------------- 1 file changed, 25 deletions(-) diff --git a/plugins/Factoids/plugin.py b/plugins/Factoids/plugin.py index 2526eb730..e125ed019 100644 --- a/plugins/Factoids/plugin.py +++ b/plugins/Factoids/plugin.py @@ -40,13 +40,6 @@ import supybot.plugins as plugins import supybot.ircutils as ircutils import supybot.callbacks as callbacks -#try: - #import sqlite3 as sqlite -#except ImportError: - #raise callbacks.Error, 'You need to have PySQLite installed to use this ' \ - #'plugin. Download it at ' \ - #'' - try: import sqlite3 except ImportError: @@ -55,10 +48,6 @@ except ImportError: import re from supybot.utils.seq import dameraulevenshtein -# these are needed cuz we are overriding getdb -import threading -import supybot.world as world - def getFactoid(irc, msg, args, state): assert not state.channel callConverter('channel', irc, msg, args, state) @@ -121,20 +110,6 @@ class Factoids(callbacks.Plugin, plugins.ChannelDBHandler): db.commit() return db - # override this because sqlite3 doesn't have autocommit - # use isolation_level instead. - def getDb(self, channel): - """Use this to get a database for a specific channel.""" - currentThread = threading.currentThread() - if channel not in self.dbCache and currentThread == world.mainThread: - self.dbCache[channel] = self.makeDb(self.makeFilename(channel)) - if currentThread != world.mainThread: - db = self.makeDb(self.makeFilename(channel)) - else: - db = self.dbCache[channel] - db.isolation_level = None - return db - def getCommandHelp(self, command, simpleSyntax=None): method = self.getCommandMethod(command) if method.im_func.func_name == 'learn': From 34d91284a578410b3d1b8d9389e00e1cee609c7c Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Sun, 11 Apr 2010 17:01:31 -0400 Subject: [PATCH 065/243] in Later nick validation, use irc.isNick. now instead of forcing strictRfc to true, we are using the config. --- plugins/Later/plugin.py | 8 ++++---- plugins/Later/test.py | 3 +++ 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/plugins/Later/plugin.py b/plugins/Later/plugin.py index bf89718b9..e718c1313 100644 --- a/plugins/Later/plugin.py +++ b/plugins/Later/plugin.py @@ -99,7 +99,7 @@ class Later(callbacks.Plugin): self.wildcards.append(nick) self._flushNotes() - def _validateNick(self, nick): + def _validateNick(self, irc, nick): """Validate nick according to the IRC RFC 2812 spec. Reference: http://tools.ietf.org/rfcmarkup?doc=2812#section-2.3.1 @@ -111,8 +111,8 @@ class Later(callbacks.Plugin): If nick incorrigibly invalid, return False, otherwise, return (possibly trimmed) nick. """ - if not ircutils.isNick(nick, strictRfc=True): - if not ircutils.isNick(nick[:-1], strictRfc=True): + if not irc.isNick(nick): + if not irc.isNick(nick[:-1]): return False else: return nick[:-1] @@ -128,7 +128,7 @@ class Later(callbacks.Plugin): if ircutils.strEqual(nick, irc.nick): irc.error('I can\'t send notes to myself.') return - validnick = self._validateNick(nick) + validnick = self._validateNick(irc, nick) if validnick is False: irc.error('That is an invalid IRC nick. Please check your input.') return diff --git a/plugins/Later/test.py b/plugins/Later/test.py index d91070620..447f088fd 100644 --- a/plugins/Later/test.py +++ b/plugins/Later/test.py @@ -44,10 +44,13 @@ class LaterTestCase(PluginTestCase): self.assertRegexp('later notes', 'foo') def testNickValidation(self): + origconf = conf.supybot.protocols.irc.strictRfc() + conf.supybot.protocols.irc.strictRfc.setValue('True') self.assertError('later tell 1foo bar') self.assertError('later tell foo$moo zoob') self.assertNotError('later tell foo: baz') self.assertRegexp('later notes', 'foo\.') + conf.supybot.protocols.irc.strictRfc.setValue(origconf) # vim:set shiftwidth=4 softtabstop=4 expandtab textwidth=79: From 127b3cfabd1bd66e6c477be24de91e9e2ff7db97 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Sun, 11 Apr 2010 17:37:09 -0400 Subject: [PATCH 066/243] for google translate, error if destination language is 'auto'. --- plugins/Google/plugin.py | 3 +++ plugins/Google/test.py | 1 + 2 files changed, 4 insertions(+) diff --git a/plugins/Google/plugin.py b/plugins/Google/plugin.py index cbb95978f..1edafa76c 100644 --- a/plugins/Google/plugin.py +++ b/plugins/Google/plugin.py @@ -268,6 +268,9 @@ class Google(callbacks.PluginRegexp): toLang = lang.normalize('lang_'+toLang)[5:] if fromLang == 'auto': fromLang = '' + if toLang == 'auto': + irc.error("Destination language cannot be 'auto'.") + return opts['langpair'] = '%s|%s' % (fromLang, toLang) fd = utils.web.getUrlFd('%s?%s' % (self._gtranslateUrl, urllib.urlencode(opts)), diff --git a/plugins/Google/test.py b/plugins/Google/test.py index ca9b78207..0b73b504c 100644 --- a/plugins/Google/test.py +++ b/plugins/Google/test.py @@ -60,6 +60,7 @@ class GoogleTestCase(ChannelPluginTestCase): def testTranslate(self): self.assertRegexp('translate en es hello world', 'mundo') self.assertRegexp('translate auto en ciao', 'Italian.*hello') + self.assertError('translate en to auto stuff') def testCalcDoesNotHaveExtraSpaces(self): self.assertNotRegexp('google calc 1000^2', r'\s+,\s+') From 5e162a28f7ebca879b9ee522e2d13543a1620383 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Sun, 11 Apr 2010 18:27:39 -0400 Subject: [PATCH 067/243] make google translate return detailed google error, instead of the unhelpful "we broke google". --- plugins/Google/plugin.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugins/Google/plugin.py b/plugins/Google/plugin.py index 1edafa76c..edd9f829b 100644 --- a/plugins/Google/plugin.py +++ b/plugins/Google/plugin.py @@ -278,7 +278,8 @@ class Google(callbacks.PluginRegexp): json = simplejson.load(fd) fd.close() if json['responseStatus'] != 200: - raise callbacks.Error, 'We broke The Google!' + raise callbacks.Error, 'Google says: Response Status %s: %s.' % \ + (json['responseStatus'], json['responseDetails'],) if fromLang != '': irc.reply(json['responseData']['translatedText'].encode('utf-8')) else: From aa634e6da1993e5f35dc26028e49f1715f821c3f Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Wed, 14 Apr 2010 10:27:56 -0400 Subject: [PATCH 068/243] fix alias bug https://sourceforge.net/tracker/?func=detail&aid=2987147&group_id=58965&atid=489447 add tests for appropriate behavior --- plugins/Alias/plugin.py | 22 ++++++++++------------ plugins/Alias/test.py | 6 +++++- 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/plugins/Alias/plugin.py b/plugins/Alias/plugin.py index 02b66ad07..9021b6809 100644 --- a/plugins/Alias/plugin.py +++ b/plugins/Alias/plugin.py @@ -62,18 +62,18 @@ def getChannel(msg, args=()): raise callbacks.Error, 'Command must be sent in a channel or ' \ 'include a channel in its arguments.' -def getArgs(args, required=1, optional=0): +def getArgs(args, required=1, optional=0, wildcard=0): 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 + if not wildcard: + ret = list(args[:required + optional - 1]) + ret.append(' '.join(args[required + optional - 1:])) + else: + ret = list(args) + return ret class AliasError(Exception): pass @@ -119,11 +119,9 @@ def makeNewAlias(name, alias): channel = getChannel(msg, args) alias = alias.replace('$channel', channel) tokens = callbacks.tokenize(alias) - if not wildcard and biggestDollar or biggestAt: - args = getArgs(args, required=biggestDollar, optional=biggestAt) - # Gotta have a mutable sequence (for replace). - if biggestDollar + biggestAt == 1: # We got a string, no tuple. - args = [args] + if biggestDollar or biggestAt: + args = getArgs(args, required=biggestDollar, optional=biggestAt, + wildcard=wildcard) def regexpReplace(m): idx = int(m.group(1)) return args[idx-1] diff --git a/plugins/Alias/test.py b/plugins/Alias/test.py index a4de21c70..57649dfa6 100644 --- a/plugins/Alias/test.py +++ b/plugins/Alias/test.py @@ -85,7 +85,11 @@ class AliasTestCase(ChannelPluginTestCase): self.assertNotError('alias add swap "echo $2 $1 $*"') self.assertResponse('swap 1 2 3 4 5', '2 1 3 4 5') self.assertError('alias add foo "echo $1 @1 $*"') - + self.assertNotError('alias add moo echo $1 $*') + self.assertError('moo') + self.assertResponse('moo foo', 'foo') + self.assertResponse('moo foo bar', 'foo bar') + def testChannel(self): self.assertNotError('alias add channel echo $channel') self.assertResponse('alias channel', self.channel) From 4890e2e80dddf493f5ed20adc92523a9a7a86f03 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Wed, 14 Apr 2010 10:56:39 -0400 Subject: [PATCH 069/243] for Alias, make doc string say "at least x args" if there are optional args in addition to required args. yay for cosmetic improvements. :) --- plugins/Alias/plugin.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/plugins/Alias/plugin.py b/plugins/Alias/plugin.py index 9021b6809..2b14b6527 100644 --- a/plugins/Alias/plugin.py +++ b/plugins/Alias/plugin.py @@ -157,8 +157,11 @@ def makeNewAlias(name, alias): return False everythingReplace(tokens) self.Proxy(irc, msg, tokens) - doc =format('\n\nAlias for %q.', - (biggestDollar, 'argument'), alias) + flexargs = '' + if biggestDollar and (wildcard or biggestAt): + flexargs = ' at least' + doc =format('\n\nAlias for %q.', + flexargs, (biggestDollar, 'argument'), alias) f = utils.python.changeFunctionName(f, name, doc) return f From b5058cc5c26f2386d2401e8962067511226e3236 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Fri, 16 Apr 2010 16:06:00 -0400 Subject: [PATCH 070/243] update Karma plugin and test code to use sqlite3. This involved also updating src/conf.py to look for sqlite3 and add it to supybot.conf.databases list, since karma uses the plugins.DB() constructor for its database, which checks the available databases list. --- plugins/Karma/plugin.py | 64 ++++---- plugins/Karma/test.py | 321 ++++++++++++++++++++-------------------- src/conf.py | 4 + 3 files changed, 198 insertions(+), 191 deletions(-) diff --git a/plugins/Karma/plugin.py b/plugins/Karma/plugin.py index d7a33ec41..f6a966cfa 100644 --- a/plugins/Karma/plugin.py +++ b/plugins/Karma/plugin.py @@ -39,6 +39,11 @@ import supybot.ircmsgs as ircmsgs import supybot.ircutils as ircutils import supybot.callbacks as callbacks +try: + import sqlite3 +except ImportError: + from pysqlite2 import dbapi2 as sqlite3 # for python2.4 + class SqliteKarmaDB(object): def __init__(self, filename): self.dbs = ircutils.IrcDict() @@ -49,19 +54,16 @@ class SqliteKarmaDB(object): db.close() def _getDb(self, channel): - try: - import sqlite - except ImportError: - raise callbacks.Error, 'You need to have PySQLite installed to ' \ - 'use Karma. Download it at ' \ - '' filename = plugins.makeChannelFilename(self.filename, channel) if filename in self.dbs: return self.dbs[filename] if os.path.exists(filename): - self.dbs[filename] = sqlite.connect(filename) - return self.dbs[filename] - db = sqlite.connect(filename) + db = sqlite3.connect(filename) + db.text_factory = str + self.dbs[filename] = db + return db + db = sqlite3.connect(filename) + db.text_factory = str self.dbs[filename] = db cursor = db.cursor() cursor.execute("""CREATE TABLE karma ( @@ -82,20 +84,21 @@ class SqliteKarmaDB(object): thing = thing.lower() cursor = db.cursor() cursor.execute("""SELECT added, subtracted FROM karma - WHERE normalized=%s""", thing) - if cursor.rowcount == 0: + WHERE normalized=?""", (thing,)) + results = cursor.fetchall() + if len(results) == 0: return None else: - return map(int, cursor.fetchone()) + return map(int, results[0]) def gets(self, channel, things): db = self._getDb(channel) cursor = db.cursor() normalizedThings = dict(zip(map(lambda s: s.lower(), things), things)) - criteria = ' OR '.join(['normalized=%s'] * len(normalizedThings)) + criteria = ' OR '.join(['normalized=?'] * len(normalizedThings)) sql = """SELECT name, added-subtracted FROM karma WHERE %s ORDER BY added-subtracted DESC""" % criteria - cursor.execute(sql, *normalizedThings) + cursor.execute(sql, normalizedThings.keys()) L = [(name, int(karma)) for (name, karma) in cursor.fetchall()] for (name, _) in L: del normalizedThings[name.lower()] @@ -107,26 +110,27 @@ class SqliteKarmaDB(object): db = self._getDb(channel) cursor = db.cursor() cursor.execute("""SELECT name, added-subtracted FROM karma - ORDER BY added-subtracted DESC LIMIT %s""", limit) + ORDER BY added-subtracted DESC LIMIT ?""", (limit,)) return [(t[0], int(t[1])) for t in cursor.fetchall()] def bottom(self, channel, limit): db = self._getDb(channel) cursor = db.cursor() cursor.execute("""SELECT name, added-subtracted FROM karma - ORDER BY added-subtracted ASC LIMIT %s""", limit) + ORDER BY added-subtracted ASC LIMIT ?""", (limit,)) return [(t[0], int(t[1])) for t in cursor.fetchall()] def rank(self, channel, thing): db = self._getDb(channel) cursor = db.cursor() cursor.execute("""SELECT added-subtracted FROM karma - WHERE name=%s""", thing) - if cursor.rowcount == 0: + WHERE name=?""", (thing,)) + results = cursor.fetchall() + if len(results) == 0: return None - karma = int(cursor.fetchone()[0]) + karma = int(results[0][0]) cursor.execute("""SELECT COUNT(*) FROM karma - WHERE added-subtracted > %s""", karma) + WHERE added-subtracted > ?""", (karma,)) rank = int(cursor.fetchone()[0]) return rank+1 @@ -140,20 +144,20 @@ class SqliteKarmaDB(object): db = self._getDb(channel) cursor = db.cursor() normalized = name.lower() - cursor.execute("""INSERT INTO karma VALUES (NULL, %s, %s, 0, 0)""", - name, normalized) + cursor.execute("""INSERT INTO karma VALUES (NULL, ?, ?, 0, 0)""", + (name, normalized,)) cursor.execute("""UPDATE karma SET added=added+1 - WHERE normalized=%s""", normalized) + WHERE normalized=?""", (normalized,)) db.commit() def decrement(self, channel, name): db = self._getDb(channel) cursor = db.cursor() normalized = name.lower() - cursor.execute("""INSERT INTO karma VALUES (NULL, %s, %s, 0, 0)""", - name, normalized) + cursor.execute("""INSERT INTO karma VALUES (NULL, ?, ?, 0, 0)""", + (name, normalized,)) cursor.execute("""UPDATE karma SET subtracted=subtracted+1 - WHERE normalized=%s""", normalized) + WHERE normalized=?""", (normalized,)) db.commit() def most(self, channel, kind, limit): @@ -177,7 +181,7 @@ class SqliteKarmaDB(object): cursor = db.cursor() normalized = name.lower() cursor.execute("""UPDATE karma SET subtracted=0, added=0 - WHERE normalized=%s""", normalized) + WHERE normalized=?""", (normalized,)) db.commit() def dump(self, channel, filename): @@ -201,13 +205,13 @@ class SqliteKarmaDB(object): for (name, added, subtracted) in reader: normalized = name.lower() cursor.execute("""INSERT INTO karma - VALUES (NULL, %s, %s, %s, %s)""", - name, normalized, added, subtracted) + VALUES (NULL, ?, ?, ?, ?)""", + (name, normalized, added, subtracted,)) db.commit() fd.close() KarmaDB = plugins.DB('Karma', - {'sqlite': SqliteKarmaDB}) + {'sqlite3': SqliteKarmaDB}) class Karma(callbacks.Plugin): callBefore = ('Factoids', 'MoobotFactoids', 'Infobot') diff --git a/plugins/Karma/test.py b/plugins/Karma/test.py index 526dda225..7dbfbdf2c 100644 --- a/plugins/Karma/test.py +++ b/plugins/Karma/test.py @@ -30,182 +30,181 @@ from supybot.test import * try: - import sqlite + import sqlite3 except ImportError: - sqlite = None + from pysqlite2 import dbapi2 as sqlite3 # for python2.4 -if sqlite is not None: - class KarmaTestCase(ChannelPluginTestCase): - plugins = ('Karma',) - def testKarma(self): - self.assertError('karma') - self.assertRegexp('karma foobar', 'neutral karma') - try: - conf.replyWhenNotCommand = True - self.assertNoResponse('foobar++', 2) - finally: - conf.replyWhenNotCommand = False - self.assertRegexp('karma foobar', 'increased 1.*total.*1') - self.assertRegexp('karma FOOBAR', 'increased 1.*total.*1') - self.assertNoResponse('foobar--', 2) - self.assertRegexp('karma foobar', 'decreased 1.*total.*0') - self.assertRegexp('karma FOOBAR', 'decreased 1.*total.*0') - self.assertNoResponse('FOO++', 2) - self.assertNoResponse('BAR--', 2) - self.assertRegexp('karma foo bar foobar', '.*foo.*foobar.*bar.*') - self.assertRegexp('karma FOO BAR FOOBAR', '.*foo.*foobar.*bar.*') - self.assertRegexp('karma FOO BAR FOOBAR', - '.*FOO.*foobar.*BAR.*', flags=0) - self.assertRegexp('karma foo bar foobar asdfjkl', 'asdfjkl') - # Test case-insensitive - self.assertNoResponse('MOO++', 2) - self.assertRegexp('karma moo', - 'Karma for [\'"]moo[\'"].*increased 1.*total.*1') - self.assertRegexp('karma MoO', - 'Karma for [\'"]MoO[\'"].*increased 1.*total.*1') +class KarmaTestCase(ChannelPluginTestCase): + plugins = ('Karma',) + def testKarma(self): + self.assertError('karma') + self.assertRegexp('karma foobar', 'neutral karma') + try: + conf.replyWhenNotCommand = True + self.assertNoResponse('foobar++', 2) + finally: + conf.replyWhenNotCommand = False + self.assertRegexp('karma foobar', 'increased 1.*total.*1') + self.assertRegexp('karma FOOBAR', 'increased 1.*total.*1') + self.assertNoResponse('foobar--', 2) + self.assertRegexp('karma foobar', 'decreased 1.*total.*0') + self.assertRegexp('karma FOOBAR', 'decreased 1.*total.*0') + self.assertNoResponse('FOO++', 2) + self.assertNoResponse('BAR--', 2) + self.assertRegexp('karma foo bar foobar', '.*foo.*foobar.*bar.*') + self.assertRegexp('karma FOO BAR FOOBAR', '.*foo.*foobar.*bar.*') + self.assertRegexp('karma FOO BAR FOOBAR', + '.*FOO.*foobar.*BAR.*', flags=0) + self.assertRegexp('karma foo bar foobar asdfjkl', 'asdfjkl') + # Test case-insensitive + self.assertNoResponse('MOO++', 2) + self.assertRegexp('karma moo', + 'Karma for [\'"]moo[\'"].*increased 1.*total.*1') + self.assertRegexp('karma MoO', + 'Karma for [\'"]MoO[\'"].*increased 1.*total.*1') - def testKarmaRankingDisplayConfigurable(self): - try: - orig = conf.supybot.plugins.Karma.response() - conf.supybot.plugins.Karma.response.setValue(True) - original = conf.supybot.plugins.Karma.rankingDisplay() - self.assertNotError('foo++') - self.assertNotError('foo++') - self.assertNotError('foo++') - self.assertNotError('foo++') - self.assertNotError('bar++') - self.assertNotError('bar++') - self.assertNotError('bar++') - self.assertNotError('baz++') - self.assertNotError('baz++') - self.assertNotError('quux++') - self.assertNotError('xuuq--') - self.assertNotError('zab--') - self.assertNotError('zab--') - self.assertNotError('rab--') - self.assertNotError('rab--') - self.assertNotError('rab--') - self.assertNotError('oof--') - self.assertNotError('oof--') - self.assertNotError('oof--') - self.assertNotError('oof--') - self.assertRegexp('karma', 'foo.*bar.*baz.*oof.*rab.*zab') - conf.supybot.plugins.Karma.rankingDisplay.setValue(4) - self.assertRegexp('karma', 'foo.*bar.*baz.*quux') - finally: - conf.supybot.plugins.Karma.response.setValue(orig) - conf.supybot.plugins.Karma.rankingDisplay.setValue(original) + def testKarmaRankingDisplayConfigurable(self): + try: + orig = conf.supybot.plugins.Karma.response() + conf.supybot.plugins.Karma.response.setValue(True) + original = conf.supybot.plugins.Karma.rankingDisplay() + self.assertNotError('foo++') + self.assertNotError('foo++') + self.assertNotError('foo++') + self.assertNotError('foo++') + self.assertNotError('bar++') + self.assertNotError('bar++') + self.assertNotError('bar++') + self.assertNotError('baz++') + self.assertNotError('baz++') + self.assertNotError('quux++') + self.assertNotError('xuuq--') + self.assertNotError('zab--') + self.assertNotError('zab--') + self.assertNotError('rab--') + self.assertNotError('rab--') + self.assertNotError('rab--') + self.assertNotError('oof--') + self.assertNotError('oof--') + self.assertNotError('oof--') + self.assertNotError('oof--') + self.assertRegexp('karma', 'foo.*bar.*baz.*oof.*rab.*zab') + conf.supybot.plugins.Karma.rankingDisplay.setValue(4) + self.assertRegexp('karma', 'foo.*bar.*baz.*quux') + finally: + conf.supybot.plugins.Karma.response.setValue(orig) + conf.supybot.plugins.Karma.rankingDisplay.setValue(original) - def testMost(self): - self.assertError('most increased') - self.assertError('most decreased') - self.assertError('most active') - self.assertHelp('most aldsfkj') - self.assertNoResponse('foo++', 1) - self.assertNoResponse('foo++', 1) - self.assertNoResponse('bar++', 1) - self.assertNoResponse('bar--', 1) - self.assertNoResponse('bar--', 1) - self.assertRegexp('karma most active', 'bar.*foo') - self.assertRegexp('karma most increased', 'foo.*bar') - self.assertRegexp('karma most decreased', 'bar.*foo') - self.assertNoResponse('foo--', 1) - self.assertNoResponse('foo--', 1) - self.assertNoResponse('foo--', 1) - self.assertNoResponse('foo--', 1) - self.assertRegexp('karma most active', 'foo.*bar') - self.assertRegexp('karma most increased', 'foo.*bar') - self.assertRegexp('karma most decreased', 'foo.*bar') + def testMost(self): + self.assertError('most increased') + self.assertError('most decreased') + self.assertError('most active') + self.assertHelp('most aldsfkj') + self.assertNoResponse('foo++', 1) + self.assertNoResponse('foo++', 1) + self.assertNoResponse('bar++', 1) + self.assertNoResponse('bar--', 1) + self.assertNoResponse('bar--', 1) + self.assertRegexp('karma most active', 'bar.*foo') + self.assertRegexp('karma most increased', 'foo.*bar') + self.assertRegexp('karma most decreased', 'bar.*foo') + self.assertNoResponse('foo--', 1) + self.assertNoResponse('foo--', 1) + self.assertNoResponse('foo--', 1) + self.assertNoResponse('foo--', 1) + self.assertRegexp('karma most active', 'foo.*bar') + self.assertRegexp('karma most increased', 'foo.*bar') + self.assertRegexp('karma most decreased', 'foo.*bar') - def testSimpleOutput(self): - try: - orig = conf.supybot.plugins.Karma.simpleOutput() - conf.supybot.plugins.Karma.simpleOutput.setValue(True) - self.assertNoResponse('foo++', 2) - self.assertResponse('karma foo', 'foo: 1') - self.assertNoResponse('bar--', 2) - self.assertResponse('karma bar', 'bar: -1') - finally: - conf.supybot.plugins.Karma.simpleOutput.setValue(orig) - - def testSelfRating(self): - nick = self.nick - try: - orig = conf.supybot.plugins.Karma.allowSelfRating() - conf.supybot.plugins.Karma.allowSelfRating.setValue(False) - self.assertError('%s++' % nick) - self.assertResponse('karma %s' % nick, - '%s has neutral karma.' % nick) - conf.supybot.plugins.Karma.allowSelfRating.setValue(True) - self.assertNoResponse('%s++' % nick, 2) - self.assertRegexp('karma %s' % nick, - 'Karma for [\'"]%s[\'"].*increased 1.*total.*1' % nick) - finally: - conf.supybot.plugins.Karma.allowSelfRating.setValue(orig) - - def testKarmaOutputConfigurable(self): + def testSimpleOutput(self): + try: + orig = conf.supybot.plugins.Karma.simpleOutput() + conf.supybot.plugins.Karma.simpleOutput.setValue(True) self.assertNoResponse('foo++', 2) - try: - orig = conf.supybot.plugins.Karma.response() - conf.supybot.plugins.Karma.response.setValue(True) - self.assertNotError('foo++') - finally: - conf.supybot.plugins.Karma.response.setValue(orig) + self.assertResponse('karma foo', 'foo: 1') + self.assertNoResponse('bar--', 2) + self.assertResponse('karma bar', 'bar: -1') + finally: + conf.supybot.plugins.Karma.simpleOutput.setValue(orig) - def testKarmaMostDisplayConfigurable(self): - self.assertNoResponse('foo++', 1) - self.assertNoResponse('foo++', 1) - self.assertNoResponse('bar++', 1) - self.assertNoResponse('bar--', 1) - self.assertNoResponse('bar--', 1) - self.assertNoResponse('foo--', 1) - self.assertNoResponse('foo--', 1) - self.assertNoResponse('foo--', 1) - self.assertNoResponse('foo--', 1) - try: - orig = conf.supybot.plugins.Karma.mostDisplay() - conf.supybot.plugins.Karma.mostDisplay.setValue(1) - self.assertRegexp('karma most active', '(?!bar)') - conf.supybot.plugins.Karma.mostDisplay.setValue(25) - self.assertRegexp('karma most active', 'bar') - finally: - conf.supybot.plugins.Karma.mostDisplay.setValue(orig) + def testSelfRating(self): + nick = self.nick + try: + orig = conf.supybot.plugins.Karma.allowSelfRating() + conf.supybot.plugins.Karma.allowSelfRating.setValue(False) + self.assertError('%s++' % nick) + self.assertResponse('karma %s' % nick, + '%s has neutral karma.' % nick) + conf.supybot.plugins.Karma.allowSelfRating.setValue(True) + self.assertNoResponse('%s++' % nick, 2) + self.assertRegexp('karma %s' % nick, + 'Karma for [\'"]%s[\'"].*increased 1.*total.*1' % nick) + finally: + conf.supybot.plugins.Karma.allowSelfRating.setValue(orig) + + def testKarmaOutputConfigurable(self): + self.assertNoResponse('foo++', 2) + try: + orig = conf.supybot.plugins.Karma.response() + conf.supybot.plugins.Karma.response.setValue(True) + self.assertNotError('foo++') + finally: + conf.supybot.plugins.Karma.response.setValue(orig) + + def testKarmaMostDisplayConfigurable(self): + self.assertNoResponse('foo++', 1) + self.assertNoResponse('foo++', 1) + self.assertNoResponse('bar++', 1) + self.assertNoResponse('bar--', 1) + self.assertNoResponse('bar--', 1) + self.assertNoResponse('foo--', 1) + self.assertNoResponse('foo--', 1) + self.assertNoResponse('foo--', 1) + self.assertNoResponse('foo--', 1) + try: + orig = conf.supybot.plugins.Karma.mostDisplay() + conf.supybot.plugins.Karma.mostDisplay.setValue(1) + self.assertRegexp('karma most active', '(?!bar)') + conf.supybot.plugins.Karma.mostDisplay.setValue(25) + self.assertRegexp('karma most active', 'bar') + finally: + conf.supybot.plugins.Karma.mostDisplay.setValue(orig) - def testIncreaseKarmaWithNickNotCallingInvalidCommand(self): - self.assertSnarfNoResponse('%s: foo++' % self.irc.nick, 3) + def testIncreaseKarmaWithNickNotCallingInvalidCommand(self): + self.assertSnarfNoResponse('%s: foo++' % self.irc.nick, 3) - def testClear(self): - self.assertNoResponse('foo++', 1) - self.assertRegexp('karma foo', '1') - self.assertNotError('karma clear foo') - self.assertRegexp('karma foo', '0') - self.assertNotRegexp('karma foo', '1') + def testClear(self): + self.assertNoResponse('foo++', 1) + self.assertRegexp('karma foo', '1') + self.assertNotError('karma clear foo') + self.assertRegexp('karma foo', '0') + self.assertNotRegexp('karma foo', '1') # def testNoKarmaDunno(self): # self.assertNotError('load Infobot') # self.assertNoResponse('foo++') - def testMultiWordKarma(self): - self.assertNoResponse('(foo bar)++', 1) - self.assertRegexp('karma "foo bar"', '1') + def testMultiWordKarma(self): + self.assertNoResponse('(foo bar)++', 1) + self.assertRegexp('karma "foo bar"', '1') - def testUnaddressedKarma(self): - karma = conf.supybot.plugins.Karma - resp = karma.response() - unaddressed = karma.allowUnaddressedKarma() - try: - karma.response.setValue(True) - karma.allowUnaddressedKarma.setValue(True) - for m in ('++', '--'): - self.assertRegexp('foo%s' % m, 'operation') - self.assertSnarfRegexp('foo%s' % m, 'operation') - #self.assertNoResponse('foo bar%s' % m) - #self.assertSnarfNoResponse('foo bar%s' % m) - self.assertRegexp('(foo bar)%s' % m, 'operation') - self.assertSnarfRegexp('(foo bar)%s' % m, 'operation') - finally: - karma.response.setValue(resp) - karma.allowUnaddressedKarma.setValue(unaddressed) + def testUnaddressedKarma(self): + karma = conf.supybot.plugins.Karma + resp = karma.response() + unaddressed = karma.allowUnaddressedKarma() + try: + karma.response.setValue(True) + karma.allowUnaddressedKarma.setValue(True) + for m in ('++', '--'): + self.assertRegexp('foo%s' % m, 'operation') + self.assertSnarfRegexp('foo%s' % m, 'operation') + #self.assertNoResponse('foo bar%s' % m) + #self.assertSnarfNoResponse('foo bar%s' % m) + self.assertRegexp('(foo bar)%s' % m, 'operation') + self.assertSnarfRegexp('(foo bar)%s' % m, 'operation') + finally: + karma.response.setValue(resp) + karma.allowUnaddressedKarma.setValue(unaddressed) # vim:set shiftwidth=4 softtabstop=4 expandtab textwidth=79: diff --git a/src/conf.py b/src/conf.py index 54141d5a8..114b87878 100644 --- a/src/conf.py +++ b/src/conf.py @@ -765,6 +765,10 @@ class Databases(registry.SpaceSeparatedListOfStrings): v = ['anydbm', 'cdb', 'flat', 'pickle'] if 'sqlite' in sys.modules: v.insert(0, 'sqlite') + if 'sqlite3' in sys.modules: + v.insert(0, 'sqlite3') + if 'pysqlite2' in sys.modules: # for python 2.4 + v.insert(0, 'sqlite3') return v def serialize(self): From 022193b61b99268d41df746f9a034c18b6d62673 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Sun, 18 Apr 2010 03:33:10 -0400 Subject: [PATCH 071/243] for Factoids: make approximate fuzzy key searches also when invoking whatis directly. also add tests. while there, remove useless config setting for replyinvalidcommand testing, since it is true by default. --- plugins/Factoids/config.py | 10 +++++----- plugins/Factoids/plugin.py | 23 +++++++++++++++-------- plugins/Factoids/test.py | 32 ++++++++++++++++---------------- 3 files changed, 36 insertions(+), 29 deletions(-) diff --git a/plugins/Factoids/config.py b/plugins/Factoids/config.py index 374a84978..5f0617bde 100644 --- a/plugins/Factoids/config.py +++ b/plugins/Factoids/config.py @@ -58,11 +58,11 @@ conf.registerChannelValue(Factoids, 'replyWhenInvalidCommand', registry.Boolean(True, """Determines whether the bot will reply to invalid commands by searching for a factoid; basically making the whatis unnecessary when you want all factoids for a given key.""")) -conf.registerChannelValue(Factoids, 'replyWhenInvalidCommandSearchKeys', - registry.Boolean(True, """If replyWhenInvalidCommand is True, and you - supply a nonexistent factoid as a command, this setting make the bot try a - wildcard search for factoid keys, returning a list of matching keys, - before giving up with an invalid command error.""")) +conf.registerChannelValue(Factoids, 'replyApproximateSearchKeys', + registry.Boolean(True, """If you try to look up a nonexistent factoid, + this setting make the bot try to find some possible matching keys through + several approximate matching algorithms and return a list of matching keys, + before giving up.""")) conf.registerChannelValue(Factoids, 'format', FactoidFormat('$key could be $value.', """Determines the format of the response given when a factoid's value is requested. All the standard diff --git a/plugins/Factoids/plugin.py b/plugins/Factoids/plugin.py index e125ed019..f5193a003 100644 --- a/plugins/Factoids/plugin.py +++ b/plugins/Factoids/plugin.py @@ -192,7 +192,6 @@ class Factoids(callbacks.Plugin, plugins.ChannelDBHandler): ORDER BY factoids.id LIMIT 20""", (key,)) return cursor.fetchall() - #return [t[0] for t in cursor.fetchall()] def _searchFactoid(self, channel, key): """Try to typo-match input to possible factoids. @@ -269,6 +268,16 @@ class Factoids(callbacks.Plugin, plugins.ChannelDBHandler): self._updateRank(channel, factoids) elif error: irc.error('No factoid matches that key.') + + def _replyApproximateFactoids(self, irc, msg, channel, key, error=True): + if self.registryValue('replyApproximateSearchKeys'): + factoids = self._searchFactoid(channel, key) + if factoids: + keylist = ["'%s'" % (fact,) for fact in factoids] + keylist = ', '.join(keylist) + irc.reply("I do not know about '%s', but I do know about these similar topics: %s" % (key, keylist)) + elif error: + irc.error('No factoid matches that key.') def invalidCommand(self, irc, msg, tokens): if irc.isChannel(msg.args[0]): @@ -279,12 +288,7 @@ class Factoids(callbacks.Plugin, plugins.ChannelDBHandler): if factoids: self._replyFactoids(irc, msg, key, channel, factoids, error=False) else: - if self.registryValue('replyWhenInvalidCommandSearchKeys'): - factoids = self._searchFactoid(channel, key) - if factoids: - keylist = ["'%s'" % (fact,) for fact in factoids] - keylist = ', '.join(keylist) - irc.reply("I do not know about '%s', but I do know about these similar topics: %s" % (key, keylist)) + self._replyApproximateFactoids(irc, msg, channel, key, error=False) def whatis(self, irc, msg, args, channel, words): """[] [] @@ -301,7 +305,10 @@ class Factoids(callbacks.Plugin, plugins.ChannelDBHandler): irc.errorInvalid('key id') key = ' '.join(words) factoids = self._lookupFactoid(channel, key) - self._replyFactoids(irc, msg, key, channel, factoids, number) + if factoids: + self._replyFactoids(irc, msg, key, channel, factoids, number) + else: + self._replyApproximateFactoids(irc, msg, channel, key) whatis = wrap(whatis, ['channel', many('something')]) def alias(self, irc, msg, args, channel, oldkey, newkey, number): diff --git a/plugins/Factoids/test.py b/plugins/Factoids/test.py index 1dddfb19c..22595c329 100644 --- a/plugins/Factoids/test.py +++ b/plugins/Factoids/test.py @@ -157,22 +157,22 @@ class FactoidsTestCase(ChannelPluginTestCase): showFactoidIfOnlyOneMatch.setValue(orig) def testInvalidCommand(self): - orig = conf.supybot.plugins.Factoids.replyWhenInvalidCommand() - try: - conf.supybot.plugins.Factoids.\ - replyWhenInvalidCommand.setValue(True) - self.assertNotError('learn foo as bar') - self.assertRegexp('foo', 'bar') - self.assertNotError('learn mooz as cowz') - self.assertRegexp('moo', 'mooz') - self.assertRegexp('mzo', 'mooz') - self.assertRegexp('moz', 'mooz') - self.assertNotError('learn moped as pretty fast') - self.assertRegexp('moe', 'mooz.*moped') - self.assertError('nosuchthing') - finally: - conf.supybot.plugins.Factoids.\ - replyWhenInvalidCommand.setValue(orig) + self.assertNotError('learn foo as bar') + self.assertRegexp('foo', 'bar') + self.assertNotError('learn mooz as cowz') + self.assertRegexp('moo', 'mooz') + self.assertRegexp('mzo', 'mooz') + self.assertRegexp('moz', 'mooz') + self.assertNotError('learn moped as pretty fast') + self.assertRegexp('moe', 'mooz.*moped') + self.assertError('nosuchthing') + + def testWhatis(self): + self.assertNotError('learn foo as bar') + self.assertRegexp('whatis foo', 'bar') + self.assertRegexp('whatis foob', 'foo') + self.assertNotError('learn foob as barb') + self.assertRegexp('whatis foom', 'foo.*foob') def testAlias(self): self.assertNotError('learn foo as bar') From 35fee237da4dd89d3783beb1413653cc59014393 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Mon, 19 Apr 2010 00:53:40 -0400 Subject: [PATCH 072/243] Undo mtughan's bugfix from 7f9a1130605fb6b36967f062d3cbcdb73aff8df6, so i can merge jamessan's fix ef8bd817e8b62ede76aa7501a9a8d69af7408efc --- src/registry.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/registry.py b/src/registry.py index b60c9c1c2..02289357a 100644 --- a/src/registry.py +++ b/src/registry.py @@ -307,10 +307,8 @@ class Value(Group): if setDefault: self.setValue(default) - def error(self, message=None): - if message: - s = message - elif self.__doc__: + def error(self): + if self.__doc__: s = self.__doc__ else: s = """%s has no docstring. If you're getting this message, @@ -541,7 +539,7 @@ class Regexp(Value): self.__parent.__init__(*args, **kwargs) def error(self, e): - self.__parent.error('%s' % e) + self.__parent.error('Value must be a regexp of the form %s' % e) def set(self, s): try: From 5ad620b5fd295e9df46232ab3a68ee2008e97bd7 Mon Sep 17 00:00:00 2001 From: James Vega Date: Sun, 11 Apr 2010 10:15:39 -0400 Subject: [PATCH 073/243] Make registry.Regexp.error mimic registry.Value.error Regexp.error can't directly call Value.error because it's providing extra information, so it needs to build the InvalidRegistryValue exception itself and raise it. Closes: Sf#2985241 Signed-off-by: James Vega (cherry picked from commit ef8bd817e8b62ede76aa7501a9a8d69af7408efc) Signed-off-by: Daniel Folkinshteyn --- src/registry.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/registry.py b/src/registry.py index 02289357a..5bd5b09b1 100644 --- a/src/registry.py +++ b/src/registry.py @@ -539,7 +539,10 @@ class Regexp(Value): self.__parent.__init__(*args, **kwargs) def error(self, e): - self.__parent.error('Value must be a regexp of the form %s' % e) + s = 'Value must be a regexp of the form m/.../ or /.../. %s' % e + e = InvalidRegistryValue(s) + e.value = self + raise e def set(self, s): try: From 9c5f05ab2ddba79194d696714e389a65c7a7526a Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Wed, 21 Apr 2010 01:24:13 -0400 Subject: [PATCH 074/243] update quotegrabs to sqlite3 --- plugins/QuoteGrabs/plugin.py | 112 ++++++++++++++++++++--------------- 1 file changed, 64 insertions(+), 48 deletions(-) diff --git a/plugins/QuoteGrabs/plugin.py b/plugins/QuoteGrabs/plugin.py index 03d3a98d6..e672f2fdf 100644 --- a/plugins/QuoteGrabs/plugin.py +++ b/plugins/QuoteGrabs/plugin.py @@ -41,6 +41,15 @@ import supybot.plugins as plugins import supybot.ircutils as ircutils import supybot.callbacks as callbacks +try: + import sqlite3 +except ImportError: + from pysqlite2 import dbapi2 as sqlite3 # for python2.4 + +import traceback + +#sqlite3.register_converter('bool', bool) + class QuoteGrabsRecord(dbi.Record): __fields__ = [ 'by', @@ -65,29 +74,27 @@ class SqliteQuoteGrabsDB(object): db.close() def _getDb(self, channel): - try: - import sqlite - except ImportError: - raise callbacks.Error, 'You need to have PySQLite installed to ' \ - 'use QuoteGrabs. Download it at ' \ - '' filename = plugins.makeChannelFilename(self.filename, channel) def p(s1, s2): - return int(ircutils.nickEqual(s1, s2)) + # text_factory seems to only apply as an output adapter, + # so doesn't apply to created functions; so we use str() + return ircutils.nickEqual(str(s1), str(s2)) if filename in self.dbs: return self.dbs[filename] if os.path.exists(filename): - self.dbs[filename] = sqlite.connect(filename, - converters={'bool': bool}) - self.dbs[filename].create_function('nickeq', 2, p) - return self.dbs[filename] - db = sqlite.connect(filename, converters={'bool': bool}) + db = sqlite3.connect(filename) + db.text_factory = str + db.create_function('nickeq', 2, p) + self.dbs[filename] = db + return db + db = sqlite3.connect(filename) + db.text_factory = str + db.create_function('nickeq', 2, p) self.dbs[filename] = db - self.dbs[filename].create_function('nickeq', 2, p) cursor = db.cursor() cursor.execute("""CREATE TABLE quotegrabs ( id INTEGER PRIMARY KEY, - nick TEXT, + nick BLOB, hostmask TEXT, added_by TEXT, added_at TIMESTAMP, @@ -100,10 +107,11 @@ class SqliteQuoteGrabsDB(object): db = self._getDb(channel) cursor = db.cursor() cursor.execute("""SELECT id, nick, quote, hostmask, added_at, added_by - FROM quotegrabs WHERE id = %s""", id) - if cursor.rowcount == 0: + FROM quotegrabs WHERE id = ?""", (id,)) + results = cursor.fetchall() + if len(results) == 0: raise dbi.NoRecordError - (id, by, quote, hostmask, at, grabber) = cursor.fetchone() + (id, by, quote, hostmask, at, grabber) = results[0] return QuoteGrabsRecord(id, by=by, text=quote, hostmask=hostmask, at=int(at), grabber=grabber) @@ -112,46 +120,50 @@ class SqliteQuoteGrabsDB(object): cursor = db.cursor() if nick: cursor.execute("""SELECT quote FROM quotegrabs - WHERE nickeq(nick, %s) + WHERE nickeq(nick, ?) ORDER BY random() LIMIT 1""", - nick) + (nick,)) else: cursor.execute("""SELECT quote FROM quotegrabs ORDER BY random() LIMIT 1""") - if cursor.rowcount == 0: + results = cursor.fetchall() + if len(results) == 0: raise dbi.NoRecordError - return cursor.fetchone()[0] + return results[0][0] def list(self, channel, nick): db = self._getDb(channel) cursor = db.cursor() cursor.execute("""SELECT id, quote FROM quotegrabs - WHERE nickeq(nick, %s) - ORDER BY id DESC""", nick) - if cursor.rowcount == 0: + WHERE nickeq(nick, ?) + ORDER BY id DESC""", (nick,)) + results = cursor.fetchall() + if len(results) == 0: raise dbi.NoRecordError return [QuoteGrabsRecord(id, text=quote) - for (id, quote) in cursor.fetchall()] + for (id, quote) in results] def getQuote(self, channel, nick): db = self._getDb(channel) cursor = db.cursor() cursor.execute("""SELECT quote FROM quotegrabs - WHERE nickeq(nick, %s) - ORDER BY id DESC LIMIT 1""", nick) - if cursor.rowcount == 0: + WHERE nickeq(nick, ?) + ORDER BY id DESC LIMIT 1""", (nick,)) + results = cursor.fetchall() + if len(results) == 0: raise dbi.NoRecordError - return cursor.fetchone()[0] + return results[0][0] def select(self, channel, nick): db = self._getDb(channel) cursor = db.cursor() cursor.execute("""SELECT added_at FROM quotegrabs - WHERE nickeq(nick, %s) - ORDER BY id DESC LIMIT 1""", nick) - if cursor.rowcount == 0: + WHERE nickeq(nick, ?) + ORDER BY id DESC LIMIT 1""", (nick,)) + results = cursor.fetchall() + if len(results) == 0: raise dbi.NoRecordError - return cursor.fetchone()[0] + return results[0][0] def add(self, channel, msg, by): db = self._getDb(channel) @@ -159,14 +171,15 @@ class SqliteQuoteGrabsDB(object): text = ircmsgs.prettyPrint(msg) # Check to see if the latest quotegrab is identical cursor.execute("""SELECT quote FROM quotegrabs - WHERE nick=%s - ORDER BY id DESC LIMIT 1""", msg.nick) - if cursor.rowcount != 0: - if text == cursor.fetchone()[0]: + WHERE nick=? + ORDER BY id DESC LIMIT 1""", (msg.nick,)) + results = cursor.fetchall() + if len(results) != 0: + if text == results[0][0]: return cursor.execute("""INSERT INTO quotegrabs - VALUES (NULL, %s, %s, %s, %s, %s)""", - msg.nick, msg.prefix, by, int(time.time()), text) + VALUES (NULL, ?, ?, ?, ?, ?)""", + (msg.nick, msg.prefix, by, int(time.time()), text,)) db.commit() def remove(self, channel, grab=None): @@ -177,14 +190,16 @@ class SqliteQuoteGrabsDB(object): # strictly unnecessary -- the DELETE operation would "succeed" # anyway, but it's silly to just keep saying 'OK' no matter what, # so... - cursor.execute("""SELECT * FROM quotegrabs WHERE id = %s""", grab) - if cursor.rowcount == 0: + cursor.execute("""SELECT * FROM quotegrabs WHERE id = ?""", (grab,)) + results = cursor.fetchall() + if len(results) == 0: raise dbi.NoRecordError - cursor.execute("""DELETE FROM quotegrabs WHERE id = %s""", grab) + cursor.execute("""DELETE FROM quotegrabs WHERE id = ?""", (grab,)) else: cursor.execute("""SELECT * FROM quotegrabs WHERE id = (SELECT MAX(id) FROM quotegrabs)""") - if cursor.rowcount == 0: + results = cursor.fetchall() + if len(results) == 0: raise dbi.NoRecordError cursor.execute("""DELETE FROM quotegrabs WHERE id = (SELECT MAX(id) FROM quotegrabs)""") @@ -195,14 +210,15 @@ class SqliteQuoteGrabsDB(object): cursor = db.cursor() text = '%' + text + '%' cursor.execute("""SELECT id, nick, quote FROM quotegrabs - WHERE quote LIKE %s - ORDER BY id DESC""", text) - if cursor.rowcount == 0: + WHERE quote LIKE ? + ORDER BY id DESC""", (text,)) + results = cursor.fetchall() + if len(results) == 0: raise dbi.NoRecordError return [QuoteGrabsRecord(id, text=quote, by=nick) - for (id, nick, quote) in cursor.fetchall()] + for (id, nick, quote) in results] -QuoteGrabsDB = plugins.DB('QuoteGrabs', {'sqlite': SqliteQuoteGrabsDB}) +QuoteGrabsDB = plugins.DB('QuoteGrabs', {'sqlite3': SqliteQuoteGrabsDB}) class QuoteGrabs(callbacks.Plugin): """Add the help for "@help QuoteGrabs" here.""" From 3005752c58d5ca35a8bd54358833265fa75d9d39 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Wed, 21 Apr 2010 16:38:25 -0400 Subject: [PATCH 075/243] fix docstring for Plugin.plugin command so it actually says what the command will do. also add a Plugin.plugins command, which always returns a list of all plugins containing a command. add a test for it. --- plugins/Plugin/plugin.py | 37 ++++++++++++++++++++++++++++++++++++- plugins/Plugin/test.py | 8 ++++++-- 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/plugins/Plugin/plugin.py b/plugins/Plugin/plugin.py index 6753125ce..136e2fd44 100644 --- a/plugins/Plugin/plugin.py +++ b/plugins/Plugin/plugin.py @@ -67,7 +67,10 @@ class Plugin(callbacks.Plugin): def plugin(self, irc, msg, args, command): """ - Returns the plugin(s) that is in. + Returns the name of the plugin that would be used to call . + + If it is not uniquely determined, returns list of all plugins that + contain . """ (maxL, cbs) = irc.findCallbacksForArgs(command) L = [] @@ -89,6 +92,38 @@ class Plugin(callbacks.Plugin): irc.error(format('There is no command %q.', command)) plugin = wrap(plugin, [many('something')]) + def _findCallbacks(self, irc, command): + command = map(callbacks.canonicalName, command) + plugin_list = [] + for cb in irc.callbacks: + if not hasattr(cb, 'getCommand'): + continue + commandlist = cb.getCommand(command) + if commandlist: + plugin_list.append(cb.name()) + return plugin_list + + def plugins(self, irc, msg, args, command): + """ + + Returns the names of all plugins that contain . + """ + L = self._findCallbacks(irc, command) + command = callbacks.formatCommand(command) + if L: + if irc.nested: + irc.reply(format('%L', L)) + else: + if len(L) > 1: + plugin = 'plugins' + else: + plugin = 'plugin' + irc.reply(format('The %q command is available in the %L %s.', + command, L, plugin)) + else: + irc.error(format('There is no command %q.', command)) + plugins = wrap(plugins, [many('something')]) + def author(self, irc, msg, args, cb): """ diff --git a/plugins/Plugin/test.py b/plugins/Plugin/test.py index b5715adea..75bc52a96 100644 --- a/plugins/Plugin/test.py +++ b/plugins/Plugin/test.py @@ -30,11 +30,15 @@ from supybot.test import * class PluginTestCase(PluginTestCase): - plugins = ('Plugin', 'Utilities') + plugins = ('Plugin', 'Utilities', 'Admin', 'Format') def testPlugin(self): self.assertRegexp('plugin plugin', 'available.*Plugin plugin') self.assertResponse('echo [plugin plugin]', 'Plugin') - + + def testPlugins(self): + self.assertRegexp('plugins join', '(Format.*Admin|Admin.*Format)') + self.assertRegexp('plugins plugin', 'Plugin') + def testList(self): self.assertRegexp('plugin list', 'Plugin.*Utilities') From 55eeb4a57bb449d7cde0e78c893ac321e7de13a2 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Sun, 25 Apr 2010 00:34:31 -0400 Subject: [PATCH 076/243] Add some sanity checks to RSS plugin: First: if an rss feed is called without the number of headlines argument, we now have a default number of items it will output. before, it tried to stuff the whole rss feed into the channel, which is quite floody, if more than one 'mores' is set, or if oneToOne is false. Second: when adding a new feed to announce, it again, tried to stuff the whole rss feed into the channel, which ran into the same floody conditions as above. now we have a default number of feeds to output when there's no cached history. --- plugins/RSS/config.py | 7 +++++++ plugins/RSS/plugin.py | 4 ++++ 2 files changed, 11 insertions(+) diff --git a/plugins/RSS/config.py b/plugins/RSS/config.py index f36e9dc4b..1027a462d 100644 --- a/plugins/RSS/config.py +++ b/plugins/RSS/config.py @@ -70,6 +70,13 @@ conf.registerChannelValue(RSS, 'showLinks', along with the title of the feed when the rss command is called. supybot.plugins.RSS.announce.showLinks affects whether links will be listed when a feed is automatically announced.""")) +conf.registerGlobalValue(RSS, 'defaultNumberOfHeadlines', + registry.PositiveInteger(1, """Indicates how many headlines an rss feed + will output by default, if no number is provided.""")) +conf.registerChannelValue(RSS, 'initialAnnounceHeadlines', + registry.PositiveInteger(5, """Indicates how many headlines an rss feed + will output when it is first added to announce for a channel.""")) + conf.registerGroup(RSS, 'announce') conf.registerChannelValue(RSS.announce, 'showLinks', diff --git a/plugins/RSS/plugin.py b/plugins/RSS/plugin.py index 9f5a4d591..6baf7498f 100644 --- a/plugins/RSS/plugin.py +++ b/plugins/RSS/plugin.py @@ -183,6 +183,8 @@ class RSS(callbacks.Plugin): newheadlines = filter(None, newheadlines) # Removes Nones. if newheadlines: for channel in channels: + if len(oldheadlines) == 0: + newheadlines = newheadlines[:self.registryValue('initialAnnounceHeadlines', channel)] bold = self.registryValue('bold', channel) sep = self.registryValue('headlineSeparator', channel) prefix = self.registryValue('announcementPrefix', channel) @@ -392,6 +394,8 @@ class RSS(callbacks.Plugin): headlines = self.buildHeadlines(headlines, channel, 'showLinks') if n: headlines = headlines[:n] + else: + headlines = headlines[:self.registryValue('defaultNumberOfHeadlines')] sep = self.registryValue('headlineSeparator', channel) if self.registryValue('bold', channel): sep = ircutils.bold(sep) From f1517a7accbb0d24b80bb8dc3fab923d7a5cc4c7 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Sun, 25 Apr 2010 02:58:43 -0400 Subject: [PATCH 077/243] some enhancements Factoids.rank: output options: plain key output, and alpha sorting for plain output. allow an optional argument for how many ranked facts to show. --- plugins/Factoids/plugin.py | 41 +++++++++++++++++++++++++++++--------- plugins/Factoids/test.py | 5 +++++ 2 files changed, 37 insertions(+), 9 deletions(-) diff --git a/plugins/Factoids/plugin.py b/plugins/Factoids/plugin.py index f5193a003..02dd18f27 100644 --- a/plugins/Factoids/plugin.py +++ b/plugins/Factoids/plugin.py @@ -380,26 +380,49 @@ class Factoids(callbacks.Plugin, plugins.ChannelDBHandler): alias = wrap(alias, ['channel', 'something', 'something', optional('int')]) - def rank(self, irc, msg, args, channel): - """[] + def rank(self, irc, msg, args, channel, optlist, number): + """[] [--plain] [--alpha] [] Returns a list of top-ranked factoid keys, sorted by usage count - (rank). The number of factoid keys returned is set by the - rankListLength registry value. is only necessary if the - message isn't sent in the channel itself. + (rank). If is not provided, the default number of factoid keys + returned is set by the rankListLength registry value. + + If --plain option is given, rank numbers and usage counts are not + included in output. + + If --alpha option is given in addition to --plain, keys are sorted + alphabetically, instead of by rank. + + is only necessary if the message isn't sent in the channel + itself. """ - numfacts = self.registryValue('rankListLength', channel) + if not number: + number = self.registryValue('rankListLength', channel) db = self.getDb(channel) cursor = db.cursor() cursor.execute("""SELECT keys.key, relations.usage_count FROM keys, relations WHERE relations.key_id=keys.id ORDER BY relations.usage_count DESC - LIMIT ?""", (numfacts,)) + LIMIT ?""", (number,)) factkeys = cursor.fetchall() - s = [ "#%d %s (%d)" % (i+1, key[0], key[1]) for i, key in enumerate(factkeys) ] + plain=False + alpha=False + for (option, arg) in optlist: + if option == 'plain': + plain = True + elif option =='alpha': + alpha = True + if plain: + s = [ "%s" % (key[0],) for i, key in enumerate(factkeys) ] + if alpha: + s.sort() + else: + s = [ "#%d %s (%d)" % (i+1, key[0], key[1]) for i, key in enumerate(factkeys) ] irc.reply(", ".join(s)) - rank = wrap(rank, ['channel']) + rank = wrap(rank, ['channel', + getopts({'plain': '', 'alpha': '',}), + optional('int')]) def lock(self, irc, msg, args, channel, key): """[] diff --git a/plugins/Factoids/test.py b/plugins/Factoids/test.py index 22595c329..46782c239 100644 --- a/plugins/Factoids/test.py +++ b/plugins/Factoids/test.py @@ -189,6 +189,11 @@ class FactoidsTestCase(ChannelPluginTestCase): self.assertRegexp('factoids rank', '#1 foo \(0\), #2 moo \(0\)') self.assertRegexp('whatis moo', '.*cow.*') self.assertRegexp('factoids rank', '#1 moo \(1\), #2 foo \(0\)') + self.assertRegexp('factoids rank 1', '#1 moo \(1\)') + self.assertNotRegexp('factoids rank 1', 'foo') + self.assertRegexp('factoids rank --plain', 'moo, foo') + self.assertRegexp('factoids rank --plain --alpha', 'foo, moo') + self.assertResponse('factoids rank --plain 1', 'moo') def testQuoteHandling(self): self.assertNotError('learn foo as "\\"bar\\""') From 053a9d590eae5657c540109b20453bbd828fa7b9 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Sun, 25 Apr 2010 23:24:42 -0400 Subject: [PATCH 078/243] Improve supybot-botchk documentation. Make a note that supybot.pidFile config must be set for it to work. --- scripts/supybot-botchk | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/supybot-botchk b/scripts/supybot-botchk index ab5366c7a..468bead4f 100644 --- a/scripts/supybot-botchk +++ b/scripts/supybot-botchk @@ -72,7 +72,9 @@ if __name__ == '__main__': parser.add_option('', '--pidfile', help='Determines what file to look in for the pid of ' 'the running bot. This should be relative to the ' - 'given bot directory.') + 'given bot directory. Note that for this to actually ' + 'work, you have to make a matching entry in the ' + 'supybot.pidFile config in the supybot registry.') parser.add_option('', '--supybot', default='supybot', help='Determines where the supybot executable is ' 'located. If not given, assumes that supybot is ' From 2bb3ba94433f489305cb6759b3cd4666855aff18 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Mon, 26 Apr 2010 19:50:08 -0400 Subject: [PATCH 079/243] fix bug in RSS.announce.list: Because the 'channel' argument was declared optional, calling announce.list off-channel without a channel argument caused an error. --- plugins/RSS/plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/RSS/plugin.py b/plugins/RSS/plugin.py index 6baf7498f..1a5d83f09 100644 --- a/plugins/RSS/plugin.py +++ b/plugins/RSS/plugin.py @@ -339,7 +339,7 @@ class RSS(callbacks.Plugin): announce = conf.supybot.plugins.RSS.announce feeds = format('%L', list(announce.get(channel)())) irc.reply(feeds or 'I am currently not announcing any feeds.') - list = wrap(list, [optional('channel')]) + list = wrap(list, ['channel',]) def add(self, irc, msg, args, channel, feeds): """[] [ ...] From a819c5b4967ca806d29449eb7bbf750b1fd06314 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Tue, 27 Apr 2010 12:46:22 -0400 Subject: [PATCH 080/243] make Misc.apropos return plugin name even if command is in only one plugin. --- plugins/Misc/plugin.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/plugins/Misc/plugin.py b/plugins/Misc/plugin.py index c67068069..a1412f32e 100644 --- a/plugins/Misc/plugin.py +++ b/plugins/Misc/plugin.py @@ -170,11 +170,8 @@ class Misc(callbacks.Plugin): if s in command: commands.setdefault(command, []).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)) + for name in names: + L.append('%s %s' % (name, key)) if L: L.sort() irc.reply(format('%L', L)) From 71f88caa6bd1e1c955f2a777165c13dc8d831937 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Tue, 27 Apr 2010 15:28:09 -0400 Subject: [PATCH 081/243] Clarify the on-error log message in MessageParser function caller. --- plugins/MessageParser/plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/MessageParser/plugin.py b/plugins/MessageParser/plugin.py index c7e13d19b..7995e1677 100644 --- a/plugins/MessageParser/plugin.py +++ b/plugins/MessageParser/plugin.py @@ -121,7 +121,7 @@ class MessageParser(callbacks.Plugin, plugins.ChannelDBHandler): try: self.Proxy(irc.irc, msg, tokens) except Exception, e: - log.exception('Uncaught exception in scheduled function:') + log.exception('Uncaught exception in function called by MessageParser:') def _checkManageCapabilities(self, irc, msg, channel): """Check if the user has any of the required capabilities to manage From 976ad82d699c0f65b30b5594c0b207ad58a0b6e5 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Wed, 28 Apr 2010 00:10:48 -0400 Subject: [PATCH 082/243] Use the ircutils.standardSubsitute function upon factoid output. This allows inclusion of the usual standardSubstitute vars within factoids. There is no config to disable this, since it is possible to escape the substitutions by simply doubling the dollar signs, as per the python documentation: http://docs.python.org/library/string.html#template-strings Thus, if you want a factoid to output a literal "$channel", for example, all you'd need to do is use "$$channel" in your factoid text, which will come out as "$channel" when said by the bot. Also added tests for this new behavior. --- plugins/Factoids/plugin.py | 10 +++++++--- plugins/Factoids/test.py | 8 ++++++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/plugins/Factoids/plugin.py b/plugins/Factoids/plugin.py index 02dd18f27..09cae4c38 100644 --- a/plugins/Factoids/plugin.py +++ b/plugins/Factoids/plugin.py @@ -243,7 +243,8 @@ class Factoids(callbacks.Plugin, plugins.ChannelDBHandler): if factoids: if number: try: - irc.reply(factoids[number-1][0]) + irc.reply(ircutils.standardSubstitute(irc, msg, + factoids[number-1][0])) self._updateRank(channel, [factoids[number-1]]) except IndexError: irc.error('That\'s not a valid number for that key.') @@ -256,12 +257,15 @@ class Factoids(callbacks.Plugin, plugins.ChannelDBHandler): return ircutils.standardSubstitute(irc, msg, formatter, env) if len(factoids) == 1: - irc.reply(prefixer(factoids[0][0])) + irc.reply(ircutils.standardSubstitute(irc, msg, + prefixer(factoids[0][0]))) else: factoidsS = [] counter = 1 for factoid in factoids: - factoidsS.append(format('(#%i) %s', counter, factoid[0])) + factoidsS.append(format('(#%i) %s', counter, + ircutils.standardSubstitute(irc, msg, + factoid[0]))) counter += 1 irc.replies(factoidsS, prefixer=prefixer, joiner=', or ', onlyPrefixFirst=True) diff --git a/plugins/Factoids/test.py b/plugins/Factoids/test.py index 46782c239..4c56f6698 100644 --- a/plugins/Factoids/test.py +++ b/plugins/Factoids/test.py @@ -174,6 +174,14 @@ class FactoidsTestCase(ChannelPluginTestCase): self.assertNotError('learn foob as barb') self.assertRegexp('whatis foom', 'foo.*foob') + def testStandardSubstitute(self): + self.assertNotError('learn foo as this is $channel, and hour is $hour') + self.assertRegexp('whatis foo', 'this is #test, and hour is \d{1,2}') + self.assertNotError('learn bar as this is $$channel escaped') + self.assertRegexp('whatis bar', 'this is \$channel') + self.assertNotError('learn bar as this is $minute') + self.assertRegexp('whatis bar', '\$channel.*\d{1,2}') + def testAlias(self): self.assertNotError('learn foo as bar') self.assertNotError('alias foo zoog') From e4c51ef51753216e1531704f01bbb1689376539f Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Wed, 28 Apr 2010 15:27:08 -0400 Subject: [PATCH 083/243] Add --raw option to factoids.whatis, which disables variable substitution on the factoid. also add test for this. --- plugins/Factoids/plugin.py | 37 ++++++++++++++++++++++++------------- plugins/Factoids/test.py | 1 + 2 files changed, 25 insertions(+), 13 deletions(-) diff --git a/plugins/Factoids/plugin.py b/plugins/Factoids/plugin.py index 09cae4c38..bb55ce6ca 100644 --- a/plugins/Factoids/plugin.py +++ b/plugins/Factoids/plugin.py @@ -239,12 +239,17 @@ class Factoids(callbacks.Plugin, plugins.ChannelDBHandler): db.commit() def _replyFactoids(self, irc, msg, key, channel, factoids, - number=0, error=True): + number=0, error=True, raw=False): + def format_fact(text): + if raw: + return text + else: + return ircutils.standardSubstitute(irc, msg, text) + if factoids: if number: try: - irc.reply(ircutils.standardSubstitute(irc, msg, - factoids[number-1][0])) + irc.reply(format_fact(factoids[number-1][0])) self._updateRank(channel, [factoids[number-1]]) except IndexError: irc.error('That\'s not a valid number for that key.') @@ -257,15 +262,13 @@ class Factoids(callbacks.Plugin, plugins.ChannelDBHandler): return ircutils.standardSubstitute(irc, msg, formatter, env) if len(factoids) == 1: - irc.reply(ircutils.standardSubstitute(irc, msg, - prefixer(factoids[0][0]))) + irc.reply(format_fact(prefixer(factoids[0][0]))) else: factoidsS = [] counter = 1 for factoid in factoids: factoidsS.append(format('(#%i) %s', counter, - ircutils.standardSubstitute(irc, msg, - factoid[0]))) + format_fact(factoid[0]))) counter += 1 irc.replies(factoidsS, prefixer=prefixer, joiner=', or ', onlyPrefixFirst=True) @@ -294,13 +297,19 @@ class Factoids(callbacks.Plugin, plugins.ChannelDBHandler): else: self._replyApproximateFactoids(irc, msg, channel, key, error=False) - def whatis(self, irc, msg, args, channel, words): - """[] [] + def whatis(self, irc, msg, args, channel, optlist, words): + """[] [--raw] [] Looks up the value of in the factoid database. If given a - number, will return only that exact factoid. is only - necessary if the message isn't sent in the channel itself. + number, will return only that exact factoid. If '--raw' option is + given, no variable substitution will take place on the factoid. + is only necessary if the message isn't sent in the channel + itself. """ + raw = False + for (option, arg) in optlist: + if option == 'raw': + raw = True number = None if len(words) > 1: if words[-1].isdigit(): @@ -310,10 +319,12 @@ class Factoids(callbacks.Plugin, plugins.ChannelDBHandler): key = ' '.join(words) factoids = self._lookupFactoid(channel, key) if factoids: - self._replyFactoids(irc, msg, key, channel, factoids, number) + self._replyFactoids(irc, msg, key, channel, factoids, number, raw=raw) else: self._replyApproximateFactoids(irc, msg, channel, key) - whatis = wrap(whatis, ['channel', many('something')]) + whatis = wrap(whatis, ['channel', + getopts({'raw': '',}), + many('something')]) def alias(self, irc, msg, args, channel, oldkey, newkey, number): """[] [] diff --git a/plugins/Factoids/test.py b/plugins/Factoids/test.py index 4c56f6698..035e4efed 100644 --- a/plugins/Factoids/test.py +++ b/plugins/Factoids/test.py @@ -177,6 +177,7 @@ class FactoidsTestCase(ChannelPluginTestCase): def testStandardSubstitute(self): self.assertNotError('learn foo as this is $channel, and hour is $hour') self.assertRegexp('whatis foo', 'this is #test, and hour is \d{1,2}') + self.assertRegexp('whatis --raw foo', 'this is \$channel, and hour is \$hour') self.assertNotError('learn bar as this is $$channel escaped') self.assertRegexp('whatis bar', 'this is \$channel') self.assertNotError('learn bar as this is $minute') From 9c12f80285c3cee3ce23eec39c7971f2b662ee3b Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Thu, 29 Apr 2010 20:04:51 -0400 Subject: [PATCH 084/243] Add Later note expiration period, 30 days by default. This should prevent the accumulation of old unclaimed notes in the database, which is possible due to notes left to misspelled nicks, to temporary nicks used by regulars, or to one-time visitor nicks. --- plugins/Later/config.py | 6 +++++- plugins/Later/plugin.py | 27 +++++++++++++++++++++++++++ plugins/Later/test.py | 10 ++++++++++ 3 files changed, 42 insertions(+), 1 deletion(-) diff --git a/plugins/Later/config.py b/plugins/Later/config.py index 9ae208bc0..9eeddff0a 100644 --- a/plugins/Later/config.py +++ b/plugins/Later/config.py @@ -48,5 +48,9 @@ conf.registerGlobalValue(Later, 'private', conf.registerGlobalValue(Later, 'tellOnJoin', registry.Boolean(True, """Determines whether users will be notified upon joining any channel the bot is in, or only upon sending a message.""")) - +conf.registerGlobalValue(Later, 'messageExpiry', + registry.NonNegativeInteger(30, """Determines the maximum number of + days that a message will remain queued for a user. After this time elapses, + the message will be deleted. If this value is 0, there is no maximum.""")) + # vim:set shiftwidth=4 softtabstop=4 expandtab textwidth=79: diff --git a/plugins/Later/plugin.py b/plugins/Later/plugin.py index e718c1313..78d6c4423 100644 --- a/plugins/Later/plugin.py +++ b/plugins/Later/plugin.py @@ -30,6 +30,7 @@ import csv import time +import datetime import supybot.log as log import supybot.conf as conf @@ -118,6 +119,31 @@ class Later(callbacks.Plugin): return nick[:-1] return nick + def _deleteExpired(self): + expiry = self.registryValue('messageExpiry') + curtime = time.time() + nickremovals=[] + for (nick, notes) in self._notes.iteritems(): + removals = [] + for (notetime, whence, text) in notes: + td = datetime.timedelta(seconds=(curtime - notetime)) + if td.days > expiry: + removals.append((notetime, whence, text)) + for note in removals: + notes.remove(note) + if len(notes) == 0: + nickremovals.append(nick) + for nick in nickremovals: + del self._notes[nick] + self._flushNotes() + + ## Note: we call _deleteExpired from 'tell'. This means that it's possible + ## for expired notes to remain in the database for longer than the maximum, + ## if no tell's are called. + ## However, the whole point of this is to avoid crud accumulation in the + ## database, so it's fine that we only delete expired notes when we try + ## adding new ones. + def tell(self, irc, msg, args, nick, text): """ @@ -125,6 +151,7 @@ class Later(callbacks.Plugin): contain wildcard characters, and the first matching nick will be given the note. """ + self._deleteExpired() if ircutils.strEqual(nick, irc.nick): irc.error('I can\'t send notes to myself.') return diff --git a/plugins/Later/test.py b/plugins/Later/test.py index 447f088fd..5dd9d5259 100644 --- a/plugins/Later/test.py +++ b/plugins/Later/test.py @@ -28,6 +28,7 @@ ### from supybot.test import * +import time class LaterTestCase(PluginTestCase): plugins = ('Later',) @@ -52,5 +53,14 @@ class LaterTestCase(PluginTestCase): self.assertRegexp('later notes', 'foo\.') conf.supybot.protocols.irc.strictRfc.setValue(origconf) + def testNoteExpiry(self): + cb = self.irc.getCallback('Later') + # add a note 40 days in the past + cb._addNote('foo', 'test', 'some stuff', at=(time.time() - 3456000)) + self.assertRegexp('later notes', 'foo') + self.assertNotError('later tell moo stuff') + self.assertNotRegexp('later notes', 'foo') + self.assertRegexp('later notes', 'moo') + # vim:set shiftwidth=4 softtabstop=4 expandtab textwidth=79: From 5d15bbf1b26aa799c886a87ba2af7f96d7624636 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Thu, 29 Apr 2010 20:20:36 -0400 Subject: [PATCH 085/243] for Later plugin, add test for actual sending of notes to nicks upon their being seen. --- plugins/Later/test.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/plugins/Later/test.py b/plugins/Later/test.py index 5dd9d5259..49a5a672e 100644 --- a/plugins/Later/test.py +++ b/plugins/Later/test.py @@ -30,7 +30,7 @@ from supybot.test import * import time -class LaterTestCase(PluginTestCase): +class LaterTestCase(ChannelPluginTestCase): plugins = ('Later',) def testLaterWorksTwice(self): self.assertNotError('later tell foo bar') @@ -62,5 +62,17 @@ class LaterTestCase(PluginTestCase): self.assertNotRegexp('later notes', 'foo') self.assertRegexp('later notes', 'moo') + def testNoteSend(self): + self.assertNotError('later tell foo stuff') + self.assertNotError('later tell bar more stuff') + self.assertRegexp('later notes', 'bar.*foo') + testPrefix = 'foo!bar@baz' + self.irc.feedMsg(ircmsgs.privmsg(self.channel, 'something', + prefix=testPrefix)) + m = self.getMsg(' ') + self.failUnless(str(m).startswith('PRIVMSG foo :Sent just now: stuff')) + self.assertNotRegexp('later notes', 'foo') + self.assertRegexp('later notes', 'bar') + # vim:set shiftwidth=4 softtabstop=4 expandtab textwidth=79: From fc1a049d3cb799686d231e89acf222bbdb1ae1c5 Mon Sep 17 00:00:00 2001 From: James Vega Date: Tue, 11 May 2010 17:50:43 -0400 Subject: [PATCH 086/243] ChannelStats: Fix rank to work with selfStats Signed-off-by: James Vega (cherry picked from commit 41fd218b8d586c5a0fbab956f96cba1896d4231d) Signed-off-by: Daniel Folkinshteyn --- plugins/ChannelStats/plugin.py | 8 ++++++-- plugins/ChannelStats/test.py | 2 ++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/plugins/ChannelStats/plugin.py b/plugins/ChannelStats/plugin.py index a6b2725bb..d09129bed 100644 --- a/plugins/ChannelStats/plugin.py +++ b/plugins/ChannelStats/plugin.py @@ -315,7 +315,8 @@ class ChannelStats(callbacks.Plugin): expr = expr.lower() users = [] for ((c, id), stats) in self.db.items(): - if ircutils.strEqual(c, channel) and ircdb.users.hasUser(id): + if ircutils.strEqual(c, channel) and \ + (id == 0 or ircdb.users.hasUser(id)): e = self._env.copy() for attr in stats._values: e[attr] = float(getattr(stats, attr)) @@ -327,7 +328,10 @@ class ChannelStats(callbacks.Plugin): irc.errorInvalid('stat variable', str(e).split()[1]) except Exception, e: irc.error(utils.exnToString(e), Raise=True) - users.append((v, ircdb.users.getUser(id).name)) + if id == 0: + users.append((v, irc.nick)) + else: + users.append((v, ircdb.users.getUser(id).name)) users.sort() users.reverse() s = utils.str.commaAndify(['#%s %s (%.3g)' % (i+1, u, v) diff --git a/plugins/ChannelStats/test.py b/plugins/ChannelStats/test.py index 20dade708..40cea6d42 100644 --- a/plugins/ChannelStats/test.py +++ b/plugins/ChannelStats/test.py @@ -1,5 +1,6 @@ ### # Copyright (c) 2002-2004, Jeremiah Fincher +# Copyright (c) 2010, James Vega # All rights reserved. # # Redistribution and use in source and binary forms, with or without @@ -61,6 +62,7 @@ class ChannelStatsTestCase(ChannelPluginTestCase): self.assertNotError('channelstats stats %s' % self.irc.nick) self.assertNotError('channelstats stats %s' % self.irc.nick) self.assertNotError('channelstats stats %s' % self.irc.nick.upper()) + self.assertRegexp('channelstats rank chars', self.irc.nick) u = ircdb.users.getUser(self.prefix) u.addCapability(ircdb.makeChannelCapability(self.channel, 'op')) ircdb.users.setUser(u) From 51cabeea33f3dab3d7cf42496c10bd660b212bc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0t=C4=9Bp=C3=A1n=20N=C4=9Bmec?= Date: Thu, 28 Jan 2010 18:16:45 +0100 Subject: [PATCH 087/243] Topic plugin: Add persistence support. Topics now persist between restarts. Rationale: Useful when reloading/restarting; previously the topics would be just forgotten. Don't use database, as that would be an unnecessary overkill and in any case not needed. (Also, remove the unused `re' module import.) Signed-off-by: James Vega (cherry picked from commit 6520d1f2829f623bc8cc845e413fc18c6584b64b) Signed-off-by: Daniel Folkinshteyn --- plugins/Topic/__init__.py | 2 +- plugins/Topic/plugin.py | 49 +++++++++++++++++++++++++++++++++++---- src/__init__.py | 2 ++ 3 files changed, 48 insertions(+), 5 deletions(-) diff --git a/plugins/Topic/__init__.py b/plugins/Topic/__init__.py index b0137a617..38f4e7655 100644 --- a/plugins/Topic/__init__.py +++ b/plugins/Topic/__init__.py @@ -42,7 +42,7 @@ __author__ = supybot.authors.jemfinch # This is a dictionary mapping supybot.Author instances to lists of # contributions. -__contributors__ = {} +__contributors__ = { supybot.authors.stepnem: ['persistence support'] } import config import plugin diff --git a/plugins/Topic/plugin.py b/plugins/Topic/plugin.py index 098b55f55..3d9309ff0 100644 --- a/plugins/Topic/plugin.py +++ b/plugins/Topic/plugin.py @@ -27,17 +27,22 @@ # POSSIBILITY OF SUCH DAMAGE. ### -import re +import os import random +import shutil +import tempfile +import cPickle as pickle import supybot.conf as conf +import supybot.ircdb as ircdb import supybot.utils as utils +import supybot.world as world from supybot.commands import * import supybot.ircmsgs as ircmsgs import supybot.plugins as plugins import supybot.ircutils as ircutils import supybot.callbacks as callbacks -import supybot.ircdb as ircdb + def canChangeTopic(irc, msg, args, state): assert not state.channel @@ -96,6 +101,9 @@ addConverter('canChangeTopic', canChangeTopic) def splitTopic(topic, separator): return filter(None, topic.split(separator)) +datadir = conf.supybot.directories.data() +filename = conf.supybot.directories.data.dirize('Topic.pickle') + class Topic(callbacks.Plugin): def __init__(self, irc): self.__parent = super(Topic, self) @@ -104,6 +112,39 @@ class Topic(callbacks.Plugin): self.redos = ircutils.IrcDict() self.lastTopics = ircutils.IrcDict() self.watchingFor332 = ircutils.IrcSet() + try: + pkl = open(filename, 'rb') + try: + self.undos = pickle.load(pkl) + self.redos = pickle.load(pkl) + self.lastTopics = pickle.load(pkl) + self.watchingFor332 = pickle.load(pkl) + except Exception, e: + self.log.debug('Unable to load pickled data: %s', e) + pkl.close() + except IOError, e: + self.log.debug('Unable to open pickle file: %s', e) + world.flushers.append(self._flush) + + def die(self): + world.flushers.remove(self._flush) + self.__parent.die() + + def _flush(self): + try: + pklfd, tempfn = tempfile.mkstemp(suffix='topic', dir=datadir) + pkl = os.fdopen(pklfd, 'wb') + try: + pickle.dump(self.undos, pkl) + pickle.dump(self.redos, pkl) + pickle.dump(self.lastTopics, pkl) + pickle.dump(self.watchingFor332, pkl) + except Exception, e: + self.log.warning('Unable to store pickled data: %s', e) + pkl.close() + shutil.move(tempfn, filename) + except (IOError, shutil.Error), e: + self.log.warning('File error: %s', e) def _splitTopic(self, topic, channel): separator = self.registryValue('separator', channel) @@ -165,10 +206,10 @@ class Topic(callbacks.Plugin): def _checkManageCapabilities(self, irc, msg, channel): """Check if the user has any of the required capabilities to manage the channel topic. - + The list of required capabilities is in requireManageCapability channel config. - + Also allow if the user is a chanop. Since he can change the topic manually anyway. """ diff --git a/src/__init__.py b/src/__init__.py index 68ca7025d..22923e31a 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -1,3 +1,4 @@ +# coding: utf-8 ### # Copyright (c) 2002-2005, Jeremiah Fincher # All rights reserved. @@ -56,6 +57,7 @@ class authors(object): # This is basically a bag. bwp = Author('Brett Phipps', 'bwp', 'phippsb@gmail.com') bear = Author('Mike Taylor', 'bear', 'bear@code-bear.com') grantbow = Author('Grant Bowman', 'Grantbow', 'grantbow@grantbow.com') + stepnem = Author('Štěpán Němec', 'stepnem', 'stepnem@gmail.com') unknown = Author('Unknown author', 'unknown', 'unknown@supybot.org') # Let's be somewhat safe about this. From f3af3ec2822171a6f268b03baa336015c1cf0ff8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0t=C4=9Bp=C3=A1n=20N=C4=9Bmec?= Date: Wed, 26 Aug 2009 17:51:55 +0200 Subject: [PATCH 088/243] Topic plugin: Restore topic automatically after join if not set. Signed-off-by: James Vega (cherry picked from commit 18ef6dadfcafeae54778f6876b57ecc0b4135153) Signed-off-by: Daniel Folkinshteyn --- plugins/Topic/plugin.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/plugins/Topic/plugin.py b/plugins/Topic/plugin.py index 3d9309ff0..b51614c96 100644 --- a/plugins/Topic/plugin.py +++ b/plugins/Topic/plugin.py @@ -232,6 +232,21 @@ class Topic(callbacks.Plugin): # We're joining a channel, let's watch for the topic. self.watchingFor332.add(msg.args[0]) + def do315(self, irc, msg): + # Try to restore the topic when not set yet. + channel = msg.args[1] + c = irc.state.channels[channel] + if irc.nick not in c.ops and 't' in c.modes: + self.log.debug('Not trying to restore topic in %s. I\'m not opped ' + 'and %s is +t.', channel, channel) + return + if c.topic == '': + try: + topics = self.lastTopics[channel] + self._sendTopics(irc, channel, topics) + except KeyError: + self.log.debug('No topic to auto-restore in %s.', channel) + def do332(self, irc, msg): if msg.args[1] in self.watchingFor332: self.watchingFor332.remove(msg.args[1]) From f25d1546bf8432723e4f4b115b6d429e8e3f988e Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Thu, 13 May 2010 00:52:58 -0400 Subject: [PATCH 089/243] restore the 'import re' to Topic plugin, which was for some reason taken out two commits ago, in commit 51cabeea33f3dab3d7cf42496c10bd660b212bc7 it is necessary for the operation of the plugin (specifically, in the _checkManageCapabilities function) the tests didn't catch that because it apparently imports re separately. --- plugins/Topic/plugin.py | 1 + 1 file changed, 1 insertion(+) diff --git a/plugins/Topic/plugin.py b/plugins/Topic/plugin.py index b51614c96..ba80df9f0 100644 --- a/plugins/Topic/plugin.py +++ b/plugins/Topic/plugin.py @@ -27,6 +27,7 @@ # POSSIBILITY OF SUCH DAMAGE. ### +import re import os import random import shutil From 1a228b3e7d13c29fa15778d74fd06f1885d791a7 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Wed, 2 Jun 2010 18:36:27 -0400 Subject: [PATCH 090/243] fix google calc to work when doing a currency conversion. made the calcre more generic, so it finds stuff on both math and currency. nothing a little exploration of google html page source couldn't solve. --- plugins/Google/plugin.py | 2 +- plugins/Google/test.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/plugins/Google/plugin.py b/plugins/Google/plugin.py index edd9f829b..59b8f6ddd 100644 --- a/plugins/Google/plugin.py +++ b/plugins/Google/plugin.py @@ -349,7 +349,7 @@ class Google(callbacks.PluginRegexp): url = r'http://google.com/search?q=' + s return url - _calcRe = re.compile(r'(.*?)', re.I) + _calcRe = re.compile(r'(.*?)', re.I) _calcSupRe = re.compile(r'(.*?)', re.I) _calcFontRe = re.compile(r'(.*?)') _calcTimesRe = re.compile(r'&(?:times|#215);') diff --git a/plugins/Google/test.py b/plugins/Google/test.py index 0b73b504c..3d42ca085 100644 --- a/plugins/Google/test.py +++ b/plugins/Google/test.py @@ -38,6 +38,7 @@ class GoogleTestCase(ChannelPluginTestCase): def testCalc(self): self.assertNotRegexp('google calc e^(i*pi)+1', r'didn\'t') + self.assertNotRegexp('google calc 1 usd in gbp', r'didn\'t') def testHtmlHandled(self): self.assertNotRegexp('google calc ' From df2c6c2650a5c893df14abf11746ba217e5f8905 Mon Sep 17 00:00:00 2001 From: Jeremy Fincher Date: Tue, 18 May 2010 12:48:36 -0500 Subject: [PATCH 091/243] Updates and tweaks to some ircutils functions. (cherry picked from commit 6135a88741fcafa49bb2bd768cfc971cd7d58b5e) Signed-off-by: Daniel Folkinshteyn --- src/ircutils.py | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/src/ircutils.py b/src/ircutils.py index de13fbffe..c7b5408df 100644 --- a/src/ircutils.py +++ b/src/ircutils.py @@ -48,14 +48,10 @@ def debug(s, *args): """Prints a debug string. Most likely replaced by our logging debug.""" print '***', s % args +userHostmaskRe = re.compile(r'^\S+!\S+@\S+$') 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 + return userHostmaskRe.match(s) is not None def isServerHostmask(s): """s => bool @@ -66,19 +62,19 @@ def nickFromHostmask(hostmask): """hostmask => nick Returns the nick from a user hostmask.""" assert isUserHostmask(hostmask) - return hostmask.split('!', 1)[0] + return splitHostmask(hostmask)[0] def userFromHostmask(hostmask): """hostmask => user Returns the user from a user hostmask.""" assert isUserHostmask(hostmask) - return hostmask.split('!', 1)[1].split('@', 1)[0] + return splitHostmask(hostmask)[1] def hostFromHostmask(hostmask): """hostmask => host Returns the host from a user hostmask.""" assert isUserHostmask(hostmask) - return hostmask.split('@', 1)[1] + return splitHostmask(hostmask)[2] def splitHostmask(hostmask): """hostmask => (nick, user, host) @@ -86,13 +82,13 @@ def splitHostmask(hostmask): assert isUserHostmask(hostmask) nick, rest = hostmask.split('!', 1) user, host = rest.split('@', 1) - return (nick, user, host) + return (intern(nick), intern(user), intern(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) + return intern('%s!%s@%s' % (nick, ident, host)) _rfc1459trans = string.maketrans(string.ascii_uppercase + r'\[]~', string.ascii_lowercase + r'|{}^') From db479731b14a28e792ef7eff3c923b6747130d4f Mon Sep 17 00:00:00 2001 From: James Vega Date: Mon, 24 May 2010 15:21:58 -0400 Subject: [PATCH 092/243] Anonymous: Implement support for allowPrivateTarget config. Closes: Sf#2991515 Signed-off-by: James Vega (cherry picked from commit 57e894de589dcdf9c7a63f8279105420e0890674) Signed-off-by: Daniel Folkinshteyn --- plugins/Anonymous/plugin.py | 48 +++++++++++++++++++++---------------- plugins/Anonymous/test.py | 13 +++++++--- 2 files changed, 38 insertions(+), 23 deletions(-) diff --git a/plugins/Anonymous/plugin.py b/plugins/Anonymous/plugin.py index 18205306e..9eb95260d 100644 --- a/plugins/Anonymous/plugin.py +++ b/plugins/Anonymous/plugin.py @@ -1,5 +1,6 @@ ### # Copyright (c) 2005, Daniel DiPaolo +# Copyright (c) 2010, James Vega # All rights reserved. # # Redistribution and use in source and binary forms, with or without @@ -45,7 +46,7 @@ class Anonymous(callbacks.Plugin): that the user be registered by setting supybot.plugins.Anonymous.requireRegistration. """ - def _preCheck(self, irc, msg, channel): + def _preCheck(self, irc, msg, target, action): if self.registryValue('requireRegistration'): try: _ = ircdb.users.getUser(msg.prefix) @@ -55,35 +56,42 @@ class Anonymous(callbacks.Plugin): if capability: if not ircdb.checkCapability(msg.prefix, capability): irc.errorNoCapability(capability, Raise=True) - if self.registryValue('requirePresenceInChannel', channel) and \ - msg.nick not in irc.state.channels[channel].users: - irc.error(format('You must be in %s to %q in there.', - channel, 'say'), Raise=True) - c = ircdb.channels.getChannel(channel) - if c.lobotomized: - irc.error(format('I\'m lobotomized in %s.', channel), Raise=True) - if not c._checkCapability(self.name()): - irc.error('That channel has set its capabilities so as to ' - 'disallow the use of this plugin.', Raise=True) + if irc.isChannel(target): + if self.registryValue('requirePresenceInChannel', target) and \ + msg.nick not in irc.state.channels[target].users: + irc.error(format('You must be in %s to %q in there.', + target, action), Raise=True) + c = ircdb.channels.getChannel(target) + if c.lobotomized: + irc.error(format('I\'m lobotomized in %s.', target), + Raise=True) + if not c._checkCapability(self.name()): + irc.error('That channel has set its capabilities so as to ' + 'disallow the use of this plugin.', Raise=True) + elif action == 'say' and not self.registryValue('allowPrivateTarget'): + irc.error(format('%q cannot be used to send private messages.', + action), + Raise=True) - def say(self, irc, msg, args, channel, text): - """ + def say(self, irc, msg, args, target, text): + """ - Sends to . + Sends to . Can only send to if + supybot.plugins.Anonymous.allowPrivateTarget is True. """ - self._preCheck(irc, msg, channel) - self.log.info('Saying %q in %s due to %s.', - text, channel, msg.prefix) - irc.queueMsg(ircmsgs.privmsg(channel, text)) + self._preCheck(irc, msg, target, 'say') + self.log.info('Saying %q to %s due to %s.', + text, target, msg.prefix) + irc.queueMsg(ircmsgs.privmsg(target, text)) irc.noReply() - say = wrap(say, ['inChannel', 'text']) + say = wrap(say, [first('nick', 'inChannel'), 'text']) def do(self, irc, msg, args, channel, text): """ Performs in . """ - self._preCheck(irc, msg, channel) + self._preCheck(irc, msg, channel, 'do') self.log.info('Performing %q in %s due to %s.', text, channel, msg.prefix) irc.queueMsg(ircmsgs.action(channel, text)) diff --git a/plugins/Anonymous/test.py b/plugins/Anonymous/test.py index 728306348..4d2dd1575 100644 --- a/plugins/Anonymous/test.py +++ b/plugins/Anonymous/test.py @@ -1,5 +1,6 @@ ### # Copyright (c) 2005, Daniel DiPaolo +# Copyright (c) 2010, James Vega # All rights reserved. # # Redistribution and use in source and binary forms, with or without @@ -32,14 +33,20 @@ from supybot.test import * class AnonymousTestCase(ChannelPluginTestCase): plugins = ('Anonymous',) def testSay(self): - m = self.assertError('anonymous say %s I love you!' % self.channel) + self.assertError('anonymous say %s I love you!' % self.channel) + self.assertError('anonymous say %s I love you!' % self.nick) + origreg = conf.supybot.plugins.Anonymous.requireRegistration() + origpriv = conf.supybot.plugins.Anonymous.allowPrivateTarget() try: - orig = conf.supybot.plugins.Anonymous.requireRegistration() conf.supybot.plugins.Anonymous.requireRegistration.setValue(False) m = self.assertNotError('anonymous say %s foo!' % self.channel) self.failUnless(m.args[1] == 'foo!') + conf.supybot.plugins.Anonymous.allowPrivateTarget.setValue(True) + m = self.assertNotError('anonymous say %s foo!' % self.nick) + self.failUnless(m.args[1] == 'foo!') finally: - conf.supybot.plugins.Anonymous.requireRegistration.setValue(orig) + conf.supybot.plugins.Anonymous.requireRegistration.setValue(origreg) + conf.supybot.plugins.Anonymous.allowPrivateTarget.setValue(origpriv) def testAction(self): m = self.assertError('anonymous do %s loves you!' % self.channel) From bc8457dc9065db0b7a4e233599d58b4fc8df3ab9 Mon Sep 17 00:00:00 2001 From: James Vega Date: Mon, 24 May 2010 15:44:25 -0400 Subject: [PATCH 093/243] utils/web.py: Only try catching socket.sslerror if built with SSL support Closes: Sf#2998820 Signed-off-by: James Vega (cherry picked from commit f03a3f6c8529c15042908d826235e46123cd29e2) Signed-off-by: Daniel Folkinshteyn --- src/utils/web.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/utils/web.py b/src/utils/web.py index 1fcae3d96..6a1fa9361 100644 --- a/src/utils/web.py +++ b/src/utils/web.py @@ -37,6 +37,12 @@ import sgmllib import urlparse import htmlentitydefs +sockerrors = (socket.error,) +try: + sockerrors += (socket.sslerror,) +except AttributeError: + pass + from str import normalizeWhitespace Request = urllib2.Request @@ -112,7 +118,7 @@ def getUrlFd(url, headers=None, data=None): return fd except socket.timeout, e: raise Error, TIMED_OUT - except (socket.error, socket.sslerror), e: + except sockerrors, e: raise Error, strError(e) except httplib.InvalidURL, e: raise Error, 'Invalid URL: %s' % e From 108f7f2f86242bb10a867793e13a11e0a165a2e7 Mon Sep 17 00:00:00 2001 From: James Vega Date: Mon, 24 May 2010 23:36:29 -0400 Subject: [PATCH 094/243] Socket: Ensure driver is flagged as disconnected after a socket error. Users were occasionally hitting a situation where the socket had errored, causing a reconnect, but the socket wasn't closed nor the driver marked as disconnected. This resulted in run() continuing to try and use the driver, which would cause another error, schedule another reconnect, log an error, ad infinitum. Closes: Sf#2965530 Signed-off-by: James Vega (cherry picked from commit a278d17f2bec3697987cbd403594393a4fb30021) Signed-off-by: Daniel Folkinshteyn --- src/drivers/Socket.py | 34 ++++++++++++++++++++++++---------- 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/src/drivers/Socket.py b/src/drivers/Socket.py index 86c6d6ada..6db605a59 100644 --- a/src/drivers/Socket.py +++ b/src/drivers/Socket.py @@ -1,5 +1,6 @@ ### # Copyright (c) 2002-2004, Jeremiah Fincher +# Copyright (c) 2010, James Vega # All rights reserved. # # Redistribution and use in source and binary forms, with or without @@ -56,8 +57,9 @@ class SocketDriver(drivers.IrcDriver, drivers.ServersMixin): self.inbuffer = '' self.outbuffer = '' self.zombie = False - self.scheduled = None self.connected = False + self.writeCheckTime = None + self.nextReconnectTime = None self.resetDelay() # Only connect to non-SSL servers if self.networkGroup.get('ssl').value: @@ -87,6 +89,11 @@ class SocketDriver(drivers.IrcDriver, drivers.ServersMixin): # hasn't finished yet. We'll keep track of how many we get. if e.args[0] != 11 or self.eagains > 120: drivers.log.disconnect(self.currentServer, e) + try: + self.conn.close() + except: + pass + self.connected = False self.scheduleReconnect() else: log.debug('Got EAGAIN, current count: %s.', self.eagains) @@ -110,6 +117,11 @@ class SocketDriver(drivers.IrcDriver, drivers.ServersMixin): self._reallyDie() def run(self): + now = time.time() + if self.nextReconnectTime is not None and now > self.nextReconnectTime: + self.reconnect() + elif self.writeCheckTime is not None and now > self.writeCheckTime: + self._checkAndWriteOrReconnect() 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. @@ -137,7 +149,7 @@ class SocketDriver(drivers.IrcDriver, drivers.ServersMixin): self.reconnect(reset=False, **kwargs) def reconnect(self, reset=True): - self.scheduled = None + self.nextReconnectTime = None if self.connected: drivers.log.reconnect(self.irc.network) self.conn.close() @@ -172,13 +184,14 @@ class SocketDriver(drivers.IrcDriver, drivers.ServersMixin): whenS = log.timestamp(when) drivers.log.debug('Connection in progress, scheduling ' 'connectedness check for %s', whenS) - schedule.addEvent(self._checkAndWriteOrReconnect, when) + self.writeCheckTime = when else: drivers.log.connectError(self.currentServer, e) self.scheduleReconnect() return def _checkAndWriteOrReconnect(self): + self.writeCheckTime = None drivers.log.debug('Checking whether we are connected.') (_, w, _) = select.select([], [self.conn], [], 0) if w: @@ -193,18 +206,19 @@ class SocketDriver(drivers.IrcDriver, drivers.ServersMixin): when = time.time() + self.getDelay() if not world.dying: drivers.log.reconnect(self.irc.network, when) - if self.scheduled: - drivers.log.error('Scheduling a second reconnect when one is ' - 'already scheduled. This is a bug; please ' + if self.nextReconnectTime: + drivers.log.error('Updating next reconnect time when one is ' + 'already present. This is a bug; please ' 'report it, with an explanation of what caused ' 'this to happen.') - schedule.removeEvent(self.scheduled) - self.scheduled = schedule.addEvent(self.reconnect, when) + self.nextReconnectTime = when def die(self): self.zombie = True - if self.scheduled: - schedule.removeEvent(self.scheduled) + if self.nextReconnectTime is not None: + self.nextReconnectTime = None + if self.writeCheckTime is not None: + self.writeCheckTime = None drivers.log.die(self.irc) def _reallyDie(self): From 52b36555f4e47892c6b820f750b944e3aeab0022 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Thu, 3 Jun 2010 12:52:48 -0400 Subject: [PATCH 095/243] Utilities: add 'sample' command, a basic interface to random.sample() Also add tests for it. --- plugins/Utilities/plugin.py | 14 +++++++++++++- plugins/Utilities/test.py | 6 ++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/plugins/Utilities/plugin.py b/plugins/Utilities/plugin.py index a8766d223..2c1e45e80 100644 --- a/plugins/Utilities/plugin.py +++ b/plugins/Utilities/plugin.py @@ -86,12 +86,24 @@ class Utilities(callbacks.Plugin): def shuffle(self, irc, msg, args, things): """ [ ...] - Shuffles the arguments given it. + Shuffles the arguments given. """ random.shuffle(things) irc.reply(' '.join(things)) shuffle = wrap(shuffle, [many('anything')]) + def sample(self, irc, msg, args, num, things): + """ [ ...] + + Randomly chooses items out of the arguments given. + """ + try: + samp = random.sample(things, num) + irc.reply(' '.join(samp)) + except ValueError, e: + irc.error('%s' % (e,)) + sample = wrap(sample, ['positiveInt', many('anything')]) + def apply(self, irc, msg, args, command, rest): """ diff --git a/plugins/Utilities/test.py b/plugins/Utilities/test.py index 8e3b0ed25..2a7ab6751 100644 --- a/plugins/Utilities/test.py +++ b/plugins/Utilities/test.py @@ -59,4 +59,10 @@ class UtilitiesTestCase(PluginTestCase): def testShuffle(self): self.assertResponse('shuffle a', 'a') + def testSample(self): + self.assertResponse('sample 1 a', 'a') + self.assertError('sample moo') + self.assertError('sample 5 moo') + self.assertRegexp('sample 2 a b c', '^[a-c] [a-c]$') + # vim:set shiftwidth=4 softtabstop=4 expandtab textwidth=79: From 5f2d2a9c5e403bbc6bba2c257787c4b05bc4b640 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Thu, 3 Jun 2010 16:08:25 -0400 Subject: [PATCH 096/243] Utilities: add countargs function, returns number of arguments supplied. also add tests for it. --- plugins/Utilities/plugin.py | 8 ++++++++ plugins/Utilities/test.py | 5 +++++ 2 files changed, 13 insertions(+) diff --git a/plugins/Utilities/plugin.py b/plugins/Utilities/plugin.py index 2c1e45e80..4846e4519 100644 --- a/plugins/Utilities/plugin.py +++ b/plugins/Utilities/plugin.py @@ -104,6 +104,14 @@ class Utilities(callbacks.Plugin): irc.error('%s' % (e,)) sample = wrap(sample, ['positiveInt', many('anything')]) + def countargs(self, irc, msg, args, things): + """ [ ...] + + Counts the arguments given. + """ + irc.reply(len(things)) + countargs = wrap(countargs, [any('anything')]) + def apply(self, irc, msg, args, command, rest): """ diff --git a/plugins/Utilities/test.py b/plugins/Utilities/test.py index 2a7ab6751..9f7c5db14 100644 --- a/plugins/Utilities/test.py +++ b/plugins/Utilities/test.py @@ -65,4 +65,9 @@ class UtilitiesTestCase(PluginTestCase): self.assertError('sample 5 moo') self.assertRegexp('sample 2 a b c', '^[a-c] [a-c]$') + def testCountargs(self): + self.assertResponse('countargs a b c', '3') + self.assertResponse('countargs a "b c"', '2') + self.assertResponse('countargs', '0') + # vim:set shiftwidth=4 softtabstop=4 expandtab textwidth=79: From 80491fddb1a14cd2de9d3d9fae928c9f6d93ccde Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Thu, 3 Jun 2010 17:03:39 -0400 Subject: [PATCH 097/243] Conditional: allow empty string arguments in string-comparison functions --- plugins/Conditional/plugin.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/plugins/Conditional/plugin.py b/plugins/Conditional/plugin.py index 347b50f7a..6f9422152 100644 --- a/plugins/Conditional/plugin.py +++ b/plugins/Conditional/plugin.py @@ -124,7 +124,7 @@ class Conditional(callbacks.Plugin): irc.reply('true') else: irc.reply('false') - ceq = wrap(ceq, ['something', 'something']) + ceq = wrap(ceq, ['anything', 'anything']) def ne(self, irc, msg, args, item1, item2): """ @@ -136,19 +136,19 @@ class Conditional(callbacks.Plugin): irc.reply('true') else: irc.reply('false') - ne = wrap(ne, ['something', 'something']) + ne = wrap(ne, ['anything', 'anything']) def gt(self, irc, msg, args, item1, item2): """ Does a string comparison on and . - Returns true if they is greater than . + Returns true if is greater than . """ if item1 > item2: irc.reply('true') else: irc.reply('false') - gt = wrap(gt, ['something', 'something']) + gt = wrap(gt, ['anything', 'anything']) def ge(self, irc, msg, args, item1, item2): """ @@ -160,7 +160,7 @@ class Conditional(callbacks.Plugin): irc.reply('true') else: irc.reply('false') - ge = wrap(ge, ['something', 'something']) + ge = wrap(ge, ['anything', 'anything']) def lt(self, irc, msg, args, item1, item2): """ @@ -172,7 +172,7 @@ class Conditional(callbacks.Plugin): irc.reply('true') else: irc.reply('false') - lt = wrap(lt, ['something', 'something']) + lt = wrap(lt, ['anything', 'anything']) def le(self, irc, msg, args, item1, item2): """ @@ -184,7 +184,7 @@ class Conditional(callbacks.Plugin): irc.reply('true') else: irc.reply('false') - le = wrap(le, ['something', 'something']) + le = wrap(le, ['anything', 'anything']) def match(self, irc, msg, args, item1, item2): """ From a91bc318dccfea00865067a44ad41654fcc7e5b7 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Sun, 13 Jun 2010 02:36:18 -0400 Subject: [PATCH 098/243] Channel: nicks: add --count argument, which outputs only the count of nicks in channel. also add tests for the nick command. --- plugins/Channel/plugin.py | 18 ++++++++++++------ plugins/Channel/test.py | 4 ++++ 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/plugins/Channel/plugin.py b/plugins/Channel/plugin.py index de12e91cc..a8f3ec0e8 100644 --- a/plugins/Channel/plugin.py +++ b/plugins/Channel/plugin.py @@ -777,11 +777,12 @@ class Channel(callbacks.Plugin): optional(('plugin', False)), additional('commandName')]) - def nicks(self, irc, msg, args, channel): - """[] + def nicks(self, irc, msg, args, channel, optlist): + """[] [--count] Returns the nicks in . is only necessary if the - message isn't sent in the channel itself. + message isn't sent in the channel itself. Returns only the number of + nicks if --count option is provided. """ # Make sure we don't elicit information about private channels to # people or channels that shouldn't know @@ -791,9 +792,14 @@ class Channel(callbacks.Plugin): msg.nick not in irc.state.channels[channel].users): irc.error('You don\'t have access to that information.') L = list(irc.state.channels[channel].users) - utils.sortBy(str.lower, L) - irc.reply(utils.str.commaAndify(L)) - nicks = wrap(nicks, ['inChannel']) + keys = [option for (option, arg) in optlist] + if 'count' not in keys: + utils.sortBy(str.lower, L) + irc.reply(utils.str.commaAndify(L)) + else: + irc.reply(str(len(L))) + nicks = wrap(nicks, ['inChannel', + getopts({'count':''})]) def alertOps(self, irc, channel, s, frm=None): """Internal message for notifying all the #channel,ops in a channel of diff --git a/plugins/Channel/test.py b/plugins/Channel/test.py index 67ba108a3..54ce68ac5 100644 --- a/plugins/Channel/test.py +++ b/plugins/Channel/test.py @@ -214,5 +214,9 @@ class ChannelTestCase(ChannelPluginTestCase): finally: conf.supybot.protocols.irc.banmask.setValue(orig) + def testNicks(self): + self.assertResponse('channel nicks', 'bar, foo, and test') + self.assertResponse('channel nicks --count', '3') + # vim:set shiftwidth=4 softtabstop=4 expandtab textwidth=79: From 1ce52f01f4fd1b621720df61da1dfdb188714f88 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Tue, 15 Jun 2010 23:53:15 -0400 Subject: [PATCH 099/243] ChannelLogger: include in logs the reasons for parts and quits --- plugins/ChannelLogger/plugin.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/plugins/ChannelLogger/plugin.py b/plugins/ChannelLogger/plugin.py index 93f9b3c06..d9bd8549a 100644 --- a/plugins/ChannelLogger/plugin.py +++ b/plugins/ChannelLogger/plugin.py @@ -224,10 +224,14 @@ class ChannelLogger(callbacks.Plugin): '*** %s was kicked by %s\n', target, msg.nick) def doPart(self, irc, msg): + if len(msg.args) > 1: + reason = " (%s)" % msg.args[1] + else: + reason = "" for channel in msg.args[0].split(','): self.doLog(irc, channel, - '*** %s <%s> has left %s\n', - msg.nick, msg.prefix, channel) + '*** %s <%s> has left %s%s\n', + msg.nick, msg.prefix, channel, reason) def doMode(self, irc, msg): channel = msg.args[0] @@ -245,13 +249,17 @@ class ChannelLogger(callbacks.Plugin): '*** %s changes topic to "%s"\n', msg.nick, msg.args[1]) def doQuit(self, irc, msg): + if len(msg.args) == 1: + reason = " (%s)" % msg.args[0] + else: + reason = "" if not isinstance(irc, irclib.Irc): irc = irc.getRealIrc() for (channel, chan) in self.lastStates[irc].channels.iteritems(): if msg.nick in chan.users: self.doLog(irc, channel, - '*** %s <%s> has quit IRC\n', - msg.nick, msg.prefix) + '*** %s <%s> has quit IRC%s\n', + msg.nick, msg.prefix, reason) def outFilter(self, irc, msg): # Gotta catch my own messages *somehow* :) From 929859b2461c14443c6877d904220970ed871bb7 Mon Sep 17 00:00:00 2001 From: James Vega Date: Fri, 18 Jun 2010 20:33:43 -0400 Subject: [PATCH 100/243] Twisted: Send all available ircmsgs and reduce delay between checks All ircmsgs that takeMsg will return should be processed each time checkIrcForMsgs is called since there may be multiple available in the fastqueue. Reduced the time between calls of checkIrcForMsgs so the delay between normally queued ircmsgs stays close to the configured throttleTime. Closes: Sf#3018148 (cherry picked from commit adc5d62bbf6b89631ab25e9b5f5707bde0b06709) Signed-off-by: Daniel Folkinshteyn --- src/drivers/Twisted.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/drivers/Twisted.py b/src/drivers/Twisted.py index 7b41b7c4f..c91d80d6b 100644 --- a/src/drivers/Twisted.py +++ b/src/drivers/Twisted.py @@ -66,7 +66,7 @@ class SupyIrcProtocol(LineReceiver): delimiter = '\n' MAX_LENGTH = 1024 def __init__(self): - self.mostRecentCall = reactor.callLater(1, self.checkIrcForMsgs) + self.mostRecentCall = reactor.callLater(0.1, self.checkIrcForMsgs) def lineReceived(self, line): msg = drivers.parseMsg(line) @@ -76,9 +76,10 @@ class SupyIrcProtocol(LineReceiver): def checkIrcForMsgs(self): if self.connected: msg = self.irc.takeMsg() - if msg: + while msg: self.transport.write(str(msg)) - self.mostRecentCall = reactor.callLater(1, self.checkIrcForMsgs) + msg = self.irc.takeMsg() + self.mostRecentCall = reactor.callLater(0.1, self.checkIrcForMsgs) def connectionLost(self, r): self.mostRecentCall.cancel() From d21fc27e0c93c9adcd87d80a922898ae8d7dad5b Mon Sep 17 00:00:00 2001 From: James Vega Date: Sat, 19 Jun 2010 16:59:13 -0400 Subject: [PATCH 101/243] Services: Disable most of the plugin on networks in the disabled list. Notify the user when trying to use the commands on a disabled network, ignore noJoinsUntilIdentified, and don't try communicating with services. Closes: Sf#3018464 Signed-off-by: James Vega (cherry picked from commit 9e73f4482c32b517f9927931831e3c8d15c285ab) Signed-off-by: Daniel Folkinshteyn --- plugins/Services/plugin.py | 43 +++++++++++++++++++++++++++++++------- 1 file changed, 36 insertions(+), 7 deletions(-) diff --git a/plugins/Services/plugin.py b/plugins/Services/plugin.py index cca8ef5b7..2e50f7b0d 100644 --- a/plugins/Services/plugin.py +++ b/plugins/Services/plugin.py @@ -1,5 +1,6 @@ ### # Copyright (c) 2002-2004, Jeremiah Fincher +# Copyright (c) 2010, James Vega # All rights reserved. # # Redistribution and use in source and binary forms, with or without @@ -62,6 +63,13 @@ class Services(callbacks.Plugin): self.identified = False self.waitingJoins = [] + def isDisabled(self, irc): + disabled = self.registryValue('disabledNetworks') + if irc.network in disabled or \ + irc.state.supported.get('NETWORK', '') in disabled: + return True + return False + def outFilter(self, irc, msg): if msg.command == 'JOIN': if not self.identified: @@ -75,9 +83,6 @@ class Services(callbacks.Plugin): def _getNick(self): return conf.supybot.nick() -## def _getNickServ(self, network): -## return self.registryValue('NickServ', network) - def _getNickServPassword(self, nick): # This should later be nick-specific. assert nick in self.registryValue('nicks') @@ -89,6 +94,8 @@ class Services(callbacks.Plugin): self.setRegistryValue('NickServ.password.%s' % nick, password) def _doIdentify(self, irc, nick=None): + if self.isDisabled(irc): + return if nick is None: nick = self._getNick() if nick not in self.registryValue('nicks'): @@ -109,6 +116,8 @@ class Services(callbacks.Plugin): irc.sendMsg(ircmsgs.privmsg(nickserv, identify)) def _doGhost(self, irc, nick=None): + if self.isDisabled(irc): + return if nick is None: nick = self._getNick() if nick not in self.registryValue('nicks'): @@ -135,11 +144,9 @@ class Services(callbacks.Plugin): self.sentGhost = time.time() def __call__(self, irc, msg): - disabled = self.registryValue('disabledNetworks') - if irc.network in disabled or \ - irc.state.supported.get('NETWORK', '') in disabled: - return self.__parent.__call__(irc, msg) + if self.isDisabled(irc): + return nick = self._getNick() if nick not in self.registryValue('nicks'): return @@ -160,6 +167,8 @@ class Services(callbacks.Plugin): self.sentGhost = None def do376(self, irc, msg): + if self.isDisabled(irc): + return nick = self._getNick() if nick not in self.registryValue('nicks'): return @@ -182,6 +191,8 @@ class Services(callbacks.Plugin): do422 = do377 = do376 def do433(self, irc, msg): + if self.isDisabled(irc): + return nick = self._getNick() if nick not in self.registryValue('nicks'): return @@ -219,6 +230,8 @@ class Services(callbacks.Plugin): _chanRe = re.compile('\x02(.*?)\x02') def doChanservNotice(self, irc, msg): + if self.isDisabled(irc): + return s = msg.args[1].lower() channel = None m = self._chanRe.search(s) @@ -249,6 +262,8 @@ class Services(callbacks.Plugin): on, msg) def doNickservNotice(self, irc, msg): + if self.isDisabled(irc): + return nick = self._getNick() s = ircutils.stripFormatting(msg.args[1].lower()) on = 'on %s' % irc.network @@ -310,6 +325,8 @@ class Services(callbacks.Plugin): self.log.debug('Unexpected notice from NickServ %s: %q.', on, s) def checkPrivileges(self, irc, channel): + if self.isDisabled(irc): + return chanserv = self.registryValue('ChanServ') on = 'on %s' % irc.network if chanserv and self.registryValue('ChanServ.op', channel): @@ -329,6 +346,8 @@ class Services(callbacks.Plugin): irc.sendMsg(ircmsgs.privmsg(chanserv, 'voice %s' % channel)) def doMode(self, irc, msg): + if self.isDisabled(irc): + return chanserv = self.registryValue('ChanServ') on = 'on %s' % irc.network if ircutils.strEqual(msg.nick, chanserv): @@ -352,6 +371,12 @@ class Services(callbacks.Plugin): channel = msg.args[1] # nick is msg.args[0]. self.checkPrivileges(irc, channel) + def callCommand(self, command, irc, msg, *args, **kwargs): + if self.isDisabled(irc): + irc.error('Services plugin is disabled on this network', + Raise=True) + self.__parent.callCommand(command, irc, msg, *args, **kwargs) + def _chanservCommand(self, irc, channel, command, log=False): chanserv = self.registryValue('ChanServ') if chanserv: @@ -394,6 +419,8 @@ class Services(callbacks.Plugin): voice = wrap(voice, [('checkChannelCapability', 'op'), 'inChannel']) def do474(self, irc, msg): + if self.isDisabled(irc): + return channel = msg.args[1] on = 'on %s' % irc.network self.log.info('Banned from %s, attempting ChanServ unban %s.', @@ -414,6 +441,8 @@ class Services(callbacks.Plugin): unban = wrap(unban, [('checkChannelCapability', 'op')]) def do473(self, irc, msg): + if self.isDisabled(irc): + return channel = msg.args[1] on = 'on %s' % irc.network self.log.info('%s is +i, attempting ChanServ invite %s.', channel, on) From 9b9d009c432c37faef369104660e75327304a433 Mon Sep 17 00:00:00 2001 From: James Vega Date: Sat, 19 Jun 2010 22:38:27 -0400 Subject: [PATCH 102/243] Services: Fix conflict with callbacks.Commands.isDisabled Signed-off-by: James Vega (cherry picked from commit f926804f4071ac0c59a67d915ca66ad19c28e6c0) Signed-off-by: Daniel Folkinshteyn --- plugins/Services/plugin.py | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/plugins/Services/plugin.py b/plugins/Services/plugin.py index 2e50f7b0d..0a6dfe505 100644 --- a/plugins/Services/plugin.py +++ b/plugins/Services/plugin.py @@ -63,7 +63,7 @@ class Services(callbacks.Plugin): self.identified = False self.waitingJoins = [] - def isDisabled(self, irc): + def disabled(self, irc): disabled = self.registryValue('disabledNetworks') if irc.network in disabled or \ irc.state.supported.get('NETWORK', '') in disabled: @@ -94,7 +94,7 @@ class Services(callbacks.Plugin): self.setRegistryValue('NickServ.password.%s' % nick, password) def _doIdentify(self, irc, nick=None): - if self.isDisabled(irc): + if self.disabled(irc): return if nick is None: nick = self._getNick() @@ -116,7 +116,7 @@ class Services(callbacks.Plugin): irc.sendMsg(ircmsgs.privmsg(nickserv, identify)) def _doGhost(self, irc, nick=None): - if self.isDisabled(irc): + if self.disabled(irc): return if nick is None: nick = self._getNick() @@ -145,7 +145,7 @@ class Services(callbacks.Plugin): def __call__(self, irc, msg): self.__parent.__call__(irc, msg) - if self.isDisabled(irc): + if self.disabled(irc): return nick = self._getNick() if nick not in self.registryValue('nicks'): @@ -167,7 +167,7 @@ class Services(callbacks.Plugin): self.sentGhost = None def do376(self, irc, msg): - if self.isDisabled(irc): + if self.disabled(irc): return nick = self._getNick() if nick not in self.registryValue('nicks'): @@ -191,7 +191,7 @@ class Services(callbacks.Plugin): do422 = do377 = do376 def do433(self, irc, msg): - if self.isDisabled(irc): + if self.disabled(irc): return nick = self._getNick() if nick not in self.registryValue('nicks'): @@ -230,7 +230,7 @@ class Services(callbacks.Plugin): _chanRe = re.compile('\x02(.*?)\x02') def doChanservNotice(self, irc, msg): - if self.isDisabled(irc): + if self.disabled(irc): return s = msg.args[1].lower() channel = None @@ -262,7 +262,7 @@ class Services(callbacks.Plugin): on, msg) def doNickservNotice(self, irc, msg): - if self.isDisabled(irc): + if self.disabled(irc): return nick = self._getNick() s = ircutils.stripFormatting(msg.args[1].lower()) @@ -325,7 +325,7 @@ class Services(callbacks.Plugin): self.log.debug('Unexpected notice from NickServ %s: %q.', on, s) def checkPrivileges(self, irc, channel): - if self.isDisabled(irc): + if self.disabled(irc): return chanserv = self.registryValue('ChanServ') on = 'on %s' % irc.network @@ -346,7 +346,7 @@ class Services(callbacks.Plugin): irc.sendMsg(ircmsgs.privmsg(chanserv, 'voice %s' % channel)) def doMode(self, irc, msg): - if self.isDisabled(irc): + if self.disabled(irc): return chanserv = self.registryValue('ChanServ') on = 'on %s' % irc.network @@ -372,7 +372,7 @@ class Services(callbacks.Plugin): self.checkPrivileges(irc, channel) def callCommand(self, command, irc, msg, *args, **kwargs): - if self.isDisabled(irc): + if self.disabled(irc): irc.error('Services plugin is disabled on this network', Raise=True) self.__parent.callCommand(command, irc, msg, *args, **kwargs) @@ -419,7 +419,7 @@ class Services(callbacks.Plugin): voice = wrap(voice, [('checkChannelCapability', 'op'), 'inChannel']) def do474(self, irc, msg): - if self.isDisabled(irc): + if self.disabled(irc): return channel = msg.args[1] on = 'on %s' % irc.network @@ -441,7 +441,7 @@ class Services(callbacks.Plugin): unban = wrap(unban, [('checkChannelCapability', 'op')]) def do473(self, irc, msg): - if self.isDisabled(irc): + if self.disabled(irc): return channel = msg.args[1] on = 'on %s' % irc.network From be39fcdbc6ff2a1becd7b42b7762d4a613e9eb09 Mon Sep 17 00:00:00 2001 From: James Vega Date: Sun, 20 Jun 2010 09:40:57 -0400 Subject: [PATCH 103/243] supybot: Remove extraneous sys.stdin.close() Signed-off-by: James Vega (cherry picked from commit 0e22e218f0d48d82eb7728a7cd3c1bb4ef052b80) Signed-off-by: Daniel Folkinshteyn --- scripts/supybot | 1 - 1 file changed, 1 deletion(-) diff --git a/scripts/supybot b/scripts/supybot index 8b974b910..7f1953776 100644 --- a/scripts/supybot +++ b/scripts/supybot @@ -263,7 +263,6 @@ if __name__ == '__main__': 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() From 16dbd8917a31b29fedca0c5d881dac16a5aaadbe Mon Sep 17 00:00:00 2001 From: James Vega Date: Mon, 21 Jun 2010 19:35:35 -0400 Subject: [PATCH 104/243] Services: Properly register the NickServ.password group and child values. Closes: Sf#3019174 Signed-off-by: James Vega (cherry picked from commit d78f7b6ac556293739f7334a56bd5b9f67742516) Signed-off-by: Daniel Folkinshteyn --- plugins/Services/config.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/plugins/Services/config.py b/plugins/Services/config.py index 6fe798925..cfa8081fa 100644 --- a/plugins/Services/config.py +++ b/plugins/Services/config.py @@ -1,5 +1,6 @@ ### # Copyright (c) 2005, Jeremiah Fincher +# Copyright (c) 2010, James Vega # All rights reserved. # # Redistribution and use in source and binary forms, with or without @@ -33,7 +34,10 @@ import supybot.registry as registry def registerNick(nick, password=''): p = conf.supybot.plugins.Services.Nickserv.get('password') - v = p.register(nick, registry.String(password, '', private=True)) + h = 'Determines what password the bot will use with NickServ when ' \ + 'identifying as %s.' % nick + v = p.register(nick, registry.String(password, h, private=True)) + v.channelValue = False if password: v.setValue(password) @@ -82,9 +86,7 @@ conf.registerGlobalValue(Services, 'ghostDelay', conf.registerGlobalValue(Services, 'NickServ', ValidNickOrEmptyString('', """Determines what nick the 'NickServ' service has.""")) -conf.registerGroup(Services.NickServ, 'password', - registry.String('', """Determines what password the bot will use with - NickServ.""", private=True)) +conf.registerGroup(Services.NickServ, 'password') conf.registerGlobalValue(Services, 'ChanServ', ValidNickOrEmptyString('', """Determines what nick the 'ChanServ' service has.""")) From dbde5fc2508bba241b2588c29ac52139a2a2ea2c Mon Sep 17 00:00:00 2001 From: James Vega Date: Thu, 24 Jun 2010 00:25:44 -0400 Subject: [PATCH 105/243] Elide ignored replies from nested command output. Signed-off-by: James Vega (cherry picked from commit c10d964604f564fc29d44a71ea04eb2c8569531c) Signed-off-by: Daniel Folkinshteyn --- plugins/Utilities/plugin.py | 7 ++++++- src/callbacks.py | 11 +++++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/plugins/Utilities/plugin.py b/plugins/Utilities/plugin.py index 4846e4519..c6f0f564e 100644 --- a/plugins/Utilities/plugin.py +++ b/plugins/Utilities/plugin.py @@ -1,5 +1,6 @@ ### # Copyright (c) 2002-2004, Jeremiah Fincher +# Copyright (c) 2010, James Vega # All rights reserved. # # Redistribution and use in source and binary forms, with or without @@ -43,7 +44,11 @@ class Utilities(callbacks.Plugin): Does nothing. Useful sometimes for sequencing commands when you don't care about their non-error return values. """ - pass + if irc.nested: + msg.tag('ignored') + # Need to call NestedCommandsIrcProxy.reply to continue evaluation + # of the remaining nested commands. + irc.reply('') # Do be careful not to wrap this unless you do any('something'). def success(self, irc, msg, args, text): diff --git a/src/callbacks.py b/src/callbacks.py index ce941b4d7..a836165e3 100644 --- a/src/callbacks.py +++ b/src/callbacks.py @@ -1,6 +1,6 @@ ### # Copyright (c) 2002-2005, Jeremiah Fincher -# Copyright (c) 2008-2009, James Vega +# Copyright (c) 2008-2010, James Vega # All rights reserved. # # Redistribution and use in source and binary forms, with or without @@ -920,7 +920,14 @@ class NestedCommandsIrcProxy(ReplyIrcProxy): finally: self._resetReplyAttributes() else: - self.args[self.counter] = s + if msg.ignored: + # Since the final reply string is constructed via + # ' '.join(self.args), the args index for ignored commands + # needs to be popped to avoid extra spaces in the final reply. + self.args.pop(self.counter) + msg.tag('ignored', False) + else: + self.args[self.counter] = s self.evalArgs() def error(self, s='', Raise=False, **kwargs): From 23cca935cbeb9e85cbd54e58e173b2c1b561d36c Mon Sep 17 00:00:00 2001 From: James Vega Date: Thu, 24 Jun 2010 00:37:40 -0400 Subject: [PATCH 106/243] Use conf.registerGlobalValue to ensure generated values are properly setup. Signed-off-by: James Vega (cherry picked from commit 0c6220480941a199334828d79189429afe1a0452) Signed-off-by: Daniel Folkinshteyn --- plugins/Alias/plugin.py | 12 ++++++------ plugins/RSS/plugin.py | 4 ++-- plugins/Services/config.py | 4 ++-- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/plugins/Alias/plugin.py b/plugins/Alias/plugin.py index 2b14b6527..f90eb29cf 100644 --- a/plugins/Alias/plugin.py +++ b/plugins/Alias/plugin.py @@ -1,6 +1,6 @@ ### # Copyright (c) 2002-2004, Jeremiah Fincher -# Copyright (c) 2009, James Vega +# Copyright (c) 2009-2010, James Vega # All rights reserved. # # Redistribution and use in source and binary forms, with or without @@ -263,13 +263,13 @@ class Alias(callbacks.Plugin): f = new.instancemethod(f, self, Alias) except RecursiveAlias: raise AliasError, 'You can\'t define a recursive alias.' + aliasGroup = self.registryValue('aliases', value=False) if name in self.aliases: # We gotta remove it so its value gets updated. - conf.supybot.plugins.Alias.aliases.unregister(name) - conf.supybot.plugins.Alias.aliases.register(name, - registry.String(alias, '')) - conf.supybot.plugins.Alias.aliases.get(name).register('locked', - registry.Boolean(lock, '')) + aliasGroup.unregister(name) + conf.registerGlobalValue(aliasGroup, name, registry.String(alias, '')) + conf.registerGlobalValue(aliasGroup.get(name), 'locked', + registry.Boolean(lock, '')) self.aliases[name] = [alias, lock, f] def removeAlias(self, name, evenIfLocked=False): diff --git a/plugins/RSS/plugin.py b/plugins/RSS/plugin.py index 1a5d83f09..2f21f51f7 100644 --- a/plugins/RSS/plugin.py +++ b/plugins/RSS/plugin.py @@ -1,6 +1,6 @@ ### # Copyright (c) 2002-2004, Jeremiah Fincher -# Copyright (c) 2008-2009, James Vega +# Copyright (c) 2008-2010, James Vega # All rights reserved. # # Redistribution and use in source and binary forms, with or without @@ -102,7 +102,7 @@ class RSS(callbacks.Plugin): def _registerFeed(self, name, url=''): self.registryValue('feeds').add(name) group = self.registryValue('feeds', value=False) - group.register(name, registry.String(url, '')) + conf.registerGlobalValue(group, name, registry.String(url, '')) def __call__(self, irc, msg): self.__parent.__call__(irc, msg) diff --git a/plugins/Services/config.py b/plugins/Services/config.py index cfa8081fa..aa09131ea 100644 --- a/plugins/Services/config.py +++ b/plugins/Services/config.py @@ -36,8 +36,8 @@ def registerNick(nick, password=''): p = conf.supybot.plugins.Services.Nickserv.get('password') h = 'Determines what password the bot will use with NickServ when ' \ 'identifying as %s.' % nick - v = p.register(nick, registry.String(password, h, private=True)) - v.channelValue = False + v = conf.registerGlobalValue(p, nick, + registry.String(password, h, private=True)) if password: v.setValue(password) From 0781ff339eefaa8c4ff6613865891949935c9d80 Mon Sep 17 00:00:00 2001 From: James Vega Date: Sun, 27 Jun 2010 19:48:36 -0400 Subject: [PATCH 107/243] Services: Don't filter outgoing JOIN messages on disabled networks Signed-off-by: James Vega (cherry picked from commit acffde68abb28606371634d7f52fe8991e3cbf9e) Signed-off-by: Daniel Folkinshteyn --- plugins/Services/plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/Services/plugin.py b/plugins/Services/plugin.py index 0a6dfe505..e5882573b 100644 --- a/plugins/Services/plugin.py +++ b/plugins/Services/plugin.py @@ -71,7 +71,7 @@ class Services(callbacks.Plugin): return False def outFilter(self, irc, msg): - if msg.command == 'JOIN': + if msg.command == 'JOIN' and not self.disabled(irc): if not self.identified: if self.registryValue('noJoinsUntilIdentified'): self.log.info('Holding JOIN to %s until identified.', From 67a41f6626bbb096a7cc39b1149fb87b012e6e0c Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Thu, 1 Jul 2010 15:44:53 -0400 Subject: [PATCH 108/243] Filter: add unbinary command, as counterpart to binary command. --- plugins/Filter/plugin.py | 10 ++++++++++ plugins/Filter/test.py | 3 +++ 2 files changed, 13 insertions(+) diff --git a/plugins/Filter/plugin.py b/plugins/Filter/plugin.py index cd0905b08..c7247bcf5 100644 --- a/plugins/Filter/plugin.py +++ b/plugins/Filter/plugin.py @@ -156,6 +156,16 @@ class Filter(callbacks.Plugin): irc.reply(''.join(L)) binary = wrap(binary, ['text']) + def unbinary(self, irc, msg, args, text): + """ + + Returns the character representation of binary . + Assumes ASCII, 8 digits per character. + """ + L = [chr(int(text[i:(i+8)], 2)) for i in xrange(0, len(text), 8)] + irc.reply(''.join(L)) + unbinary = wrap(unbinary, ['text']) + def hexlify(self, irc, msg, args, text): """ diff --git a/plugins/Filter/test.py b/plugins/Filter/test.py index d31959f66..9c76e4331 100644 --- a/plugins/Filter/test.py +++ b/plugins/Filter/test.py @@ -86,6 +86,9 @@ class FilterTest(ChannelPluginTestCase): def testBinary(self): self.assertResponse('binary A', '01000001') + + def testUnbinary(self): + self.assertResponse('unbinary 011011010110111101101111', 'moo') def testRot13(self): for s in map(str, range(1000, 1010)): From a8e91a58a0a0ad3c7abdfe00e70e639a1a25e33b Mon Sep 17 00:00:00 2001 From: "oevna@users.sourceforge.net" Date: Thu, 8 Jul 2010 17:17:37 -0400 Subject: [PATCH 109/243] Added ping command to Unix plugin Signed-off-by: Daniel Folkinshteyn --- plugins/Unix/config.py | 4 ++++ plugins/Unix/plugin.py | 31 +++++++++++++++++++++++++++++++ plugins/Unix/test.py | 5 +++++ 3 files changed, 40 insertions(+) diff --git a/plugins/Unix/config.py b/plugins/Unix/config.py index 34096c024..330ec1be3 100644 --- a/plugins/Unix/config.py +++ b/plugins/Unix/config.py @@ -85,5 +85,9 @@ conf.registerGlobalValue(Unix.wtf, 'command', registry.String(utils.findBinaryInPath('wtf') or '', """Determines what command will be called for the wtf command.""")) +conf.registerGroup(Unix, 'ping') +conf.registerGlobalValue(Unix.ping, 'command', + registry.String(utils.findBinaryInPath('ping') or '', """Determines what + command will be called for the ping command.""")) # vim:set shiftwidth=4 softtabstop=4 expandtab textwidth=79: diff --git a/plugins/Unix/plugin.py b/plugins/Unix/plugin.py index d4da8e070..3898f150f 100644 --- a/plugins/Unix/plugin.py +++ b/plugins/Unix/plugin.py @@ -249,5 +249,36 @@ class Unix(callbacks.Plugin): 'variable appropriately.') wtf = wrap(wtf, [optional(('literal', ['is'])), 'something']) + def ping(self, irc, msg, args, host): + """ + Sends an ICMP echo request to the specified host + """ + pingCmd = self.registryValue('ping.command') + if not pingCmd: + irc.error('The ping command is not configured. If one ' + 'is installed, reconfigure ' + 'supybot.plugins.Unix.ping.command appropriately.', + Raise=True) + else: + try: host = host.group(0) + except AttributeError: pass + + inst = subprocess.Popen([pingCmd,'-c','1', host], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + stdin=file(os.devnull)) + + result = inst.communicate() + + if result[1]: # stderr + irc.reply(' '.join(result[1].split())) + else: + response = result[0].split("\n"); + irc.reply(' '.join(response[1].split()[3:5]).split(':')[0] + + ': ' + ' '.join(response[-3:])) + + _hostExpr = re.compile(r'^[a-z0-9][a-z0-9\.-]*$', re.I) + ping = wrap(ping, [first('ip', ('matches', _hostExpr, 'Invalid hostname'))]) + Class = Unix # vim:set shiftwidth=4 softtabstop=4 expandtab textwidth=79: diff --git a/plugins/Unix/test.py b/plugins/Unix/test.py index 062ce550b..b51cd8397 100644 --- a/plugins/Unix/test.py +++ b/plugins/Unix/test.py @@ -64,5 +64,10 @@ if os.name == 'posix': def testFortune(self): self.assertNotError('fortune') + if utils.findBinaryInPath('ping') is not None: + def testPing(self): + self.assertNotError('ping') + + # vim:set shiftwidth=4 softtabstop=4 expandtab textwidth=79: From e178d045224c75d35ce00337efb2d864b3022a99 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Thu, 8 Jul 2010 23:29:01 -0400 Subject: [PATCH 110/243] Unix: fix test for the ping command, call unix ping instead of the default misc ping. --- plugins/Unix/test.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugins/Unix/test.py b/plugins/Unix/test.py index b51cd8397..8f67ce351 100644 --- a/plugins/Unix/test.py +++ b/plugins/Unix/test.py @@ -66,7 +66,8 @@ if os.name == 'posix': if utils.findBinaryInPath('ping') is not None: def testPing(self): - self.assertNotError('ping') + self.assertNotError('unix ping localhost') + self.assertError('unix ping') From 6df6d477f1444b7020c25194dc12844f4c6e4b98 Mon Sep 17 00:00:00 2001 From: "oevna@users.sourceforge.net" Date: Fri, 9 Jul 2010 10:20:41 -0400 Subject: [PATCH 111/243] Unix: fixed uncaught OSError exception raised when executing ping fails. Signed-off-by: Daniel Folkinshteyn --- plugins/Unix/plugin.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/plugins/Unix/plugin.py b/plugins/Unix/plugin.py index 3898f150f..7396da95c 100644 --- a/plugins/Unix/plugin.py +++ b/plugins/Unix/plugin.py @@ -262,12 +262,16 @@ class Unix(callbacks.Plugin): else: try: host = host.group(0) except AttributeError: pass - - inst = subprocess.Popen([pingCmd,'-c','1', host], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - stdin=file(os.devnull)) + try: + inst = subprocess.Popen([pingCmd,'-c','1', host], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + stdin=file(os.devnull)) + except OSError, e: + irc.error('It seems the configured ping command was ' + 'not available (%s).' % e, Raise=True) + result = inst.communicate() if result[1]: # stderr From a9e2fc7bed8846099dc53f1c74548fc5af9bcd17 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Sun, 11 Jul 2010 02:29:02 -0400 Subject: [PATCH 112/243] Socket driver: implement ssl connection support. --- src/drivers/Socket.py | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/src/drivers/Socket.py b/src/drivers/Socket.py index 6db605a59..ff9e59146 100644 --- a/src/drivers/Socket.py +++ b/src/drivers/Socket.py @@ -46,6 +46,13 @@ import supybot.drivers as drivers import supybot.schedule as schedule from supybot.utils.iter import imap +try: + import ssl +except ImportError: + drivers.log.debug('ssl module is not available, ' + 'cannot connect to SSL servers.') + ssl = None + class SocketDriver(drivers.IrcDriver, drivers.ServersMixin): def __init__(self, irc): self.irc = irc @@ -61,12 +68,7 @@ class SocketDriver(drivers.IrcDriver, drivers.ServersMixin): self.writeCheckTime = None self.nextReconnectTime = None self.resetDelay() - # Only connect to non-SSL servers - if self.networkGroup.get('ssl').value: - drivers.log.error('The Socket driver can not connect to SSL ' - 'servers. Try the Twisted driver instead.') - else: - self.connect() + self.connect() def getDelay(self): ret = self.currentDelay @@ -139,6 +141,12 @@ class SocketDriver(drivers.IrcDriver, drivers.ServersMixin): self.irc.feedMsg(msg) except socket.timeout: pass + except ssl.SSLError, e: + if e.args[0] == 'The read operation timed out': + pass + else: + self._handleSocketError(e) + return except socket.error, e: self._handleSocketError(e) return @@ -163,6 +171,14 @@ class SocketDriver(drivers.IrcDriver, drivers.ServersMixin): drivers.log.connect(self.currentServer) try: self.conn = utils.net.getSocket(server[0]) + if self.networkGroup.get('ssl').value: + if ssl: + self.plainconn = self.conn + self.conn = ssl.wrap_socket(self.conn) + else: + drivers.log.error('ssl module not available, ' + 'cannot connect to SSL servers.') + return vhost = conf.supybot.protocols.irc.vhost() self.conn.bind((vhost, 0)) except socket.error, e: From 6e33df49ab5e7001c0d7a7c8ed129671f5a69d41 Mon Sep 17 00:00:00 2001 From: James Vega Date: Sun, 11 Jul 2010 09:48:16 -0400 Subject: [PATCH 113/243] ShrinkUrl: Add serviceRotation config. When configured, the outFilter and shrinkSnarfer use cycle through serviceRotation's list of services. Otherwise, the default service is used. Closes: deb#539858 Signed-off-by: James Vega (cherry picked from commit 0bfa0d153c5dd01336efb1cd0330c4a5a3e1405b) Signed-off-by: Daniel Folkinshteyn --- plugins/ShrinkUrl/config.py | 28 ++++++++++++++++++++++++++-- plugins/ShrinkUrl/plugin.py | 16 +++++++++++++--- plugins/ShrinkUrl/test.py | 30 +++++++++++++++++++++++------- 3 files changed, 62 insertions(+), 12 deletions(-) diff --git a/plugins/ShrinkUrl/config.py b/plugins/ShrinkUrl/config.py index f03f88a0f..fa5429510 100644 --- a/plugins/ShrinkUrl/config.py +++ b/plugins/ShrinkUrl/config.py @@ -1,6 +1,6 @@ ### # Copyright (c) 2005, Jeremiah Fincher -# Copyright (c) 2009, James Vega +# Copyright (c) 2009-2010, James Vega # All rights reserved. # # Redistribution and use in source and binary forms, with or without @@ -40,8 +40,30 @@ def configure(advanced): conf.supybot.plugins.ShrinkUrl.shrinkSnarfer.setValue(True) class ShrinkService(registry.OnlySomeStrings): + """Valid values include 'ln', 'tiny', 'xrl', and 'x0'.""" validStrings = ('ln', 'tiny', 'xrl', 'x0') +class ShrinkCycle(registry.SpaceSeparatedListOfStrings): + """Valid values include 'ln', 'tiny', 'xrl', and 'x0'.""" + Value = ShrinkService + + def __init__(self, *args, **kwargs): + super(ShrinkCycle, self).__init__(*args, **kwargs) + self.lastIndex = -1 + + def setValue(self, v): + super(ShrinkCycle, self).setValue(v) + self.lastIndex = -1 + + def getService(self): + L = self() + if L: + self.lastIndex = (self.lastIndex + 1) % len(L) + return L[self.lastIndex] + raise ValueError, \ + 'No services have been configured for rotation. ' \ + 'See conf.supybot.plugins.ShrinkUrl.serviceRotation.' + ShrinkUrl = conf.registerPlugin('ShrinkUrl') conf.registerChannelValue(ShrinkUrl, 'shrinkSnarfer', registry.Boolean(False, """Determines whether the @@ -70,6 +92,8 @@ conf.registerChannelValue(ShrinkUrl, 'default', conf.registerGlobalValue(ShrinkUrl, 'bold', registry.Boolean(True, """Determines whether this plugin will bold certain portions of its replies.""")) - +conf.registerChannelValue(ShrinkUrl, 'serviceRotation', + ShrinkCycle([], """If set to a non-empty value, specifies the list of + services to rotate through for the shrinkSnarfer and outFilter.""")) # vim:set shiftwidth=4 softtabstop=4 expandtab textwidth=79: diff --git a/plugins/ShrinkUrl/plugin.py b/plugins/ShrinkUrl/plugin.py index e47ae88cd..d162c27dc 100644 --- a/plugins/ShrinkUrl/plugin.py +++ b/plugins/ShrinkUrl/plugin.py @@ -1,6 +1,6 @@ ### # Copyright (c) 2002-2004, Jeremiah Fincher -# Copyright (c) 2009, James Vega +# Copyright (c) 2009-2010, James Vega # All rights reserved. # # Redistribution and use in source and binary forms, with or without @@ -86,7 +86,12 @@ class ShrinkUrl(callbacks.PluginRegexp): for m in utils.web.httpUrlRe.finditer(text): url = m.group(1) if len(url) > self.registryValue('minimumLength', channel): - cmd = self.registryValue('default', channel).capitalize() + try: + cmd = self.registryValue('serviceRotation', + channel, value=False) + cmd = cmd.getService().capitalize() + except ValueError: + cmd = self.registryValue('default', channel).capitalize() try: shortUrl = getattr(self, '_get%sUrl' % cmd)(url) text = text.replace(url, shortUrl) @@ -117,7 +122,12 @@ class ShrinkUrl(callbacks.PluginRegexp): self.log.debug('Matched nonSnarfingRegexp: %u', url) return minlen = self.registryValue('minimumLength', channel) - cmd = self.registryValue('default', channel).capitalize() + try: + cmd = self.registryValue('serviceRotation', + channel, value=False) + cmd = cmd.getService().capitalize() + except ValueError: + cmd = self.registryValue('default', channel).capitalize() if len(url) >= minlen: try: shorturl = getattr(self, '_get%sUrl' % cmd)(url) diff --git a/plugins/ShrinkUrl/test.py b/plugins/ShrinkUrl/test.py index bae344175..caf606668 100644 --- a/plugins/ShrinkUrl/test.py +++ b/plugins/ShrinkUrl/test.py @@ -1,6 +1,6 @@ ### # Copyright (c) 2002-2004, Jeremiah Fincher -# Copyright (c) 2009, James Vega +# Copyright (c) 2009-2010, James Vega # All rights reserved. # # Redistribution and use in source and binary forms, with or without @@ -48,15 +48,31 @@ class ShrinkUrlTestCase(ChannelPluginTestCase): } if network: def testShrink(self): + for (service, testdata) in self.tests.iteritems(): + for (url, shrunkurl) in testdata: + self.assertRegexp('shrinkurl %s %s' % (service, url), + shrunkurl) + + def testShrinkCycle(self): + cycle = conf.supybot.plugins.ShrinkUrl.serviceRotation snarfer = conf.supybot.plugins.ShrinkUrl.shrinkSnarfer - orig = snarfer() + origcycle = cycle() + origsnarfer = snarfer() try: - for (service, testdata) in self.tests.iteritems(): - for (url, shrunkurl) in testdata: - self.assertRegexp('shrinkurl %s %s' % (service, url), - shrunkurl) + self.assertNotError( + 'config plugins.ShrinkUrl.serviceRotation ln x0') + self.assertError( + 'config plugins.ShrinkUrl.serviceRotation ln x1') + snarfer.setValue(True) + self.assertSnarfRegexp(self.udUrl, r'%s.* \(at' % + self.tests['ln'][1][1]) + self.assertSnarfRegexp(self.udUrl, r'%s.* \(at' % + self.tests['x0'][1][1]) + self.assertSnarfRegexp(self.udUrl, r'%s.* \(at' % + self.tests['ln'][1][1]) finally: - snarfer.setValue(orig) + cycle.setValue(origcycle) + snarfer.setValue(origsnarfer) def _snarf(self, service): shrink = conf.supybot.plugins.ShrinkUrl From 3a84faeb18bcd7636d310aa9454b6aac6987ee65 Mon Sep 17 00:00:00 2001 From: James Vega Date: Sun, 11 Jul 2010 11:04:33 -0400 Subject: [PATCH 114/243] Include String plugin for Utilities' tests. Signed-off-by: James Vega (cherry picked from commit 3090cffe2c9b9749a42a249285fbc1a4783f1c2e) Signed-off-by: Daniel Folkinshteyn --- plugins/Utilities/test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/Utilities/test.py b/plugins/Utilities/test.py index 9f7c5db14..753b010a4 100644 --- a/plugins/Utilities/test.py +++ b/plugins/Utilities/test.py @@ -30,7 +30,7 @@ from supybot.test import * class UtilitiesTestCase(PluginTestCase): - plugins = ('Utilities',) + plugins = ('Utilities', 'String') def testIgnore(self): self.assertNoResponse('utilities ignore foo bar baz', 1) self.assertError('utilities ignore [re m/foo bar]') From edc4d8644e9bf5ac6af02a3ae28e3927337921a9 Mon Sep 17 00:00:00 2001 From: brian c Date: Mon, 12 Jul 2010 16:42:03 -0400 Subject: [PATCH 115/243] Unix: various enhancements to the ping command: Fixed bug in 100% packet loss response. Errors from ping are now sent to irc.error(). Added packet count, interval, ttl, and wait options. Added additional test cases. Also, Enabled threading for Unix plugin, and for wtf, spell, and ping commands. Signed-off-by: Daniel Folkinshteyn --- plugins/Unix/plugin.py | 52 ++++++++++++++++++++++++++++-------------- plugins/Unix/test.py | 22 +++++++++++++++--- 2 files changed, 54 insertions(+), 20 deletions(-) diff --git a/plugins/Unix/plugin.py b/plugins/Unix/plugin.py index 7396da95c..2dd5ba7b3 100644 --- a/plugins/Unix/plugin.py +++ b/plugins/Unix/plugin.py @@ -66,6 +66,7 @@ def pipeReadline(fd, timeout=2): raise TimeoutError class Unix(callbacks.Plugin): + threaded = True def errno(self, irc, msg, args, s): """ @@ -181,7 +182,7 @@ class Unix(callbacks.Plugin): else: resp = 'Something unexpected was seen in the [ai]spell output.' irc.reply(resp) - spell = wrap(spell, ['something']) + spell = thread(wrap(spell, ['something'])) def fortune(self, irc, msg, args): """takes no arguments @@ -247,11 +248,14 @@ class Unix(callbacks.Plugin): 'on this system, reconfigure the ' 'supybot.plugins.Unix.wtf.command configuration ' 'variable appropriately.') - wtf = wrap(wtf, [optional(('literal', ['is'])), 'something']) + wtf = thread(wrap(wtf, [optional(('literal', ['is'])), 'something'])) - def ping(self, irc, msg, args, host): - """ - Sends an ICMP echo request to the specified host + def ping(self, irc, msg, args, optlist, host): + """[--c ] [--i ] [--t ] [--W ] + Sends an ICMP echo request to the specified host. + The arguments correspond with those listed in ping(8). --c is + limited to 10 packets or less (default is 5). --i is limited to 5 + or less. --W is limited to 10 or less. """ pingCmd = self.registryValue('ping.command') if not pingCmd: @@ -263,26 +267,40 @@ class Unix(callbacks.Plugin): try: host = host.group(0) except AttributeError: pass + args = [pingCmd] + for opt, val in optlist: + if opt == 'c' and val > 10: val = 10 + if opt == 'i' and val > 5: val = 5 + if opt == 'W' and val > 10: val = 10 + args.append('-%s' % opt) + args.append(str(val)) + if '-c' not in args: + args.append('-c') + args.append('5') + args.append(host) try: - inst = subprocess.Popen([pingCmd,'-c','1', host], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - stdin=file(os.devnull)) + inst = subprocess.Popen(args, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + stdin=file(os.devnull)) except OSError, e: irc.error('It seems the configured ping command was ' 'not available (%s).' % e, Raise=True) - result = inst.communicate() - if result[1]: # stderr - irc.reply(' '.join(result[1].split())) + irc.error(' '.join(result[1].split())) else: response = result[0].split("\n"); - irc.reply(' '.join(response[1].split()[3:5]).split(':')[0] - + ': ' + ' '.join(response[-3:])) - - _hostExpr = re.compile(r'^[a-z0-9][a-z0-9\.-]*$', re.I) - ping = wrap(ping, [first('ip', ('matches', _hostExpr, 'Invalid hostname'))]) + if response[1]: + irc.reply(' '.join(response[1].split()[3:5]).split(':')[0] + + ': ' + ' '.join(response[-3:])) + else: + irc.reply(' '.join(response[0].split()[1:3]) + + ': ' + ' '.join(response[-3:])) + + _hostExpr = re.compile(r'^[a-z0-9][a-z0-9\.-]*[a-z0-9]$', re.I) + ping = thread(wrap(ping, [getopts({'c':'positiveInt','i':'float', + 't':'positiveInt','W':'positiveInt'}), + first('ip', ('matches', _hostExpr, 'Invalid hostname'))])) Class = Unix # vim:set shiftwidth=4 softtabstop=4 expandtab textwidth=79: diff --git a/plugins/Unix/test.py b/plugins/Unix/test.py index 8f67ce351..86d73db50 100644 --- a/plugins/Unix/test.py +++ b/plugins/Unix/test.py @@ -66,9 +66,25 @@ if os.name == 'posix': if utils.findBinaryInPath('ping') is not None: def testPing(self): - self.assertNotError('unix ping localhost') + self.assertNotError('unix ping 127.0.0.1') self.assertError('unix ping') - - + self.assertError('unix ping -localhost') + self.assertError('unix ping local%host') + def testPingCount(self): + self.assertNotError('unix ping --c 1 127.0.0.1') + self.assertError('unix ping --c a 127.0.0.1') + self.assertRegexp('unix ping --c 11 127.0.0.1','10 packets') + self.assertRegexp('unix ping 127.0.0.1','5 packets') + def testPingInterval(self): + self.assertNotError('unix ping --i 1 --c 1 127.0.0.1') + self.assertError('unix ping --i a --c 1 127.0.0.1') + # Super-user privileged interval setting + self.assertError('unix ping --i 0.1 --c 1 127.0.0.1') + def testPingTtl(self): + self.assertNotError('unix ping --t 64 --c 1 127.0.0.1') + self.assertError('unix ping --t a --c 1 127.0.0.1') + def testPingWait(self): + self.assertNotError('unix ping --W 1 --c 1 127.0.0.1') + self.assertError('unix ping --W a --c 1 127.0.0.1') # vim:set shiftwidth=4 softtabstop=4 expandtab textwidth=79: From 6905d22c2ced9f518b52ff19fc9bb94838aef18b Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Wed, 14 Jul 2010 15:56:48 -0400 Subject: [PATCH 116/243] Google: add --snippet option to lucky command, which shows the text snippet for the page. --- plugins/Google/plugin.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/plugins/Google/plugin.py b/plugins/Google/plugin.py index 59b8f6ddd..1c732a4e1 100644 --- a/plugins/Google/plugin.py +++ b/plugins/Google/plugin.py @@ -159,18 +159,27 @@ class Google(callbacks.PluginRegexp): else: return format('; '.join(results)) - def lucky(self, irc, msg, args, text): - """ + def lucky(self, irc, msg, args, opts, text): + """[--snippet] Does a google search, but only returns the first result. + If option --snippet is given, returns also the page text snippet. """ + opts = dict(opts) data = self.search(text, msg.args[0], {'smallsearch': True}) if data['responseData']['results']: url = data['responseData']['results'][0]['unescapedUrl'] - irc.reply(url.encode('utf-8')) + if opts.has_key('snippet'): + snippet = " | " + data['responseData']['results'][0]['content'] + snippet = snippet.replace('', '') + snippet = snippet.replace('', '') + else: + snippet = "" + result = url + snippet + irc.reply(result.encode('utf-8')) else: irc.reply('Google found nothing.') - lucky = wrap(lucky, ['text']) + lucky = wrap(lucky, [getopts({'snippet':'',}), 'text']) def google(self, irc, msg, args, optlist, text): """ [--{filter,language} ] From 76f109ce0d3e19418cd620ca616dd8e5132b33d2 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Wed, 14 Jul 2010 19:03:31 -0400 Subject: [PATCH 117/243] Google: in lucky --snippet, properly convert html to text, using utils.web.htmlToText. --- plugins/Google/plugin.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/plugins/Google/plugin.py b/plugins/Google/plugin.py index 1c732a4e1..9754649d0 100644 --- a/plugins/Google/plugin.py +++ b/plugins/Google/plugin.py @@ -170,9 +170,8 @@ class Google(callbacks.PluginRegexp): if data['responseData']['results']: url = data['responseData']['results'][0]['unescapedUrl'] if opts.has_key('snippet'): - snippet = " | " + data['responseData']['results'][0]['content'] - snippet = snippet.replace('', '') - snippet = snippet.replace('', '') + snippet = data['responseData']['results'][0]['content'] + snippet = " | " + utils.web.htmlToText(snippet, tagReplace='') else: snippet = "" result = url + snippet From 920c37c3142095bed50beef03e07871f782761ff Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Wed, 21 Jul 2010 12:48:46 -0400 Subject: [PATCH 118/243] Unix: add call command, giving owner ability to call any system command. --- plugins/Unix/plugin.py | 27 +++++++++++++++++++++++++++ plugins/Unix/test.py | 4 ++++ 2 files changed, 31 insertions(+) diff --git a/plugins/Unix/plugin.py b/plugins/Unix/plugin.py index 2dd5ba7b3..5428f3d54 100644 --- a/plugins/Unix/plugin.py +++ b/plugins/Unix/plugin.py @@ -38,6 +38,7 @@ import random import select import struct import subprocess +import shlex import supybot.utils as utils from supybot.commands import * @@ -302,5 +303,31 @@ class Unix(callbacks.Plugin): 't':'positiveInt','W':'positiveInt'}), first('ip', ('matches', _hostExpr, 'Invalid hostname'))])) + def call(self, irc, msg, args, text): + """ + Calls any command available on the system, and returns its output. + Requires owner capability. + Note that being restricted to owner, this command does not do any + sanity checking on input/output. So it is up to you to make sure + you don't run anything that will spamify your channel or that + will bring your machine to its knees. + """ + args = shlex.split(text) + try: + inst = subprocess.Popen(args, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + stdin=file(os.devnull)) + except OSError, e: + irc.error('It seems the requested command was ' + 'not available (%s).' % e, Raise=True) + result = inst.communicate() + if result[1]: # stderr + irc.error(' '.join(result[1].split())) + if result[0]: # stdout + response = result[0].split("\n"); + response = [l for l in response if l] + irc.replies(response) + call = thread(wrap(call, ["owner", "text"])) + Class = Unix # vim:set shiftwidth=4 softtabstop=4 expandtab textwidth=79: diff --git a/plugins/Unix/test.py b/plugins/Unix/test.py index 86d73db50..e6ac42b84 100644 --- a/plugins/Unix/test.py +++ b/plugins/Unix/test.py @@ -87,4 +87,8 @@ if os.name == 'posix': self.assertNotError('unix ping --W 1 --c 1 127.0.0.1') self.assertError('unix ping --W a --c 1 127.0.0.1') + def testCall(self): + self.assertNotError('unix call /bin/ping -c 1 localhost') + self.assertRegexp('unix call /bin/ping -c 1 localhost', 'ping statistics') + self.assertError('unix call /usr/bin/nosuchcommandaoeuaoeu') # vim:set shiftwidth=4 softtabstop=4 expandtab textwidth=79: From 4aa876255c3e8c176ccb9cd74abe338b7e3a9421 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Wed, 21 Jul 2010 12:57:18 -0400 Subject: [PATCH 119/243] Filter: catch invalid input for unbinary command. --- plugins/Filter/plugin.py | 7 +++++-- plugins/Filter/test.py | 1 + 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/plugins/Filter/plugin.py b/plugins/Filter/plugin.py index c7247bcf5..56aca7c6b 100644 --- a/plugins/Filter/plugin.py +++ b/plugins/Filter/plugin.py @@ -162,8 +162,11 @@ class Filter(callbacks.Plugin): Returns the character representation of binary . Assumes ASCII, 8 digits per character. """ - L = [chr(int(text[i:(i+8)], 2)) for i in xrange(0, len(text), 8)] - irc.reply(''.join(L)) + try: + L = [chr(int(text[i:(i+8)], 2)) for i in xrange(0, len(text), 8)] + irc.reply(''.join(L)) + except ValueError: + irc.error('Invalid input.') unbinary = wrap(unbinary, ['text']) def hexlify(self, irc, msg, args, text): diff --git a/plugins/Filter/test.py b/plugins/Filter/test.py index 9c76e4331..361565b76 100644 --- a/plugins/Filter/test.py +++ b/plugins/Filter/test.py @@ -89,6 +89,7 @@ class FilterTest(ChannelPluginTestCase): def testUnbinary(self): self.assertResponse('unbinary 011011010110111101101111', 'moo') + self.assertError('unbinary moo') def testRot13(self): for s in map(str, range(1000, 1010)): From d469108054bf9bc4a0435c8161dd51490a40b3fd Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Fri, 23 Jul 2010 16:50:25 -0400 Subject: [PATCH 120/243] Topic: fix bug in invalid number error output. Previously, when giving an invalid positive number, error would reference number-1 as being invalid. --- plugins/Topic/plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/Topic/plugin.py b/plugins/Topic/plugin.py index ba80df9f0..6b936bd18 100644 --- a/plugins/Topic/plugin.py +++ b/plugins/Topic/plugin.py @@ -89,7 +89,7 @@ def getTopicNumber(irc, msg, args, state): try: topics[n] except IndexError: - error(str(n)) + error(args[0]) del args[0] while n < 0: n += len(topics) From 3a181b6dd2731e6107a16ccd144054f6fdda6ad7 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Mon, 26 Jul 2010 09:22:07 -0400 Subject: [PATCH 121/243] Google: fix encoding bug in lucky --snippet; need to pass encoded string to utils.web.htmlToText --- plugins/Google/plugin.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/Google/plugin.py b/plugins/Google/plugin.py index 9754649d0..56aea4cf9 100644 --- a/plugins/Google/plugin.py +++ b/plugins/Google/plugin.py @@ -168,14 +168,14 @@ class Google(callbacks.PluginRegexp): opts = dict(opts) data = self.search(text, msg.args[0], {'smallsearch': True}) if data['responseData']['results']: - url = data['responseData']['results'][0]['unescapedUrl'] + url = data['responseData']['results'][0]['unescapedUrl'].encode('utf-8') if opts.has_key('snippet'): - snippet = data['responseData']['results'][0]['content'] + snippet = data['responseData']['results'][0]['content'].encode('utf-8') snippet = " | " + utils.web.htmlToText(snippet, tagReplace='') else: snippet = "" result = url + snippet - irc.reply(result.encode('utf-8')) + irc.reply(result) else: irc.reply('Google found nothing.') lucky = wrap(lucky, [getopts({'snippet':'',}), 'text']) From 9398025088fa09e9e215d60a99a89379d99b8832 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Mon, 2 Aug 2010 17:48:51 -0400 Subject: [PATCH 122/243] MoobotFactoids: add check_same_thread=False to the sqlite3 connect calls, so it doesn't complain. (thanks malex!) also fix up the code a bit so it doesn't fail the tests, and doesn't require presence of plain sqlite. --- plugins/MoobotFactoids/plugin.py | 6 +++--- plugins/MoobotFactoids/test.py | 7 +++---- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/plugins/MoobotFactoids/plugin.py b/plugins/MoobotFactoids/plugin.py index 056e3af5d..dddc781e4 100644 --- a/plugins/MoobotFactoids/plugin.py +++ b/plugins/MoobotFactoids/plugin.py @@ -107,11 +107,11 @@ class SqliteMoobotDB(object): filename = plugins.makeChannelFilename(self.filename, channel) if os.path.exists(filename): - db = sqlite3.connect(filename) + db = sqlite3.connect(filename, check_same_thread = False) db.text_factory = str self.dbs[channel] = db return db - db = sqlite3.connect(filename) + db = sqlite3.connect(filename, check_same_thread = False) db.text_factory = str self.dbs[channel] = db cursor = db.cursor() @@ -281,7 +281,7 @@ class SqliteMoobotDB(object): results = cursor.fetchall() return results -MoobotDB = plugins.DB('MoobotFactoids', {'sqlite': SqliteMoobotDB}) +MoobotDB = plugins.DB('MoobotFactoids', {'sqlite3': SqliteMoobotDB}) class MoobotFactoids(callbacks.Plugin): """Add the help for "@help MoobotFactoids" here (assuming you don't implement a MoobotFactoids diff --git a/plugins/MoobotFactoids/test.py b/plugins/MoobotFactoids/test.py index aec775492..94e3c6c7e 100644 --- a/plugins/MoobotFactoids/test.py +++ b/plugins/MoobotFactoids/test.py @@ -31,11 +31,10 @@ from supybot.test import * #import supybot.plugin as plugin import supybot.ircutils as ircutils - try: - import sqlite + import sqlite3 except ImportError: - sqlite = None + from pysqlite2 import dbapi2 as sqlite3 # for python2.4 MF = plugin.loadPluginModule('MoobotFactoids') MFconf = conf.supybot.plugins.MoobotFactoids @@ -223,7 +222,7 @@ class FactoidsTestCase(ChannelPluginTestCase): self.assertRegexp('most authored', 'Most prolific authors:.*moo.*(1).*boo.*(1)') self.assertRegexp('most recent', - "2 latest factoids:.*mogle.*moogle.*") + "2 latest factoids:.*moogle.*mogle.*") self.assertResponse('moogle', 'moo') self.assertRegexp('most popular', "Top 1 requested factoid:.*moogle.*(2)") From 0c300162d843dcf4ede805922ec221aec0884be5 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Thu, 5 Aug 2010 01:20:46 -0400 Subject: [PATCH 123/243] Create a commands.process function which runs a function inside a separate process. This is the only way to limit the execution time of a possibly long-running python statement. Use this on String.re, due to the possibility of pathologically long re matching in python. This allows us to remove the 'trusted-only' restriction on string.re. In the future, this should probably be used in other places that take user-supplied regexps, such as 'misc last --regexp', for example, as well as other potentially long-running tasks that can block the bot. --- plugins/String/plugin.py | 10 ++++++---- src/callbacks.py | 17 +++++++++++++++++ src/commands.py | 25 +++++++++++++++++++++++++ src/world.py | 10 ++++++++++ 4 files changed, 58 insertions(+), 4 deletions(-) diff --git a/plugins/String/plugin.py b/plugins/String/plugin.py index d6b4c0dbe..b836cbb1f 100644 --- a/plugins/String/plugin.py +++ b/plugins/String/plugin.py @@ -33,10 +33,12 @@ import binascii import supybot.utils as utils from supybot.commands import * +import supybot.commands as commands import supybot.plugins as plugins import supybot.ircutils as ircutils import supybot.callbacks as callbacks +import multiprocessing class String(callbacks.Plugin): def ord(self, irc, msg, args, letter): @@ -136,10 +138,10 @@ class String(callbacks.Plugin): s = 'You probably don\'t want to match the empty string.' irc.error(s) else: - irc.reply(f(text)) - re = wrap(re, [('checkCapability', 'trusted'), - first('regexpMatcher', 'regexpReplacer'), - 'text']) + v = commands.process(f, text, timeout=10) + irc.reply(v) + re = thread(wrap(re, [first('regexpMatcher', 'regexpReplacer'), + 'text'])) def xor(self, irc, msg, args, password, text): """ diff --git a/src/callbacks.py b/src/callbacks.py index a836165e3..822e06616 100644 --- a/src/callbacks.py +++ b/src/callbacks.py @@ -976,6 +976,23 @@ class CommandThread(world.SupyThread): finally: self.cb.threaded = self.originalThreaded +class CommandProcess(world.SupyProcess): + """Just does some extra logging and error-recovery for commands that need + to run in processes. + """ + def __init__(self, target=None, args=(), kwargs={}): + self.command = args[0] + self.cb = target.im_self + procName = 'Process #%s (for %s.%s)' % (world.processesSpawned, + self.cb.name(), + self.command) + log.debug('Spawning process %s (args: %r)', procName, args) + self.__parent = super(CommandProcess, self) + self.__parent.__init__(target=target, name=procName, + args=args, kwargs=kwargs) + + def run(self): + self.__parent.run() class CanonicalString(registry.NormalizedString): def normalize(self, s): diff --git a/src/commands.py b/src/commands.py index 717465a9c..1879082c2 100644 --- a/src/commands.py +++ b/src/commands.py @@ -37,6 +37,8 @@ import types import getopt import inspect import threading +import multiprocessing #python2.6 or later! +import Queue import supybot.log as log import supybot.conf as conf @@ -67,6 +69,29 @@ def thread(f): f(self, irc, msg, args, *L, **kwargs) return utils.python.changeFunctionName(newf, f.func_name, f.__doc__) +def process(f, *args, **kwargs): + """Runs a function in a subprocess. + Takes an extra timeout argument, which, if supplied, limits the length + of execution of target function to seconds.""" + timeout = kwargs.pop('timeout') + q = multiprocessing.Queue() + def newf(f, q, *args, **kwargs): + r = f(*args, **kwargs) + q.put(r) + targetArgs = (f, q,) + args + p = world.SupyProcess(target=newf, + args=targetArgs, kwargs=kwargs) + p.start() + p.join(timeout) + if p.is_alive(): + p.terminate() + q.put("Function call aborted due to timeout.") + try: + v = q.get(block=False) + except Queue.Empty: + v = "Nothing returned." + return v + class UrlSnarfThread(world.SupyThread): def __init__(self, *args, **kwargs): assert 'url' in kwargs diff --git a/src/world.py b/src/world.py index 23ec3789e..c69d37a7a 100644 --- a/src/world.py +++ b/src/world.py @@ -37,6 +37,7 @@ import sys import time import atexit import threading +import multiprocessing # python 2.6 and later! if sys.version_info >= (2, 5, 0): import re as sre @@ -67,6 +68,15 @@ class SupyThread(threading.Thread): super(SupyThread, self).__init__(*args, **kwargs) log.debug('Spawning thread %q.', self.getName()) +processesSpawned = 1 # Starts at one for the initial process. +class SupyProcess(multiprocessing.Process): + def __init__(self, *args, **kwargs): + global processesSpawned + processesSpawned += 1 + super(SupyProcess, self).__init__(*args, **kwargs) + log.debug('Spawning process %q.', self.name) + + commandsProcessed = 0 ircs = [] # A list of all the IRCs. From 1b84e208caf3c98803cc0c136a1b8fb4ce2bcf7c Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Thu, 5 Aug 2010 01:27:38 -0400 Subject: [PATCH 124/243] Format.replace: replacement text is now allowed to be the empty string. --- plugins/Format/plugin.py | 2 +- plugins/Format/test.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/plugins/Format/plugin.py b/plugins/Format/plugin.py index ebe42c407..6dc16a2cb 100644 --- a/plugins/Format/plugin.py +++ b/plugins/Format/plugin.py @@ -97,7 +97,7 @@ class Format(callbacks.Plugin): with in . """ irc.reply(text.replace(bad, good)) - replace = wrap(replace, ['something', 'something', 'text']) + replace = wrap(replace, ['something', 'anything', 'text']) def upper(self, irc, msg, args, text): """ diff --git a/plugins/Format/test.py b/plugins/Format/test.py index f891e0dd7..3ed19470e 100644 --- a/plugins/Format/test.py +++ b/plugins/Format/test.py @@ -58,6 +58,7 @@ class FormatTestCase(PluginTestCase): def testReplace(self): self.assertResponse('replace # %23 bla#foo', 'bla%23foo') + self.assertResponse('replace foo "" blafoobar', 'blabar') def testUpper(self): self.assertResponse('upper foo', 'FOO') From 89cbc7efdfb1a956db9624b4fd2b1e23140a6ddf Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Thu, 5 Aug 2010 13:45:02 -0400 Subject: [PATCH 125/243] Some improvements to the commands.process function - better process naming and informational output. --- plugins/String/plugin.py | 2 +- src/callbacks.py | 8 ++++---- src/commands.py | 17 +++++++++++------ 3 files changed, 16 insertions(+), 11 deletions(-) diff --git a/plugins/String/plugin.py b/plugins/String/plugin.py index b836cbb1f..6127e2755 100644 --- a/plugins/String/plugin.py +++ b/plugins/String/plugin.py @@ -138,7 +138,7 @@ class String(callbacks.Plugin): s = 'You probably don\'t want to match the empty string.' irc.error(s) else: - v = commands.process(f, text, timeout=10) + v = commands.process(f, text, timeout=10, pn=self.name(), cn='re') irc.reply(v) re = thread(wrap(re, [first('regexpMatcher', 'regexpReplacer'), 'text'])) diff --git a/src/callbacks.py b/src/callbacks.py index 822e06616..4d9396708 100644 --- a/src/callbacks.py +++ b/src/callbacks.py @@ -981,11 +981,11 @@ class CommandProcess(world.SupyProcess): to run in processes. """ def __init__(self, target=None, args=(), kwargs={}): - self.command = args[0] - self.cb = target.im_self + pn = kwargs.pop('pn', 'Unknown') + cn = kwargs.pop('cn', 'unknown') procName = 'Process #%s (for %s.%s)' % (world.processesSpawned, - self.cb.name(), - self.command) + pn, + cn) log.debug('Spawning process %s (args: %r)', procName, args) self.__parent = super(CommandProcess, self) self.__parent.__init__(target=target, name=procName, diff --git a/src/commands.py b/src/commands.py index 1879082c2..8b1994faf 100644 --- a/src/commands.py +++ b/src/commands.py @@ -70,22 +70,27 @@ def thread(f): return utils.python.changeFunctionName(newf, f.func_name, f.__doc__) def process(f, *args, **kwargs): - """Runs a function in a subprocess. - Takes an extra timeout argument, which, if supplied, limits the length - of execution of target function to seconds.""" - timeout = kwargs.pop('timeout') + """Runs a function in a subprocess. + + Several extra keyword arguments can be supplied. + , the pluginname, and , the command name, are strings used to + create the process name, for identification purposes. + , if supplied, limits the length of execution of target + function to seconds.""" + timeout = kwargs.pop('timeout', None) + q = multiprocessing.Queue() def newf(f, q, *args, **kwargs): r = f(*args, **kwargs) q.put(r) targetArgs = (f, q,) + args - p = world.SupyProcess(target=newf, + p = callbacks.CommandProcess(target=newf, args=targetArgs, kwargs=kwargs) p.start() p.join(timeout) if p.is_alive(): p.terminate() - q.put("Function call aborted due to timeout.") + q.put("%s aborted due to timeout." % (p.name,)) try: v = q.get(block=False) except Queue.Empty: From f55606cfb4bfb8a23f3a74fc430bc342dbae4b53 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Thu, 5 Aug 2010 13:54:54 -0400 Subject: [PATCH 126/243] Status: add 'processes' command, the multiprocessing equivalent of the threads command. --- plugins/Status/plugin.py | 12 ++++++++++++ plugins/Status/test.py | 2 ++ 2 files changed, 14 insertions(+) diff --git a/plugins/Status/plugin.py b/plugins/Status/plugin.py index fccd58336..dcc952d9e 100644 --- a/plugins/Status/plugin.py +++ b/plugins/Status/plugin.py @@ -93,6 +93,18 @@ class Status(callbacks.Plugin): (len(threads), 'thread'), len(threads), threads) irc.reply(s) threads = wrap(threads) + + def processes(self, irc, msg, args): + """takes no arguments + + Returns the number of processes that have been spawned. + """ + # TODO: maintain a dict of active subprocesses, so we can + # include a list thereof in output, linke in threads(). maybe? + s = format('I have spawned %n.', + (world.processesSpawned, 'process')) + irc.reply(s) + processes = wrap(processes) def net(self, irc, msg, args): """takes no arguments diff --git a/plugins/Status/test.py b/plugins/Status/test.py index 4843c4145..de456b061 100644 --- a/plugins/Status/test.py +++ b/plugins/Status/test.py @@ -71,6 +71,8 @@ class StatusTestCase(PluginTestCase): def testThreads(self): self.assertNotError('threads') + def testProcesses(self): + self.assertNotError('processes') # vim:set shiftwidth=4 softtabstop=4 expandtab textwidth=79: From 27be9ceb74cc37dde6a8bbe5ff486fa2ee9671b5 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Thu, 5 Aug 2010 14:48:12 -0400 Subject: [PATCH 127/243] commands.process: return immediately when terminating process, without having to deal with the queue. otherwise, we have to block for $smalldelay between putting and getting the item, since queue putting is not instantaneous and sometimes we would get 'nothing returned' instead of the timeout message. --- src/commands.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands.py b/src/commands.py index 8b1994faf..674b631f4 100644 --- a/src/commands.py +++ b/src/commands.py @@ -90,7 +90,7 @@ def process(f, *args, **kwargs): p.join(timeout) if p.is_alive(): p.terminate() - q.put("%s aborted due to timeout." % (p.name,)) + return "%s aborted due to timeout." % (p.name,) try: v = q.get(block=False) except Queue.Empty: From 89fd19ed7d64aba76de05adda4c33b2dcccf6161 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Fri, 6 Aug 2010 14:48:21 -0400 Subject: [PATCH 128/243] Status.processes: add output of currently active processes. --- plugins/Status/plugin.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/plugins/Status/plugin.py b/plugins/Status/plugin.py index dcc952d9e..61ccfccde 100644 --- a/plugins/Status/plugin.py +++ b/plugins/Status/plugin.py @@ -32,6 +32,7 @@ import os import sys import time import threading +import multiprocessing import subprocess import supybot.conf as conf @@ -97,12 +98,15 @@ class Status(callbacks.Plugin): def processes(self, irc, msg, args): """takes no arguments - Returns the number of processes that have been spawned. + Returns the number of processes that have been spawned, and list of + ones that are still active. """ - # TODO: maintain a dict of active subprocesses, so we can - # include a list thereof in output, linke in threads(). maybe? - s = format('I have spawned %n.', - (world.processesSpawned, 'process')) + ps = [multiprocessing.current_process().name] + ps = ps + [p.name for p in multiprocessing.active_children()] + s = format('I have spawned %n; %n %b still currently active: %L.', + (world.processesSpawned, 'process'), + (len(ps), 'process'), + len(ps), ps) irc.reply(s) processes = wrap(processes) From e4498664bb3b25500fa4dc88f8343d483abe32f6 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Sun, 8 Aug 2010 00:39:51 -0400 Subject: [PATCH 129/243] Fix error handling for subprocesses. --- src/commands.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/commands.py b/src/commands.py index 674b631f4..5182ea582 100644 --- a/src/commands.py +++ b/src/commands.py @@ -81,8 +81,11 @@ def process(f, *args, **kwargs): q = multiprocessing.Queue() def newf(f, q, *args, **kwargs): - r = f(*args, **kwargs) - q.put(r) + try: + r = f(*args, **kwargs) + q.put(r) + except Exception as e: + q.put(e) targetArgs = (f, q,) + args p = callbacks.CommandProcess(target=newf, args=targetArgs, kwargs=kwargs) @@ -95,6 +98,8 @@ def process(f, *args, **kwargs): v = q.get(block=False) except Queue.Empty: v = "Nothing returned." + if isinstance(v, Exception): + v = "Error: " + str(v) return v class UrlSnarfThread(world.SupyThread): From 2d9f61e66c5d15441738f564922fe3b35cde2188 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Sun, 8 Aug 2010 01:43:05 -0400 Subject: [PATCH 130/243] String: make re timeout configurable. --- plugins/String/config.py | 8 +++++++- plugins/String/plugin.py | 3 ++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/plugins/String/config.py b/plugins/String/config.py index e4f1840ad..75d2cf446 100644 --- a/plugins/String/config.py +++ b/plugins/String/config.py @@ -50,5 +50,11 @@ conf.registerGlobalValue(String.levenshtein, 'max', and more time. Using nested commands, strings can get quite large, hence this variable, to limit the size of arguments passed to the levenshtein command.""")) - +conf.registerGroup(String, 're') +conf.registerGlobalValue(String.re, 'timeout', + registry.PositiveFloat(5, """Determines the maximum time, in seconds, that + a regular expression is given to execute before being terminated. Since + there is a possibility that user input for the re command can cause it to + eat up large amounts of ram or cpu time, it's a good idea to keep this + low. Most normal regexps should not take very long at all.""")) # vim:set shiftwidth=4 softtabstop=4 expandtab textwidth=79: diff --git a/plugins/String/plugin.py b/plugins/String/plugin.py index 6127e2755..c390a1885 100644 --- a/plugins/String/plugin.py +++ b/plugins/String/plugin.py @@ -138,7 +138,8 @@ class String(callbacks.Plugin): s = 'You probably don\'t want to match the empty string.' irc.error(s) else: - v = commands.process(f, text, timeout=10, pn=self.name(), cn='re') + t = self.registryValue('re.timeout') + v = commands.process(f, text, timeout=t, pn=self.name(), cn='re') irc.reply(v) re = thread(wrap(re, [first('regexpMatcher', 'regexpReplacer'), 'text'])) From 4ea3761b4b8bb6900b20cdd68e5d979ad0eddfe7 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Sun, 8 Aug 2010 01:46:05 -0400 Subject: [PATCH 131/243] String: make levenshtein command threaded, since it can take a nontrivial amount of time with longer inputs. --- plugins/String/plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/String/plugin.py b/plugins/String/plugin.py index c390a1885..4f3398824 100644 --- a/plugins/String/plugin.py +++ b/plugins/String/plugin.py @@ -102,7 +102,7 @@ class String(callbacks.Plugin): 'it with some smaller inputs.') else: irc.reply(str(utils.str.distance(s1, s2))) - levenshtein = wrap(levenshtein, ['something', 'text']) + levenshtein = thread(wrap(levenshtein, ['something', 'text'])) def soundex(self, irc, msg, args, text, length): """ [] From 808cb2c9f4c2c5a0688b1624a9d85a50a7c5809d Mon Sep 17 00:00:00 2001 From: James Vega Date: Mon, 26 Jul 2010 19:48:37 -0400 Subject: [PATCH 132/243] Ensure channel-specific reply.whenNotAddressed works. Signed-off-by: James Vega (cherry picked from commit 166f32dcb02eab58659882fb003502c1e990797a) Signed-off-by: Daniel Folkinshteyn --- src/callbacks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/callbacks.py b/src/callbacks.py index 4d9396708..45b34650c 100644 --- a/src/callbacks.py +++ b/src/callbacks.py @@ -126,7 +126,7 @@ def _addressed(nick, msg, prefixChars=None, nicks=None, # There should be some separator between the nick and the # previous alphanumeric character. return possiblePayload - if conf.supybot.reply.whenNotAddressed(): + if get(conf.supybot.reply.whenNotAddressed): return payload else: return '' From 29837e94b1b27d3566154c4145765322b3fab14b Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Wed, 11 Aug 2010 00:43:05 -0400 Subject: [PATCH 133/243] Make plugin loading/reloading case-insensitive. Since load/reload was the only place where case mattered for plugins, and it tripped up a lot of new users, this should be a nice bit of usability improvement. --- src/plugin.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/plugin.py b/src/plugin.py index 25cbe67a7..c1d478059 100644 --- a/src/plugin.py +++ b/src/plugin.py @@ -32,6 +32,7 @@ import sys import imp import os.path import linecache +import re import supybot.log as log import supybot.conf as conf @@ -55,6 +56,13 @@ def loadPluginModule(name, ignoreDeprecation=False): except EnvironmentError: # OSError, IOError superclass. log.warning('Invalid plugin directory: %s; removing.', dir) conf.supybot.directories.plugins().remove(dir) + if name not in files: + matched_names = filter(lambda x: re.search(r'(?i)^%s$' % (name,), x), + files) + if len(matched_names) == 1: + name = matched_names[0] + else: + raise ImportError, name moduleInfo = imp.find_module(name, pluginDirs) try: module = imp.load_module(name, *moduleInfo) From 2c37d3e6a794a18a63f0a293d0b8a363c562d4df Mon Sep 17 00:00:00 2001 From: quantumlemur Date: Thu, 19 Aug 2010 18:55:03 -0400 Subject: [PATCH 134/243] MessageParser: added configurable separator for the list command. --- plugins/MessageParser/config.py | 3 +++ plugins/MessageParser/plugin.py | 3 ++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/plugins/MessageParser/config.py b/plugins/MessageParser/config.py index 456feb023..f70dbdb24 100644 --- a/plugins/MessageParser/config.py +++ b/plugins/MessageParser/config.py @@ -64,5 +64,8 @@ conf.registerChannelValue(MessageParser, 'requireManageCapability', channel-level capabilities. Note that absence of an explicit anticapability means user has capability.""")) +conf.registerChannelValue(MessageParser, 'listSeparator', + registry.String(', ', """Determines the separator used between rexeps when + shown by the list command.""")) # vim:set shiftwidth=4 tabstop=4 expandtab textwidth=79: diff --git a/plugins/MessageParser/plugin.py b/plugins/MessageParser/plugin.py index 7995e1677..62d8e9dad 100644 --- a/plugins/MessageParser/plugin.py +++ b/plugins/MessageParser/plugin.py @@ -368,7 +368,8 @@ class MessageParser(callbacks.Plugin, plugins.ChannelDBHandler): return s = [ "\"%s\" (%d)" % (regexp[0], regexp[1]) for regexp in regexps ] - irc.reply(', '.join(s)) + separator = self.registryValue('listSeparator', channel) + irc.reply(separator.join(s)) list = wrap(list, ['channel']) def rank(self, irc, msg, args, channel): From 2a40d6eb9083f38b213bd5793fa5a47d3cd9996d Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Fri, 20 Aug 2010 10:31:05 -0400 Subject: [PATCH 135/243] Consolidate the version string to reside in one central place to ease change making. --- sandbox/release.py | 2 +- scripts/supybot | 3 ++- setup.py | 3 ++- src/conf.py | 2 +- src/version.py | 3 +++ 5 files changed, 9 insertions(+), 4 deletions(-) create mode 100644 src/version.py diff --git a/sandbox/release.py b/sandbox/release.py index 0538e6c30..d07eee0b9 100644 --- a/sandbox/release.py +++ b/sandbox/release.py @@ -90,7 +90,7 @@ if __name__ == '__main__': error('Invalid third line in ChangeLog.') print 'Updating version in version files.' - versionFiles = ('src/conf.py', 'scripts/supybot', 'setup.py') + versionFiles = ('src/version.py') for fn in versionFiles: sh = 'perl -pi -e "s/^version\s*=.*/version = \'%s\'/" %s' % (v, fn) system(sh, 'Error changing version in %s' % fn) diff --git a/scripts/supybot b/scripts/supybot index 7f1953776..9054cef57 100644 --- a/scripts/supybot +++ b/scripts/supybot @@ -65,6 +65,8 @@ import supybot.utils as utils import supybot.registry as registry import supybot.questions as questions +from supybot.version import version + def main(): import supybot.conf as conf import supybot.world as world @@ -125,7 +127,6 @@ def main(): log.info('Total CPU time taken: %s seconds.', user+system) log.info('No more Irc objects, exiting.') -version = '0.83.4.1+git' if __name__ == '__main__': parser = optparse.OptionParser(usage='Usage: %prog [options] configFile', version='Supybot %s' % version) diff --git a/setup.py b/setup.py index 538c4e837..166a84cd6 100644 --- a/setup.py +++ b/setup.py @@ -47,6 +47,8 @@ import glob import shutil import os.path +from src.version import version + plugins = [s for s in os.listdir('plugins') if os.path.exists(os.path.join('plugins', s, 'plugin.py'))] @@ -116,7 +118,6 @@ package_dir = {'supybot': 'src', for plugin in plugins: package_dir['supybot.plugins.' + plugin] = 'plugins/' + plugin -version = '0.83.4.1+git' setup( # Metadata name='supybot', diff --git a/src/conf.py b/src/conf.py index 114b87878..3768682f4 100644 --- a/src/conf.py +++ b/src/conf.py @@ -40,7 +40,7 @@ import supybot.ircutils as ircutils ### # version: This should be pretty obvious. ### -version = '0.83.4.1+git' +from supybot.version import version ### # *** The following variables are affected by command-line options. They are diff --git a/src/version.py b/src/version.py new file mode 100644 index 000000000..f87586219 --- /dev/null +++ b/src/version.py @@ -0,0 +1,3 @@ +"""stick the various versioning attributes in here, so we only have to change +them once.""" +version = '0.83.4.1+gribble' From 05e8b658e633f37456340b83b01fa304f9fcc5d3 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Fri, 20 Aug 2010 11:08:31 -0400 Subject: [PATCH 136/243] Add script to automatically update version with a datestamp upon commit. --- sandbox/version-commit | 10 ++++++++++ src/version.py | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) create mode 100755 sandbox/version-commit diff --git a/sandbox/version-commit b/sandbox/version-commit new file mode 100755 index 000000000..2a41cd134 --- /dev/null +++ b/sandbox/version-commit @@ -0,0 +1,10 @@ +#!/bin/bash +# Use this script to make commits. It will automatically update the version +# string to have a datetime stamp, prior to committing. +# All arguments get passed to git commit, so of course, use the -a option so that +# the version change gets added to the commit. +echo "Updating version..." +perl -pi -e "s/^version\s*=\s*['\"]([\S]+)[ '\"].*/version = '\1 ($( date -Iseconds ))'/" src/version.py +if [ "$?" -eq "0" ]; then + git commit $@ +fi diff --git a/src/version.py b/src/version.py index f87586219..33e683489 100644 --- a/src/version.py +++ b/src/version.py @@ -1,3 +1,3 @@ """stick the various versioning attributes in here, so we only have to change them once.""" -version = '0.83.4.1+gribble' +version = '0.83.4.1+gribble (2010-08-20T11:08:31-0400)' From 6230a32c69cd11b5291da241636b71e633262288 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Fri, 20 Aug 2010 12:35:58 -0400 Subject: [PATCH 137/243] Fix setup.py version import. It failed on clean install, since module supybot is not yet available. When importing 'from src', that pulled src/__init__.py, which in turn tried to import the supybot module. Now we edit sys.path and import the .py file directly. --- setup.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 166a84cd6..8375f3de5 100644 --- a/setup.py +++ b/setup.py @@ -47,7 +47,10 @@ import glob import shutil import os.path -from src.version import version +# grab version from src directly, since we do not yet have supybot +# available as a module. +sys.path.append('./src') +from version import version plugins = [s for s in os.listdir('plugins') if os.path.exists(os.path.join('plugins', s, 'plugin.py'))] From e5e9cbba69367df16de60e1b56e5e71f05ef6981 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Wed, 1 Sep 2010 02:02:47 -0400 Subject: [PATCH 138/243] Scheduler: add scheduled task persistence. The list of tasks scheduled with the Scheduler plugin is now saved on exit, and restored upon restart. Previously all scheduled tasks would be forgotten upon bot restart, which was undesirable behavior. --- plugins/Scheduler/plugin.py | 90 +++++++++++++++++++++++++++++++++---- 1 file changed, 81 insertions(+), 9 deletions(-) diff --git a/plugins/Scheduler/plugin.py b/plugins/Scheduler/plugin.py index 1f02c6eff..abcb337bb 100644 --- a/plugins/Scheduler/plugin.py +++ b/plugins/Scheduler/plugin.py @@ -28,18 +28,73 @@ ### import time +import os +import shutil +import tempfile +import cPickle as pickle import supybot.conf as conf import supybot.utils as utils from supybot.commands import * import supybot.schedule as schedule import supybot.callbacks as callbacks +import supybot.world as world + +datadir = conf.supybot.directories.data() +filename = conf.supybot.directories.data.dirize('Scheduler.pickle') class Scheduler(callbacks.Plugin): def __init__(self, irc): self.__parent = super(Scheduler, self) self.__parent.__init__(irc) self.events = {} + self._restoreEvents(irc) + world.flushers.append(self._flush) + + def _restoreEvents(self, irc): + try: + pkl = open(filename, 'rb') + try: + eventdict = pickle.load(pkl) + except Exception, e: + self.log.debug('Unable to load pickled data: %s', e) + return + finally: + pkl.close() + except IOError, e: + self.log.debug('Unable to open pickle file: %s', e) + return + try: + for name, event in eventdict.iteritems(): + ircobj = callbacks.ReplyIrcProxy(irc, event['msg']) + if event['type'] == 'single': # non-repeating event + self._add(ircobj, event['msg'], + event['time'], event['command']) + elif event['type'] == 'repeat': # repeating event + self._repeat(ircobj, event['msg'], name, + event['time'], event['command']) + except AssertionError, e: + if str(e) == 'An event with the same name has already been scheduled.': + pass # we must be reloading the plugin + else: + raise + + def _flush(self): + try: + pklfd, tempfn = tempfile.mkstemp(suffix='scheduler', dir=datadir) + pkl = os.fdopen(pklfd, 'wb') + try: + pickle.dump(self.events, pkl) + except Exception, e: + self.log.warning('Unable to store pickled data: %s', e) + pkl.close() + shutil.move(tempfn, filename) + except (IOError, shutil.Error), e: + self.log.warning('File error: %s', e) + + def die(self): + world.flushers.remove(self._flush) + self.__parent.die() def _makeCommandFunction(self, irc, msg, command, remove=True): """Makes a function suitable for scheduling from command.""" @@ -52,6 +107,16 @@ class Scheduler(callbacks.Plugin): self.Proxy(irc.irc, msg, tokens) return f + def _add(self, irc, msg, t, command): + f = self._makeCommandFunction(irc, msg, command) + id = schedule.addEvent(f, t) + f.eventId = id + self.events[str(id)] = {'command':command, + 'msg':msg, + 'time':t, + 'type':'single'} + return id + def add(self, irc, msg, args, seconds, command): """ @@ -61,10 +126,8 @@ class Scheduler(callbacks.Plugin): command was given in (with no prefixed nick, a consequence of using echo). Do pay attention to the quotes in that example. """ - f = self._makeCommandFunction(irc, msg, command) - id = schedule.addEvent(f, time.time() + seconds) - f.eventId = id - self.events[str(id)] = command + t = time.time() + seconds + id = self._add(irc, msg, t, command) irc.replySuccess(format('Event #%i added.', id)) add = wrap(add, ['positiveInt', 'text']) @@ -88,6 +151,15 @@ class Scheduler(callbacks.Plugin): irc.error('Invalid event id.') remove = wrap(remove, ['lowered']) + def _repeat(self, irc, msg, name, seconds, command): + f = self._makeCommandFunction(irc, msg, command, remove=False) + id = schedule.addPeriodicEvent(f, seconds, name) + assert id == name + self.events[name] = {'command':command, + 'msg':msg, + 'time':seconds, + 'type':'repeat'} + def repeat(self, irc, msg, args, name, seconds, command): """ @@ -100,10 +172,7 @@ class Scheduler(callbacks.Plugin): if name in self.events: irc.error('There is already an event with that name, please ' 'choose another name.', Raise=True) - self.events[name] = command - f = self._makeCommandFunction(irc, msg, command, remove=False) - id = schedule.addPeriodicEvent(f, seconds, name) - assert id == name + self._repeat(irc, msg, name, seconds, command) # We don't reply because the command runs immediately. # But should we? What if the command doesn't have visible output? # irc.replySuccess() @@ -118,8 +187,11 @@ class Scheduler(callbacks.Plugin): if L: L.sort() for (i, (name, command)) in enumerate(L): - L[i] = format('%s: %q', name, command) + L[i] = format('%s: %q', name, command['command']) irc.reply(format('%L', L)) + irc.reply(schedule.schedule.schedule) + irc.reply(schedule.schedule.events) + irc.reply(schedule.schedule.counter) else: irc.reply('There are currently no scheduled commands.') list = wrap(list) From f9fc250a621c24c40d3398d642b39aa650277a31 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Wed, 1 Sep 2010 16:37:55 -0400 Subject: [PATCH 139/243] Scheduler: handle event persistence on plugin reload. Write data to disk on unload; populate events dict with events which are still scheduled on reload. --- plugins/Scheduler/plugin.py | 31 ++++++++++++++++++++----------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/plugins/Scheduler/plugin.py b/plugins/Scheduler/plugin.py index abcb337bb..a6590467f 100644 --- a/plugins/Scheduler/plugin.py +++ b/plugins/Scheduler/plugin.py @@ -64,20 +64,28 @@ class Scheduler(callbacks.Plugin): except IOError, e: self.log.debug('Unable to open pickle file: %s', e) return - try: - for name, event in eventdict.iteritems(): - ircobj = callbacks.ReplyIrcProxy(irc, event['msg']) + for name, event in eventdict.iteritems(): + ircobj = callbacks.ReplyIrcProxy(irc, event['msg']) + try: if event['type'] == 'single': # non-repeating event + n = None + if schedule.schedule.counter > int(name): + # counter not reset, we're probably reloading the plugin + # though we'll never know for sure, because other + # plugins can schedule stuff, too. + n = int(name) self._add(ircobj, event['msg'], - event['time'], event['command']) + event['time'], event['command'], n) elif event['type'] == 'repeat': # repeating event self._repeat(ircobj, event['msg'], name, event['time'], event['command']) - except AssertionError, e: - if str(e) == 'An event with the same name has already been scheduled.': - pass # we must be reloading the plugin - else: - raise + except AssertionError, e: + if str(e) == 'An event with the same name has already been scheduled.': + # we must be reloading the plugin, event is still scheduled + self.log.info('Event %s already exists, adding to dict.' % (name,)) + self.events[name] = event + else: + raise def _flush(self): try: @@ -93,6 +101,7 @@ class Scheduler(callbacks.Plugin): self.log.warning('File error: %s', e) def die(self): + self._flush() world.flushers.remove(self._flush) self.__parent.die() @@ -107,9 +116,9 @@ class Scheduler(callbacks.Plugin): self.Proxy(irc.irc, msg, tokens) return f - def _add(self, irc, msg, t, command): + def _add(self, irc, msg, t, command, name=None): f = self._makeCommandFunction(irc, msg, command) - id = schedule.addEvent(f, t) + id = schedule.addEvent(f, t, name) f.eventId = id self.events[str(id)] = {'command':command, 'msg':msg, From 3a0e19bc225539bfa53b2b9bb953bf6414178878 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Wed, 1 Sep 2010 17:37:31 -0400 Subject: [PATCH 140/243] Scheduler: remove spammy debug output. --- plugins/Scheduler/plugin.py | 3 --- src/version.py | 2 +- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/plugins/Scheduler/plugin.py b/plugins/Scheduler/plugin.py index a6590467f..4a4611ad5 100644 --- a/plugins/Scheduler/plugin.py +++ b/plugins/Scheduler/plugin.py @@ -198,9 +198,6 @@ class Scheduler(callbacks.Plugin): for (i, (name, command)) in enumerate(L): L[i] = format('%s: %q', name, command['command']) irc.reply(format('%L', L)) - irc.reply(schedule.schedule.schedule) - irc.reply(schedule.schedule.events) - irc.reply(schedule.schedule.counter) else: irc.reply('There are currently no scheduled commands.') list = wrap(list) diff --git a/src/version.py b/src/version.py index 33e683489..673e8b88c 100644 --- a/src/version.py +++ b/src/version.py @@ -1,3 +1,3 @@ """stick the various versioning attributes in here, so we only have to change them once.""" -version = '0.83.4.1+gribble (2010-08-20T11:08:31-0400)' +version = '0.83.4.1+gribble (2010-09-02T08:54:13-0400)' From 7613e4056a1665bf0108e6a30ae4bd63ec286057 Mon Sep 17 00:00:00 2001 From: James Vega Date: Sun, 29 Aug 2010 10:26:59 -0400 Subject: [PATCH 141/243] Model Admin's ignore help after Channel's. Closes: Sf#3054919 Signed-off-by: James Vega (cherry picked from commit 25b987cc581ff33dc561325138f6e2937597ab95) Signed-off-by: Daniel Folkinshteyn --- plugins/Admin/plugin.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/plugins/Admin/plugin.py b/plugins/Admin/plugin.py index 781570695..149d08235 100644 --- a/plugins/Admin/plugin.py +++ b/plugins/Admin/plugin.py @@ -298,12 +298,10 @@ class Admin(callbacks.Plugin): def add(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. + This will set a 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. """ ircdb.ignores.add(hostmask, expires) irc.replySuccess() @@ -312,8 +310,8 @@ class Admin(callbacks.Plugin): def remove(self, irc, msg, args, hostmask): """ - Ignores or, if a nick is given, ignores whatever - hostmask that nick is currently using. + This will remove the persistent ignore on or the + hostmask currently associated with . """ try: ircdb.ignores.remove(hostmask) @@ -325,7 +323,7 @@ class Admin(callbacks.Plugin): def list(self, irc, msg, args): """takes no arguments - Returns the hostmasks currently being globally ignored. + Lists the hostmasks that the bot is ignoring. """ # XXX Add the expirations. if ircdb.ignores.hostmasks: From 2c812cfd9e59dd71fe1d497dd93aab0c85c0797e Mon Sep 17 00:00:00 2001 From: James Vega Date: Sun, 29 Aug 2010 10:49:13 -0400 Subject: [PATCH 142/243] User: Specify changename must be used in private in its help. Closes: Sf#3055353 Signed-off-by: James Vega (cherry picked from commit 07da8cab138ae53e168b7aa5097fd752d04a6476) Signed-off-by: Daniel Folkinshteyn --- plugins/User/plugin.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/User/plugin.py b/plugins/User/plugin.py index cf1342027..c102ea0e4 100644 --- a/plugins/User/plugin.py +++ b/plugins/User/plugin.py @@ -150,8 +150,8 @@ class User(callbacks.Plugin): 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). + This message must be sent to the bot privately (not on a channel) since + it may contain a password. """ try: id = ircdb.users.getUserId(newname) From 5e72daa5f49110853660bde6edb7ec7bbe186b9b Mon Sep 17 00:00:00 2001 From: James Vega Date: Sun, 29 Aug 2010 11:03:05 -0400 Subject: [PATCH 143/243] User: Require set.password to be sent in private. Closes: Sf#3055365 Signed-off-by: James Vega (cherry picked from commit f977a3a260f6c0853501196b15665425c2163649) Signed-off-by: Daniel Folkinshteyn --- plugins/User/plugin.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugins/User/plugin.py b/plugins/User/plugin.py index c102ea0e4..df5353e88 100644 --- a/plugins/User/plugin.py +++ b/plugins/User/plugin.py @@ -187,7 +187,8 @@ class User(callbacks.Plugin): irc.replySuccess() else: irc.error(conf.supybot.replies.incorrectAuthentication()) - password = wrap(password, ['otherUser', 'something', 'something']) + password = wrap(password, ['private', 'otherUser', 'something', + 'something']) def secure(self, irc, msg, args, user, password, value): """ [] From 1fbc28b37666158c5ab81490cd0d12c046084c24 Mon Sep 17 00:00:00 2001 From: James Vega Date: Sun, 29 Aug 2010 11:10:54 -0400 Subject: [PATCH 144/243] User: Only require name for set.password when changing other user's password. Closes: Sf#3055358 Signed-off-by: James Vega (cherry picked from commit de726f90f395c0ec0d705a7e582fe4f643898e86) Signed-off-by: Daniel Folkinshteyn --- plugins/User/plugin.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/plugins/User/plugin.py b/plugins/User/plugin.py index df5353e88..e08348239 100644 --- a/plugins/User/plugin.py +++ b/plugins/User/plugin.py @@ -168,7 +168,7 @@ class User(callbacks.Plugin): class set(callbacks.Commands): def password(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 @@ -180,6 +180,10 @@ class User(callbacks.Plugin): u = ircdb.users.getUser(msg.prefix) except KeyError: u = None + if user is None: + if u is None: + irc.errorNotRegistered(Raise=True) + user = u if user.checkPassword(password) or \ (u and u._checkCapability('owner') and not u == user): user.setPassword(newpassword) @@ -187,8 +191,8 @@ class User(callbacks.Plugin): irc.replySuccess() else: irc.error(conf.supybot.replies.incorrectAuthentication()) - password = wrap(password, ['private', 'otherUser', 'something', - 'something']) + password = wrap(password, ['private', optional('otherUser'), + 'something', 'something']) def secure(self, irc, msg, args, user, password, value): """ [] From 828b82ea9c515c4b67e55e5a917794c0b97eeb7c Mon Sep 17 00:00:00 2001 From: James Vega Date: Sun, 29 Aug 2010 11:24:54 -0400 Subject: [PATCH 145/243] User: Handle DuplicateHostmask exception in hostmask.add. Signed-off-by: James Vega (cherry picked from commit 577294f48942efeda3b5ee09038dc0d1fa51deab) Signed-off-by: Daniel Folkinshteyn --- plugins/User/plugin.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plugins/User/plugin.py b/plugins/User/plugin.py index e08348239..e9eddc3b6 100644 --- a/plugins/User/plugin.py +++ b/plugins/User/plugin.py @@ -327,6 +327,8 @@ class User(callbacks.Plugin): ircdb.users.setUser(user) except ValueError, e: irc.error(str(e), Raise=True) + except ircdb.DuplicateHostmask: + irc.error('That hostmask is already registered.', Raise=True) irc.replySuccess() add = wrap(add, ['private', first('otherUser', 'user'), optional('something'), additional('something', '')]) From d6423cad67c9b126b28eebea1ffa8ab96562dcd2 Mon Sep 17 00:00:00 2001 From: James Vega Date: Tue, 31 Aug 2010 18:46:05 -0400 Subject: [PATCH 146/243] Dict: Refer to the server config variable in Dict.dict's help. Closes: Sf#3056621 Signed-off-by: James Vega (cherry picked from commit bc98577fb14932b354f01215de8a9293f9aa4816) Signed-off-by: Daniel Folkinshteyn --- plugins/Dict/plugin.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugins/Dict/plugin.py b/plugins/Dict/plugin.py index 1c0085926..556c65add 100644 --- a/plugins/Dict/plugin.py +++ b/plugins/Dict/plugin.py @@ -79,7 +79,8 @@ class Dict(callbacks.Plugin): def dict(self, irc, msg, args, words): """[] - Looks up the definition of on dict.org's dictd server. + Looks up the definition of on the dictd server specified by + the supybot.plugins.Dict.server config variable. """ try: server = conf.supybot.plugins.Dict.server() From ee42f42fb4c00c136526b7ed14b9966b8f4d5e57 Mon Sep 17 00:00:00 2001 From: James Vega Date: Tue, 31 Aug 2010 18:52:09 -0400 Subject: [PATCH 147/243] Filter: Rename _(un)code to _(morse|unMorse)code Closes: Sf#3056753 Signed-off-by: James Vega (cherry picked from commit 452c019b10ab9fccf07ea8857cfcd9eca7013c5d) Signed-off-by: Daniel Folkinshteyn --- plugins/Filter/plugin.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/plugins/Filter/plugin.py b/plugins/Filter/plugin.py index 56aca7c6b..7cac0cbc7 100644 --- a/plugins/Filter/plugin.py +++ b/plugins/Filter/plugin.py @@ -278,7 +278,7 @@ class Filter(callbacks.Plugin): irc.reply(s) scramble = wrap(scramble, ['text']) - _code = { + _morseCode = { "A" : ".-", "B" : "-...", "C" : "-.-.", @@ -326,7 +326,7 @@ class Filter(callbacks.Plugin): "@" : ".--.-.", "=" : "-...-" } - _revcode = dict([(y, x) for (x, y) in _code.items()]) + _revMorseCode = dict([(y, x) for (x, y) in _morseCode.items()]) _unmorsere = re.compile('([.-]+)') def unmorse(self, irc, msg, args, text): """ @@ -336,7 +336,7 @@ class Filter(callbacks.Plugin): text = text.replace('_', '-') def morseToLetter(m): s = m.group(1) - return self._revcode.get(s, s) + return self._revMorseCode.get(s, s) text = self._unmorsere.sub(morseToLetter, text) text = text.replace(' ', '\x00') text = text.replace(' ', '') @@ -351,10 +351,7 @@ class Filter(callbacks.Plugin): """ L = [] for c in text.upper(): - if c in self._code: - L.append(self._code[c]) - else: - L.append(c) + L.append(self._morseCode.get(c, c)) irc.reply(' '.join(L)) morse = wrap(morse, ['text']) From bde37c2afebc244fe120ca59d820712c32119f7f Mon Sep 17 00:00:00 2001 From: James Vega Date: Tue, 31 Aug 2010 19:00:28 -0400 Subject: [PATCH 148/243] Channel: Correct specification of "#channel,op" capability. Closes: Sf#3055991 Signed-off-by: James Vega (cherry picked from commit 8cc63207681d5973abc108911803af2647d38b55) Signed-off-by: Daniel Folkinshteyn --- plugins/Channel/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/Channel/__init__.py b/plugins/Channel/__init__.py index 93d8e9c57..9bff7a104 100644 --- a/plugins/Channel/__init__.py +++ b/plugins/Channel/__init__.py @@ -29,14 +29,14 @@ """ Basic channel management commands. Many of these commands require their caller -to have the .op capability. This plugin is loaded by default. +to have the #channel,op capability. This plugin is loaded by default. """ 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. +# in here if you're keeping the plugin in CVS or some similar system. __version__ = "%%VERSION%%" __author__ = supybot.authors.jemfinch From 567e72488d4ee68917e27f265adf31e25f775672 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Thu, 2 Sep 2010 09:14:22 -0400 Subject: [PATCH 149/243] Cherry-pick a bunch of upstream fix commits. --- src/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/version.py b/src/version.py index 673e8b88c..edc1d657d 100644 --- a/src/version.py +++ b/src/version.py @@ -1,3 +1,3 @@ """stick the various versioning attributes in here, so we only have to change them once.""" -version = '0.83.4.1+gribble (2010-09-02T08:54:13-0400)' +version = '0.83.4.1+gribble (2010-09-02T09:14:22-0400)' From 0fe7912628d4640f66448ec3f532abe97c8efac8 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Thu, 2 Sep 2010 18:31:42 -0400 Subject: [PATCH 150/243] Misc: update version command to get latest version from gribble git. Note: if this is ever merged into upstream supybot, should change url to point at supybot gitweb, rather than gribble. --- plugins/Misc/plugin.py | 13 +++++++++---- src/version.py | 2 +- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/plugins/Misc/plugin.py b/plugins/Misc/plugin.py index a1412f32e..e3edbfce0 100644 --- a/plugins/Misc/plugin.py +++ b/plugins/Misc/plugin.py @@ -31,6 +31,7 @@ import os import sys import time +import re import supybot @@ -207,12 +208,16 @@ class Misc(callbacks.Plugin): Returns the version of the current bot. """ try: - newest = utils.web.getUrl('http://supybot.sf.net/version.txt') - newest ='The newest version available online is %s.'%newest.strip() + newest = utils.web.getUrl('http://gribble.git.sourceforge.net/git/'\ + 'gitweb.cgi?p=gribble/gribble;a=blob_plain;'\ + 'f=src/version.py;hb=HEAD') + m = re.search(r"^version = '([^']+)'$", newest, re.M) + newest = 'The newest version available in the gribble git '\ + 'repository is %s.' % (m.group(1),) except utils.web.Error, e: - self.log.info('Couldn\'t get website version: %s', e) + self.log.info('Couldn\'t get newest version: %s', e) newest = 'I couldn\'t fetch the newest version ' \ - 'from the Supybot website.' + 'from the gribble git repository website.' s = 'The current (running) version of this Supybot is %s. %s' % \ (conf.version, newest) irc.reply(s) diff --git a/src/version.py b/src/version.py index edc1d657d..efc4da14a 100644 --- a/src/version.py +++ b/src/version.py @@ -1,3 +1,3 @@ """stick the various versioning attributes in here, so we only have to change them once.""" -version = '0.83.4.1+gribble (2010-09-02T09:14:22-0400)' +version = '0.83.4.1+gribble (2010-09-02T18:31:42-0400)' From 3c00d8257910022d4f791e7edb6fb675cbbbe454 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Sat, 4 Sep 2010 01:25:57 -0400 Subject: [PATCH 151/243] Scheduler: don't immediately execute commands when restoring repeated events. --- plugins/Scheduler/plugin.py | 6 +++--- src/version.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/plugins/Scheduler/plugin.py b/plugins/Scheduler/plugin.py index 4a4611ad5..d6e4dc838 100644 --- a/plugins/Scheduler/plugin.py +++ b/plugins/Scheduler/plugin.py @@ -78,7 +78,7 @@ class Scheduler(callbacks.Plugin): event['time'], event['command'], n) elif event['type'] == 'repeat': # repeating event self._repeat(ircobj, event['msg'], name, - event['time'], event['command']) + event['time'], event['command'], False) except AssertionError, e: if str(e) == 'An event with the same name has already been scheduled.': # we must be reloading the plugin, event is still scheduled @@ -160,9 +160,9 @@ class Scheduler(callbacks.Plugin): irc.error('Invalid event id.') remove = wrap(remove, ['lowered']) - def _repeat(self, irc, msg, name, seconds, command): + def _repeat(self, irc, msg, name, seconds, command, now=True): f = self._makeCommandFunction(irc, msg, command, remove=False) - id = schedule.addPeriodicEvent(f, seconds, name) + id = schedule.addPeriodicEvent(f, seconds, name, now) assert id == name self.events[name] = {'command':command, 'msg':msg, diff --git a/src/version.py b/src/version.py index efc4da14a..d5892c844 100644 --- a/src/version.py +++ b/src/version.py @@ -1,3 +1,3 @@ """stick the various versioning attributes in here, so we only have to change them once.""" -version = '0.83.4.1+gribble (2010-09-02T18:31:42-0400)' +version = '0.83.4.1+gribble (2010-09-04T01:25:57-0400)' From 8d80fb9e8875aed3812479333d8748a5e2931821 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Tue, 7 Sep 2010 20:27:51 -0400 Subject: [PATCH 152/243] Badwords: add plugin docstring, and fix/standardize some method docstrings. --- plugins/BadWords/plugin.py | 9 ++++++--- src/version.py | 2 +- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/plugins/BadWords/plugin.py b/plugins/BadWords/plugin.py index e373567a4..d2121610e 100644 --- a/plugins/BadWords/plugin.py +++ b/plugins/BadWords/plugin.py @@ -41,6 +41,9 @@ import supybot.ircutils as ircutils import supybot.callbacks as callbacks class BadWords(callbacks.Privmsg): + """Maintains a list of words that the bot is not allowed to say. + Can also be used to kick people that say these words, if the bot + has op.""" def __init__(self, irc): self.__parent = super(BadWords, self) self.__parent.__init__(irc) @@ -66,7 +69,7 @@ class BadWords(callbacks.Privmsg): def inFilter(self, irc, msg): self.filtering = True # We need to check for bad words here rather than in doPrivmsg because - # messages don't get to doPrivmsg is the user is ignored. + # messages don't get to doPrivmsg if the user is ignored. if msg.command == 'PRIVMSG': self.updateRegexp() s = ircutils.stripFormatting(msg.args[1]) @@ -120,7 +123,7 @@ class BadWords(callbacks.Privmsg): def add(self, irc, msg, args, words): """ [ ...] - Adds all s to the list of words the bot isn't to say. + Adds all s to the list of words being censored. """ set = self.words() set.update(words) @@ -131,7 +134,7 @@ class BadWords(callbacks.Privmsg): def remove(self, irc, msg, args, words): """ [ ...] - Removes a s from the list of words the bot isn't to say. + Removes s from the list of words being censored. """ set = self.words() for word in words: diff --git a/src/version.py b/src/version.py index d5892c844..003a8c811 100644 --- a/src/version.py +++ b/src/version.py @@ -1,3 +1,3 @@ """stick the various versioning attributes in here, so we only have to change them once.""" -version = '0.83.4.1+gribble (2010-09-04T01:25:57-0400)' +version = '0.83.4.1+gribble (2010-09-07T20:27:51-0400)' From 246c73eed233b35d526e325cdb21ce25ec0d0224 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Wed, 8 Sep 2010 00:11:28 -0400 Subject: [PATCH 153/243] BadWords: improve help for requireWordBoundaries config. Make a note that the plugin requires restart or the words set updating, for changes to this setting to take effect. --- plugins/BadWords/config.py | 4 +++- src/version.py | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/plugins/BadWords/config.py b/plugins/BadWords/config.py index d88d0a5f4..d20f983af 100644 --- a/plugins/BadWords/config.py +++ b/plugins/BadWords/config.py @@ -54,7 +54,9 @@ conf.registerGlobalValue(BadWords,'requireWordBoundaries', words to be independent words, or whether it will censor them within other words. For instance, if 'darn' is a bad word, then if this is true, 'darn' will be censored, but 'darnit' will not. You probably want this to be - false.""")) + false. After changing this setting, the BadWords regexp needs to be + regenerated by adding/removing a word to the list, or reloading the + plugin.""")) class String256(registry.String): def __call__(self): diff --git a/src/version.py b/src/version.py index 003a8c811..2b6e388d3 100644 --- a/src/version.py +++ b/src/version.py @@ -1,3 +1,3 @@ """stick the various versioning attributes in here, so we only have to change them once.""" -version = '0.83.4.1+gribble (2010-09-07T20:27:51-0400)' +version = '0.83.4.1+gribble (2010-09-08T00:11:28-0400)' From 961786f3620261ea7e8a6ba731933cfbf984f2d7 Mon Sep 17 00:00:00 2001 From: James Vega Date: Wed, 8 Sep 2010 21:35:22 -0400 Subject: [PATCH 154/243] Games: Remove arbitrary upper limits on dice command. Closes: Sf#3057255 Signed-off-by: James Vega (cherry picked from commit 7cf61ad04643c3c9f795f06f0886ee25c2923472) Signed-off-by: Daniel Folkinshteyn --- plugins/Games/plugin.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/plugins/Games/plugin.py b/plugins/Games/plugin.py index 7bc25870d..427880193 100644 --- a/plugins/Games/plugin.py +++ b/plugins/Games/plugin.py @@ -1,5 +1,6 @@ ### # Copyright (c) 2003-2005, Jeremiah Fincher +# Copyright (c) 2010, James Vega # All rights reserved. # # Redistribution and use in source and binary forms, with or without @@ -57,11 +58,7 @@ class Games(callbacks.Plugin): ten-sided dice. """ (dice, sides) = utils.iter.imap(int, m.groups()) - if dice > 6: - irc.error('You can\'t roll more than 6 dice.') - elif sides > 100: - irc.error('Dice can\'t have more than 100 sides.') - elif sides < 3: + if sides < 3: irc.error('Dice can\'t have fewer than 3 sides.') else: L = [0] * dice From f68b6f709ea40dd7403e375128c7bae3a81d5a9e Mon Sep 17 00:00:00 2001 From: James Vega Date: Wed, 8 Sep 2010 22:20:23 -0400 Subject: [PATCH 155/243] Google: Remove Groups snarfer The regular expressions were woefully out of date and since there's not a stable API (or any for that matter), keeping things working is a losing battle. Closes: Sf#3057485 Signed-off-by: James Vega (cherry picked from commit c9274606ce0312b9726400fc431c810c93d2aa3c) Signed-off-by: Daniel Folkinshteyn --- plugins/Google/config.py | 14 ++-------- plugins/Google/plugin.py | 42 ++---------------------------- plugins/Google/test.py | 56 ---------------------------------------- 3 files changed, 4 insertions(+), 108 deletions(-) diff --git a/plugins/Google/config.py b/plugins/Google/config.py index 9b7c1b5b9..2323433a7 100644 --- a/plugins/Google/config.py +++ b/plugins/Google/config.py @@ -1,6 +1,6 @@ ### # Copyright (c) 2005, Jeremiah Fincher -# Copyright (c) 2008-2009, James Vega +# Copyright (c) 2008-2010, James Vega # All rights reserved. # # Redistribution and use in source and binary forms, with or without @@ -37,13 +37,7 @@ def configure(advanced): output("""The Google plugin has the functionality to watch for URLs that match a specific pattern. (We call this a snarfer) When supybot sees such a URL, it will parse the web page - for information and reply with the results. - - Google has two available snarfers: Google Groups link - snarfing and a google search snarfer.""") - if yn('Do you want the Google Groups link snarfer enabled by ' - 'default?'): - conf.supybot.plugins.Google.groupsSnarfer.setValue(True) + for information and reply with the results.""") if yn('Do you want the Google search snarfer enabled by default?'): conf.supybot.plugins.Google.searchSnarfer.setValue(True) @@ -105,10 +99,6 @@ conf.registerGlobalValue(Google, 'referer', the Referer field of the search requests. If this value is empty, a Referer will be generated in the following format: http://$server/$botName""")) -conf.registerChannelValue(Google, 'groupsSnarfer', - registry.Boolean(False, """Determines whether the groups snarfer is - enabled. If so, URLs at groups.google.com will be snarfed and their - group/title messaged to the channel.""")) conf.registerChannelValue(Google, 'searchSnarfer', registry.Boolean(False, """Determines whether the search snarfer is enabled. If so, messages (even unaddressed ones) beginning with the word diff --git a/plugins/Google/plugin.py b/plugins/Google/plugin.py index 56aea4cf9..1045803fb 100644 --- a/plugins/Google/plugin.py +++ b/plugins/Google/plugin.py @@ -1,6 +1,6 @@ ### # Copyright (c) 2002-2004, Jeremiah Fincher -# Copyright (c) 2008-2009, James Vega +# Copyright (c) 2008-2010, James Vega # All rights reserved. # # Redistribution and use in source and binary forms, with or without @@ -66,7 +66,7 @@ except ImportError: class Google(callbacks.PluginRegexp): threaded = True callBefore = ['Web'] - regexps = ['googleSnarfer', 'googleGroups'] + regexps = ['googleSnarfer'] _colorGoogles = {} def _getColorGoogle(self, m): @@ -313,44 +313,6 @@ class Google(callbacks.PluginRegexp): irc.reply(url.encode('utf-8'), prefixNick=False) googleSnarfer = urlSnarfer(googleSnarfer) - _ggThread = re.compile(r'Subject: ([^<]+)', re.I) - _ggGroup = re.compile(r'Google Groups :\s*([^<]+)', re.I) - _ggThreadm = re.compile(r'src="(/group[^"]+)">', re.I) - _ggSelm = re.compile(r'selm=[^&]+', re.I) - _threadmThread = re.compile(r'TITLE="([^"]+)">', re.I) - _threadmGroup = re.compile(r'class=groupname[^>]+>([^<]+)<', re.I) - def googleGroups(self, irc, msg, match): - r"http://groups.google.[\w.]+/\S+\?(\S+)" - if not self.registryValue('groupsSnarfer', msg.args[0]): - return - queries = cgi.parse_qsl(match.group(1)) - queries = [q for q in queries if q[0] in ('threadm', 'selm')] - if not queries: - return - queries.append(('hl', 'en')) - url = 'http://groups.google.com/groups?' + urllib.urlencode(queries) - text = utils.web.getUrl(url) - mThread = None - mGroup = None - if 'threadm=' in url: - path = self._ggThreadm.search(text) - if path is not None: - url = 'http://groups-beta.google.com' + path.group(1) - text = utils.web.getUrl(url) - mThread = self._threadmThread.search(text) - mGroup = self._threadmGroup.search(text) - else: - mThread = self._ggThread.search(text) - mGroup = self._ggGroup.search(text) - if mThread and mGroup: - irc.reply(format('Google Groups: %s, %s', - mGroup.group(1), mThread.group(1)), - prefixNick=False) - else: - self.log.debug('Unable to snarf. %s doesn\'t appear to be a ' - 'proper Google Groups page.', match.group(1)) - googleGroups = urlSnarfer(googleGroups) - def _googleUrl(self, s): s = s.replace('+', '%2B') s = s.replace(' ', '+') diff --git a/plugins/Google/test.py b/plugins/Google/test.py index 3d42ca085..c4c1233cb 100644 --- a/plugins/Google/test.py +++ b/plugins/Google/test.py @@ -66,60 +66,4 @@ class GoogleTestCase(ChannelPluginTestCase): def testCalcDoesNotHaveExtraSpaces(self): self.assertNotRegexp('google calc 1000^2', r'\s+,\s+') - def testGroupsSnarfer(self): - orig = conf.supybot.plugins.Google.groupsSnarfer() - try: - conf.supybot.plugins.Google.groupsSnarfer.setValue(True) - # This should work, and does work in practice, but is failing - # in the tests. - #self.assertSnarfRegexp( - # 'http://groups.google.com/groups?dq=&hl=en&lr=lang_en&' - # 'ie=UTF-8&oe=UTF-8&selm=698f09f8.0310132012.738e22fc' - # '%40posting.google.com', - # r'comp\.lang\.python.*question: usage of __slots__') - self.assertSnarfRegexp( - 'http://groups.google.com/groups?selm=ExDm.8bj.23' - '%40gated-at.bofh.it&oe=UTF-8&output=gplain', - r'linux\.kernel.*NFS client freezes') - self.assertSnarfRegexp( - 'http://groups.google.com/groups?q=kernel+hot-pants&' - 'hl=en&lr=&ie=UTF-8&oe=UTF-8&selm=1.5.4.32.199703131' - '70853.00674d60%40adan.kingston.net&rnum=1', - r'Madrid Bluegrass Ramble') - self.assertSnarfRegexp( - 'http://groups.google.com/groups?selm=1.5.4.32.19970' - '313170853.00674d60%40adan.kingston.net&oe=UTF-8&' - 'output=gplain', - r'Madrid Bluegrass Ramble') - self.assertSnarfRegexp( - 'http://groups.google.com/groups?dq=&hl=en&lr=&' - 'ie=UTF-8&threadm=mailman.1010.1069645289.702.' - 'python-list%40python.org&prev=/groups%3Fhl%3Den' - '%26lr%3D%26ie%3DUTF-8%26group%3Dcomp.lang.python', - r'comp\.lang\.python.*What exactly are bound') - # Test for Bug #1002547 - self.assertSnarfRegexp( - 'http://groups.google.com/groups?q=supybot+is+the&' - 'hl=en&lr=&ie=UTF-8&c2coff=1&selm=1028329672' - '%40freshmeat.net&rnum=9', - r'fm\.announce.*SupyBot') - finally: - conf.supybot.plugins.Google.groupsSnarfer.setValue(orig) - - def testConfig(self): - orig = conf.supybot.plugins.Google.groupsSnarfer() - try: - conf.supybot.plugins.Google.groupsSnarfer.setValue(False) - self.assertSnarfNoResponse( - 'http://groups.google.com/groups?dq=&hl=en&lr=lang_en&' - 'ie=UTF-8&oe=UTF-8&selm=698f09f8.0310132012.738e22fc' - '%40posting.google.com') - conf.supybot.plugins.Google.groupsSnarfer.setValue(True) - self.assertSnarfNotError( - 'http://groups.google.com/groups?dq=&hl=en&lr=lang_en&' - 'ie=UTF-8&oe=UTF-8&selm=698f09f8.0310132012.738e22fc' - '%40posting.google.com') - finally: - conf.supybot.plugins.Google.groupsSnarfer.setValue(orig) - # vim:set shiftwidth=4 softtabstop=4 expandtab textwidth=79: From 219832a0f91aad42625289d260660b8e8243259d Mon Sep 17 00:00:00 2001 From: James Vega Date: Wed, 8 Sep 2010 22:42:07 -0400 Subject: [PATCH 156/243] Limiter: Fix "reduce limit" test case. Closes: Sf#3058142 Signed-off-by: James Vega (cherry picked from commit c0e24cef3093f061dc3abde3b80ff69ef68f0520) Signed-off-by: Daniel Folkinshteyn --- plugins/Limiter/test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/Limiter/test.py b/plugins/Limiter/test.py index 9941dd433..f70eccdf3 100644 --- a/plugins/Limiter/test.py +++ b/plugins/Limiter/test.py @@ -47,7 +47,7 @@ class LimiterTestCase(ChannelPluginTestCase): conf.supybot.plugins.Limiter.maximumExcess.setValue(7) self.irc.feedMsg(ircmsgs.part('#foo', prefix='bar!root@host')) m = self.irc.takeMsg() - self.assertEqual(m, ircmsgs.limit('#foo', 1-5)) + self.assertEqual(m, ircmsgs.limit('#foo', 1+5)) finally: conf.supybot.plugins.Limiter.minimumExcess.setValue(origMin) conf.supybot.plugins.Limiter.maximumExcess.setValue(origMax) From 3177b3ac3619d171f5640aa0acc011448186dce5 Mon Sep 17 00:00:00 2001 From: James Vega Date: Wed, 8 Sep 2010 22:43:45 -0400 Subject: [PATCH 157/243] -> in Karma.karma's help Closes: Sf#3057517 Signed-off-by: James Vega (cherry picked from commit fc2a84fb900bf4c9f7eabbbe3a8c11054c1f56b7) Signed-off-by: Daniel Folkinshteyn --- plugins/Karma/plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/Karma/plugin.py b/plugins/Karma/plugin.py index f6a966cfa..e53b9607b 100644 --- a/plugins/Karma/plugin.py +++ b/plugins/Karma/plugin.py @@ -280,7 +280,7 @@ class Karma(callbacks.Plugin): def karma(self, irc, msg, args, channel, things): """[] [ ...] - Returns the karma of . If is not given, returns the top + Returns the karma of . If is not given, returns the top three and bottom three karmas. If one is given, returns the details of its karma; if more than one is given, returns the total karma of each of the the things. is only necessary if From 8baf08b882da2fc888435e2a06f6e9cb8a4a9464 Mon Sep 17 00:00:00 2001 From: James Vega Date: Wed, 8 Sep 2010 23:31:01 -0400 Subject: [PATCH 158/243] Handle changes to fnmatch.translate in Python 2.6 Define utils.python.glob2re based on the Python version being used. Use glob2re in Todo and Note plugins. Closes: Sf#3059292 Signed-off-by: James Vega (cherry picked from commit b0575cec885d2576b7ff5c7b302d4746b7c5a482) Signed-off-by: Daniel Folkinshteyn --- plugins/Note/plugin.py | 12 ++---------- plugins/Todo/plugin.py | 6 +++--- src/utils/python.py | 14 ++++++++++++-- 3 files changed, 17 insertions(+), 15 deletions(-) diff --git a/plugins/Note/plugin.py b/plugins/Note/plugin.py index 95a53bbea..3fd3cb326 100644 --- a/plugins/Note/plugin.py +++ b/plugins/Note/plugin.py @@ -30,7 +30,6 @@ import re import time -import fnmatch import operator import supybot.dbi as dbi @@ -98,15 +97,9 @@ class DbiNoteDB(dbi.DB): self.set(id, n) def getUnnotifiedIds(self, to): -## def p(note): -## return not note.notified and note.to == to -## return [note.id for note in self.select(p)] return self.unNotified.get(to, []) def getUnreadIds(self, to): -## def p(note): -## return not note.read and note.to == to -## return [note.id for note in self.select(p)] return self.unRead.get(to, []) def send(self, frm, to, public, text): @@ -303,9 +296,8 @@ class Note(callbacks.Plugin): elif option == 'sent': own = frm if glob: - glob = fnmatch.translate(glob) - # ignore the trailing $ fnmatch.translate adds to the regexp - criteria.append(re.compile(glob[:-1]).search) + glob = utils.python.glob2re(glob) + criteria.append(re.compile(glob).search) def match(note): for p in criteria: if not p(note.text): diff --git a/plugins/Todo/plugin.py b/plugins/Todo/plugin.py index efc415e5f..b995140ba 100644 --- a/plugins/Todo/plugin.py +++ b/plugins/Todo/plugin.py @@ -1,5 +1,6 @@ ### # Copyright (c) 2003-2005, Daniel DiPaolo +# Copyright (c) 2010, James Vega # All rights reserved. # # Redistribution and use in source and binary forms, with or without @@ -30,7 +31,6 @@ import os import re import time -import fnmatch import operator import supybot.dbi as dbi @@ -230,8 +230,8 @@ class Todo(callbacks.Plugin): if option == 'regexp': criteria.append(arg.search) for glob in globs: - glob = fnmatch.translate(glob) - criteria.append(re.compile(glob[:-1]).search) + glob = utils.python.glob2re(glob) + criteria.append(re.compile(glob).search) try: tasks = self.db.select(user.id, criteria) L = [format('#%i: %s', t.id, self._shrink(t.task)) for t in tasks] diff --git a/src/utils/python.py b/src/utils/python.py index d636d62bb..f38de2f78 100644 --- a/src/utils/python.py +++ b/src/utils/python.py @@ -1,6 +1,6 @@ ### # Copyright (c) 2005-2009, Jeremiah Fincher -# Copyright (c) 2009, James Vega +# Copyright (c) 2009-2010, James Vega # All rights reserved. # # Redistribution and use in source and binary forms, with or without @@ -30,6 +30,7 @@ import sys import types +import fnmatch import UserDict import threading @@ -65,7 +66,6 @@ def changeFunctionName(f, name, doc=None): class Object(object): def __ne__(self, other): return not self == other - class Synchronized(type): METHODS = '__synchronized__' @@ -103,5 +103,15 @@ class Synchronized(type): newclass = super(Synchronized, cls).__new__(cls, name, bases, dict) return newclass +# Translate glob to regular expression, trimming the "match EOL" portion of +# the regular expression. +if sys.version_info < (2, 6, 0): + # Pre-2.6 just uses the $ anchor + def glob2re(g): + return fnmatch.translate(g)[:-1] +else: + # Post-2.6 uses \Z(?ms) per http://issues.python.org/6665 + def glob2re(g): + return fnmatch.translate(g)[:-7] # vim:set shiftwidth=4 softtabstop=8 expandtab textwidth=78: From fa7c17e24eebcede1e497ca97f883033f6c26d1d Mon Sep 17 00:00:00 2001 From: James Vega Date: Wed, 8 Sep 2010 23:44:40 -0400 Subject: [PATCH 159/243] Karma: Refer to plugins.Karma.rankingDisplay in Karma.karma's help. Signed-off-by: James Vega (cherry picked from commit 45abdc82485633d709a0eee98ccef111f4101bef) Signed-off-by: Daniel Folkinshteyn --- plugins/Karma/plugin.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/plugins/Karma/plugin.py b/plugins/Karma/plugin.py index e53b9607b..773325a09 100644 --- a/plugins/Karma/plugin.py +++ b/plugins/Karma/plugin.py @@ -281,10 +281,11 @@ class Karma(callbacks.Plugin): """[] [ ...] Returns the karma of . If is not given, returns the top - three and bottom three karmas. If one is given, returns the - details of its karma; if more than one is given, returns the - total karma of each of the the things. is only necessary if - the message isn't sent on the channel itself. + N karmas, where N is determined by the config variable + supybot.plugins.Karma.rankingDisplay. If one is given, returns + the details of its karma; if more than one is given, returns + the total karma of each of the the things. is only necessary + if the message isn't sent on the channel itself. """ if len(things) == 1: name = things[0] From 14a5f52b2968d8a5ff2399a662af4ace566e0c06 Mon Sep 17 00:00:00 2001 From: James Vega Date: Thu, 9 Sep 2010 00:00:47 -0400 Subject: [PATCH 160/243] Games: Re-add some, much higher, upper limits to dice. Signed-off-by: James Vega (cherry picked from commit 154fbc30e9a8a4e4a38ef0847650d7fd24f1862b) Signed-off-by: Daniel Folkinshteyn --- plugins/Games/plugin.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/plugins/Games/plugin.py b/plugins/Games/plugin.py index 427880193..203bb9fd5 100644 --- a/plugins/Games/plugin.py +++ b/plugins/Games/plugin.py @@ -58,7 +58,11 @@ class Games(callbacks.Plugin): ten-sided dice. """ (dice, sides) = utils.iter.imap(int, m.groups()) - if sides < 3: + if dice > 1000: + irc.error('You can\'t roll more than 1000 dice.') + elif sides > 100: + irc.error('Dice can\'t have more than 100 sides.') + elif sides < 3: irc.error('Dice can\'t have fewer than 3 sides.') else: L = [0] * dice From 771331232e0745dbe187620b54c3f74443458bdd Mon Sep 17 00:00:00 2001 From: James Vega Date: Mon, 20 Sep 2010 18:20:52 -0400 Subject: [PATCH 161/243] Use the plugin name for Owner.defaultplugin's error message. Signed-off-by: James Vega (cherry picked from commit 074ded49e44903a0699dde194b493c25e7ab2de7) Signed-off-by: Daniel Folkinshteyn --- plugins/Owner/plugin.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugins/Owner/plugin.py b/plugins/Owner/plugin.py index 39de4b23e..c025529db 100644 --- a/plugins/Owner/plugin.py +++ b/plugins/Owner/plugin.py @@ -315,7 +315,8 @@ class Owner(callbacks.Plugin): irc.errorInvalid('command', command) elif plugin: if not plugin.isCommand(command): - irc.errorInvalid('command in the %s plugin' % plugin, command) + irc.errorInvalid('command in the %s plugin' % plugin.name(), + command) registerDefaultPlugin(command, plugin.name()) irc.replySuccess() else: From a9b515fbd22f83aaf2e6f3097cec8d85a1fd517b Mon Sep 17 00:00:00 2001 From: James Vega Date: Mon, 20 Sep 2010 18:34:07 -0400 Subject: [PATCH 162/243] Use self.error() instead of raise for BooleanRequiredFalseOnWindows. Closes: Sf#3070285 Signed-off-by: James Vega (cherry picked from commit 0fd6a84632ce03731d2f857f627d8990cd87e4f9) Signed-off-by: Daniel Folkinshteyn --- src/log.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/log.py b/src/log.py index f91f2fb03..00c3559d3 100644 --- a/src/log.py +++ b/src/log.py @@ -259,11 +259,11 @@ conf.registerGlobalValue(conf.supybot.log, 'timestampFormat', format.""")) class BooleanRequiredFalseOnWindows(registry.Boolean): + """Value cannot be true on Windows""" def set(self, s): registry.Boolean.set(self, s) if self.value and os.name == 'nt': - raise registry.InvalidRegistryValue, \ - 'Value cannot be true on Windows.' + self.error() conf.registerGlobalValue(conf.supybot.log, 'stdout', registry.Boolean(True, """Determines whether the bot will log to From 4d84798fb8262861b35cf06af99df722f03cba06 Mon Sep 17 00:00:00 2001 From: James Vega Date: Mon, 20 Sep 2010 19:01:38 -0400 Subject: [PATCH 163/243] README: It's a 3-clause, not 2-clause BSD license. Closes: Sf#3069906 Signed-off-by: James Vega (cherry picked from commit 89df85c3b3208357f316098f2482a7667b8881c4) Signed-off-by: Daniel Folkinshteyn --- README | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README b/README index ed1f6ab72..23607adc6 100644 --- a/README +++ b/README @@ -1,6 +1,6 @@ EVERYONE: --------- -Read LICENSE. It's a 2-clause BSD license, but you should read it +Read LICENSE. It's a 3-clause BSD license, but you should read it anyway. USERS: From e78a754c692203cdffa33e0eeb42f79fb2b05deb Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Sun, 10 Oct 2010 17:42:26 -0400 Subject: [PATCH 164/243] Unix: Use converter to enforce "no spaces" for spell's argument. Closes: Sf#3064304 Signed-off-by: James Vega (cherry picked from commit ca56575eb51995b6a1f768db4afa846f536e1edb) --- plugins/Unix/plugin.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/plugins/Unix/plugin.py b/plugins/Unix/plugin.py index 5428f3d54..874401853 100644 --- a/plugins/Unix/plugin.py +++ b/plugins/Unix/plugin.py @@ -1,6 +1,6 @@ ### # Copyright (c) 2002-2005, Jeremiah Fincher -# Copyright (c) 2008-2009, James Vega +# Copyright (c) 2008-2010, James Vega # All rights reserved. # # Redistribution and use in source and binary forms, with or without @@ -139,9 +139,6 @@ class Unix(callbacks.Plugin): if word and not word[0].isalpha(): irc.error(' must begin with an alphabet character.') return - if ' ' in word: - irc.error('Spaces aren\'t allowed in the word.') - return try: inst = subprocess.Popen([spellCmd, '-a'], close_fds=True, stdout=subprocess.PIPE, @@ -183,7 +180,7 @@ class Unix(callbacks.Plugin): else: resp = 'Something unexpected was seen in the [ai]spell output.' irc.reply(resp) - spell = thread(wrap(spell, ['something'])) + spell = thread(wrap(spell, ['somethingWithoutSpaces'])) def fortune(self, irc, msg, args): """takes no arguments From 9183c96a13dc78233257fde647aa75278252b220 Mon Sep 17 00:00:00 2001 From: James Vega Date: Sun, 3 Oct 2010 14:58:38 -0400 Subject: [PATCH 165/243] Services: Correct formatting of "isn't registered" log. Closes: Sf#3075937 Signed-off-by: James Vega (cherry picked from commit cb48912db6d3dec473d73f1cfa3754547034896f) Signed-off-by: Daniel Folkinshteyn --- plugins/Services/plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/Services/plugin.py b/plugins/Services/plugin.py index e5882573b..1d17a8085 100644 --- a/plugins/Services/plugin.py +++ b/plugins/Services/plugin.py @@ -244,7 +244,7 @@ class Services(callbacks.Plugin): # You have been unbanned from (oftc) irc.sendMsg(networkGroup.channels.join(channel)) elif 'isn\'t registered' in s: - self.log.warning('Received "%s isn\'t registered" from ChanServ %', + self.log.warning('Received "%s isn\'t registered" from ChanServ %s', channel, on) elif 'this channel has been registered' in s: self.log.debug('Got "Registered channel" from ChanServ %s.', on) From 8b702543faac20af92a3151af5acfa6463c7f458 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Sun, 10 Oct 2010 17:52:04 -0400 Subject: [PATCH 166/243] update the version timestamp for the batch of upstream bugfixes that just came through. --- src/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/version.py b/src/version.py index 2b6e388d3..93286227a 100644 --- a/src/version.py +++ b/src/version.py @@ -1,3 +1,3 @@ """stick the various versioning attributes in here, so we only have to change them once.""" -version = '0.83.4.1+gribble (2010-09-08T00:11:28-0400)' +version = '0.83.4.1+gribble (2010-10-10T17:52:04-0400)' From 42464d8180361b83ef090119a5e6f9ed32c8d142 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Wed, 1 Dec 2010 16:52:01 -0500 Subject: [PATCH 167/243] Google: add some extra matching capability to google calc now should be able to display any 'special' result from google. --- plugins/Google/plugin.py | 8 ++++++-- plugins/Google/test.py | 1 + src/version.py | 2 +- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/plugins/Google/plugin.py b/plugins/Google/plugin.py index 1045803fb..8a7cbe770 100644 --- a/plugins/Google/plugin.py +++ b/plugins/Google/plugin.py @@ -319,7 +319,8 @@ class Google(callbacks.PluginRegexp): url = r'http://google.com/search?q=' + s return url - _calcRe = re.compile(r'(.*?)', re.I) + _calcRe1 = re.compile(r']*>(.*?)', re.I) + _calcRe2 = re.compile(r'(.*?)', re.I) _calcSupRe = re.compile(r'(.*?)', re.I) _calcFontRe = re.compile(r'(.*?)') _calcTimesRe = re.compile(r'&(?:times|#215);') @@ -330,12 +331,15 @@ class Google(callbacks.PluginRegexp): """ url = self._googleUrl(expr) html = utils.web.getUrl(url) - match = self._calcRe.search(html) + match = self._calcRe1.search(html) + if match is None: + match = self._calcRe2.search(html) if match is not None: s = match.group(1) s = self._calcSupRe.sub(r'^(\1)', s) s = self._calcFontRe.sub(r',', s) s = self._calcTimesRe.sub(r'*', s) + s = utils.web.htmlToText(s) irc.reply(s) else: irc.reply('Google\'s calculator didn\'t come up with anything.') diff --git a/plugins/Google/test.py b/plugins/Google/test.py index c4c1233cb..e3c461aeb 100644 --- a/plugins/Google/test.py +++ b/plugins/Google/test.py @@ -39,6 +39,7 @@ class GoogleTestCase(ChannelPluginTestCase): def testCalc(self): self.assertNotRegexp('google calc e^(i*pi)+1', r'didn\'t') self.assertNotRegexp('google calc 1 usd in gbp', r'didn\'t') + self.assertRegexp('google calc current time in usa', r'Time in.*USA') def testHtmlHandled(self): self.assertNotRegexp('google calc ' diff --git a/src/version.py b/src/version.py index 93286227a..8a9a03351 100644 --- a/src/version.py +++ b/src/version.py @@ -1,3 +1,3 @@ """stick the various versioning attributes in here, so we only have to change them once.""" -version = '0.83.4.1+gribble (2010-10-10T17:52:04-0400)' +version = '0.83.4.1+gribble (2010-12-01T16:53:08-0500)' From fc1a3ab2588b5efb3967d942626f97131e8f3187 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Tue, 18 Jan 2011 13:51:34 -0500 Subject: [PATCH 168/243] Services: add some more strings indicating identification success. --- plugins/Services/plugin.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plugins/Services/plugin.py b/plugins/Services/plugin.py index 1d17a8085..651c68db8 100644 --- a/plugins/Services/plugin.py +++ b/plugins/Services/plugin.py @@ -301,6 +301,8 @@ class Services(callbacks.Plugin): pass elif ('now recognized' in s) or \ ('already identified' in s) or \ + ('already logged in' in s) or \ + ('successfully identified' in s) or \ ('password accepted' in s) or \ ('now identified' in s): # freenode, oftc, arstechnica, zirc, .... From a6d361573d84bdf1479f8e41a89a5bdf06155973 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Mon, 24 Jan 2011 16:09:18 -0500 Subject: [PATCH 169/243] Fix banmask creation. Thanks Progval for the patch! fixes https://sourceforge.net/tracker/?func=detail&aid=3088559&group_id=58965&atid=489447 incorporating patch https://sourceforge.net/tracker/?func=detail&aid=3163843&group_id=58965&atid=489449 --- src/ircutils.py | 2 +- test/test_ircutils.py | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/ircutils.py b/src/ircutils.py index c7b5408df..b2ec6bd2d 100644 --- a/src/ircutils.py +++ b/src/ircutils.py @@ -196,7 +196,7 @@ def banmask(hostmask): L[-1] = '*' return '*!*@' + ':'.join(L) else: - if '.' in host: + if len(host.split('.')) > 2: # If it is a subdomain return '*!*@*%s' % host[host.find('.'):] else: return '*!*@' + host diff --git a/test/test_ircutils.py b/test/test_ircutils.py index 6dc11e21d..b0fca5261 100644 --- a/test/test_ircutils.py +++ b/test/test_ircutils.py @@ -233,6 +233,10 @@ class FunctionsTestCase(SupyTestCase): msg.prefix), '%r didn\'t match %r' % (msg.prefix, banmask)) self.assertEqual(ircutils.banmask('foobar!user@host'), '*!*@host') + self.assertEqual(ircutils.banmask('foobar!user@host.tld'), + '*!*@host.tld') + self.assertEqual(ircutils.banmask('foobar!user@sub.host.tld'), + '*!*@*.host.tld') self.assertEqual(ircutils.banmask('foo!bar@2001::'), '*!*@2001::*') def testSeparateModes(self): From 3151d08e73479e828f6a97090a9b932193ae02cc Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Mon, 24 Jan 2011 16:18:01 -0500 Subject: [PATCH 170/243] Filter: fix rainbow so it doesn't fail with numbers. fixing this bug: https://sourceforge.net/tracker/?func=detail&aid=3140981&group_id=58965&atid=489447 Thanks to The Compiler for the report and the fix. --- plugins/Filter/plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/Filter/plugin.py b/plugins/Filter/plugin.py index 7cac0cbc7..6d279ef41 100644 --- a/plugins/Filter/plugin.py +++ b/plugins/Filter/plugin.py @@ -384,7 +384,7 @@ class Filter(callbacks.Plugin): Returns colorized like a rainbow. """ - colors = utils.iter.cycle([4, 7, 8, 3, 2, 12, 6]) + colors = utils.iter.cycle(['04', '07', '08', '03', '02', '12', '06']) L = [self._color(c, fg=colors.next()) for c in text] irc.reply(''.join(L) + '\x03') rainbow = wrap(rainbow, ['text']) From 831a2c3b9a5decad3842f9e76787e8dbf62e0311 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Tue, 25 Jan 2011 01:26:42 -0500 Subject: [PATCH 171/243] Topic: get shouldn't require capabilities, since it's a read-only operation. --- plugins/Topic/plugin.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/plugins/Topic/plugin.py b/plugins/Topic/plugin.py index 6b936bd18..a9e50ab93 100644 --- a/plugins/Topic/plugin.py +++ b/plugins/Topic/plugin.py @@ -390,9 +390,6 @@ class Topic(callbacks.Plugin): index into the topics. is only necessary if the message isn't sent in the channel itself. """ - if not self._checkManageCapabilities(irc, msg, channel): - capabilities = self.registryValue('requireManageCapability') - irc.errorNoCapability(capabilities, Raise=True) topics = self._splitTopic(irc.state.getTopic(channel), channel) irc.reply(topics[number]) get = wrap(get, ['inChannel', 'topicNumber']) From c25db0ecdfdf91b8fa8183fc6cf1dcebf7432a3a Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Sun, 13 Mar 2011 14:21:46 -0400 Subject: [PATCH 172/243] Services: fix problem with some channels being mixed up between networks on startup, when noJoinsUntilIdentified is true. When noJoinsUntilIdentified config is true, the bot holds join messages in a 'waitingJoins' list, and processes them once nickserv identification comes through. The problem was that when the bot is configured to join multiple networks, join messages from different networks would get appended to the same list, without any differentiation by which message belongs to which network. Thus, if there are messages waiting for multiple networks, it would often be the case that whichever network got identification done first, would 'pick up' other network's join messages. This fix stores the network name along with the join messages in the list, and has each network pick out only its own join messages. --- plugins/Services/plugin.py | 11 +++++++---- src/version.py | 2 +- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/plugins/Services/plugin.py b/plugins/Services/plugin.py index 651c68db8..105bca1b0 100644 --- a/plugins/Services/plugin.py +++ b/plugins/Services/plugin.py @@ -30,6 +30,7 @@ import re import time +import copy import config @@ -76,7 +77,7 @@ class Services(callbacks.Plugin): if self.registryValue('noJoinsUntilIdentified'): self.log.info('Holding JOIN to %s until identified.', msg.args[0]) - self.waitingJoins.append(msg) + self.waitingJoins.append((irc.network, msg,)) return None return msg @@ -314,9 +315,11 @@ class Services(callbacks.Plugin): for channel in self.channels: irc.queueMsg(networkGroup.channels.join(channel)) if self.waitingJoins: - for m in self.waitingJoins: - irc.sendMsg(m) - self.waitingJoins = [] + tmp_wj = copy.deepcopy(self.waitingJoins) # can't iterate over list if we're modifying it + for netname, m in tmp_wj: + if netname == irc.network: + irc.sendMsg(m) + self.waitingJoins.remove((netname, m,)) elif 'not yet authenticated' in s: # zirc.org has this, it requires an auth code. email = s.split()[-1] diff --git a/src/version.py b/src/version.py index 8a9a03351..404e312f3 100644 --- a/src/version.py +++ b/src/version.py @@ -1,3 +1,3 @@ """stick the various versioning attributes in here, so we only have to change them once.""" -version = '0.83.4.1+gribble (2010-12-01T16:53:08-0500)' +version = '0.83.4.1+gribble (2011-03-13T14:21:46-0400)' From da5b2b35e2a579bafb2d1dfe1c9ede5d6889a9d6 Mon Sep 17 00:00:00 2001 From: Valentin Lorentz Date: Sun, 3 Apr 2011 14:45:33 +0200 Subject: [PATCH 173/243] Seen: Fix save Seen.any.db. (thanks to beo_ for the repport and the test) Signed-off-by: Daniel Folkinshteyn --- plugins/Seen/plugin.py | 1 + 1 file changed, 1 insertion(+) diff --git a/plugins/Seen/plugin.py b/plugins/Seen/plugin.py index 4051e0994..a71616330 100644 --- a/plugins/Seen/plugin.py +++ b/plugins/Seen/plugin.py @@ -102,6 +102,7 @@ class Seen(callbacks.Plugin): self.lastmsg = {} self.ircstates = {} world.flushers.append(self.db.flush) + world.flushers.append(self.anydb.flush) def die(self): if self.db.flush in world.flushers: From 1b74b8ddf6a05f50af68951c874c60c5f1d62109 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Mon, 4 Apr 2011 16:30:52 -0400 Subject: [PATCH 174/243] Seen: fix tests so they pass. fix seen command so it properly accepts nick wildcards. --- plugins/Seen/plugin.py | 4 ++-- plugins/Seen/test.py | 2 +- src/version.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/plugins/Seen/plugin.py b/plugins/Seen/plugin.py index a71616330..6608202d0 100644 --- a/plugins/Seen/plugin.py +++ b/plugins/Seen/plugin.py @@ -219,10 +219,10 @@ class Seen(callbacks.Plugin): Returns the last time was seen and what was last seen saying. is only necessary if the message isn't sent on the - channel itself. + channel itself. may contain * as a wildcard. """ self._seen(irc, channel, name) - seen = wrap(seen, ['channel', 'nick']) + seen = wrap(seen, ['channel', 'something']) def any(self, irc, msg, args, channel, optlist, name): """[] [--user ] [] diff --git a/plugins/Seen/test.py b/plugins/Seen/test.py index 0ae819b6b..77eaeb37d 100644 --- a/plugins/Seen/test.py +++ b/plugins/Seen/test.py @@ -50,7 +50,7 @@ class ChannelDBTestCase(ChannelPluginTestCase): self.assertNotRegexp('seen asldfkjasdlfkj', 'KeyError') def testAny(self): - self.assertRegexp('seen any', 'test has joined') + self.assertRegexp('seen any', 'test has joined') self.irc.feedMsg(ircmsgs.mode(self.channel, args=('+o', self.nick), prefix=self.prefix)) self.assertRegexp('seen any %s' % self.nick, diff --git a/src/version.py b/src/version.py index 404e312f3..21541de05 100644 --- a/src/version.py +++ b/src/version.py @@ -1,3 +1,3 @@ """stick the various versioning attributes in here, so we only have to change them once.""" -version = '0.83.4.1+gribble (2011-03-13T14:21:46-0400)' +version = '0.83.4.1+gribble (2011-04-04T16:30:52-0400)' From 2b708f034b539b408b837ddae29c338c056df309 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Mon, 13 Jun 2011 16:42:57 -0400 Subject: [PATCH 175/243] Web: add 'timeout' config for web fetch, default 5 sec. Otherwise, when a site would take a long time to respond, the thread would hang for quite a while. also needed to mod src/utils/web.py to take the timeout arg. --- plugins/Web/config.py | 5 +++++ plugins/Web/plugin.py | 5 ++++- src/utils/web.py | 4 ++-- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/plugins/Web/config.py b/plugins/Web/config.py index 2ce3e3bfc..f22e0ad15 100644 --- a/plugins/Web/config.py +++ b/plugins/Web/config.py @@ -58,4 +58,9 @@ conf.registerGlobalValue(Web.fetch, 'maximum', registry.NonNegativeInteger(0, """Determines the maximum number of bytes the bot will download via the 'fetch' command in this plugin.""")) +conf.registerGlobalValue(Web.fetch, 'timeout', + registry.NonNegativeInteger(5, """Determines the maximum number of + seconds the bot will wait for the site to respond, when using the 'fetch' + command in this plugin. If 0, will use socket.defaulttimeout""")) + # vim:set shiftwidth=4 softtabstop=4 expandtab textwidth=79: diff --git a/plugins/Web/plugin.py b/plugins/Web/plugin.py index 6a332c792..bbfbed992 100644 --- a/plugins/Web/plugin.py +++ b/plugins/Web/plugin.py @@ -236,7 +236,10 @@ class Web(callbacks.PluginRegexp): irc.error('This command is disabled ' '(supybot.plugins.Web.fetch.maximum is set to 0).', Raise=True) - fd = utils.web.getUrlFd(url) + timeout = self.registryValue('fetch.timeout') + if timeout == 0: + timeout = None + fd = utils.web.getUrlFd(url, timeout=timeout) irc.reply(fd.read(max)) fetch = wrap(fetch, ['url']) diff --git a/src/utils/web.py b/src/utils/web.py index 6a1fa9361..424e9067a 100644 --- a/src/utils/web.py +++ b/src/utils/web.py @@ -96,7 +96,7 @@ defaultHeaders = { # application-specific function. Feel free to use a callable here. proxy = None -def getUrlFd(url, headers=None, data=None): +def getUrlFd(url, headers=None, data=None, timeout=None): """getUrlFd(url, headers=None, data=None) Opens the given url and returns a file object. Headers and data are @@ -114,7 +114,7 @@ def getUrlFd(url, headers=None, data=None): httpProxy = force(proxy) if httpProxy: request.set_proxy(httpProxy, 'http') - fd = urllib2.urlopen(request) + fd = urllib2.urlopen(request, timeout=timeout) return fd except socket.timeout, e: raise Error, TIMED_OUT From 1e337bdfa9085133ddd54c82cffa69dfcaa13c9a Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Mon, 27 Jun 2011 14:41:49 -0400 Subject: [PATCH 176/243] Dict: Fix FSF address in included dictclient module Taking fresh address from http://www.gnu.org/licenses/old-licenses/gpl-2.0.html#SEC4 --- plugins/Dict/local/dictclient.py | 2 +- src/version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/Dict/local/dictclient.py b/plugins/Dict/local/dictclient.py index e7a6094e2..8701dbf9a 100644 --- a/plugins/Dict/local/dictclient.py +++ b/plugins/Dict/local/dictclient.py @@ -14,7 +14,7 @@ # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software -# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. import socket, re diff --git a/src/version.py b/src/version.py index 21541de05..723eec3b2 100644 --- a/src/version.py +++ b/src/version.py @@ -1,3 +1,3 @@ """stick the various versioning attributes in here, so we only have to change them once.""" -version = '0.83.4.1+gribble (2011-04-04T16:30:52-0400)' +version = '0.83.4.1+gribble (2011-06-27T14:41:49-0400)' From 3a96f6735ba4ce15b48a9a015e6319514c9babac Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Mon, 8 Aug 2011 18:45:02 -0400 Subject: [PATCH 177/243] Services: catch occasional error when removing waiting joins from list --- plugins/Services/plugin.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/plugins/Services/plugin.py b/plugins/Services/plugin.py index 105bca1b0..28d719544 100644 --- a/plugins/Services/plugin.py +++ b/plugins/Services/plugin.py @@ -319,7 +319,10 @@ class Services(callbacks.Plugin): for netname, m in tmp_wj: if netname == irc.network: irc.sendMsg(m) - self.waitingJoins.remove((netname, m,)) + try: + self.waitingJoins.remove((netname, m,)) + except ValueError: + pass # weird stuff happen sometimes elif 'not yet authenticated' in s: # zirc.org has this, it requires an auth code. email = s.split()[-1] From 08e676e8fe1416459fa62c27131aabeca438f2b0 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Fri, 12 Aug 2011 16:21:36 -0400 Subject: [PATCH 178/243] Misc: fix test for misc.last --- plugins/Misc/test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/Misc/test.py b/plugins/Misc/test.py index bf7948dbd..2479cb96f 100644 --- a/plugins/Misc/test.py +++ b/plugins/Misc/test.py @@ -166,7 +166,7 @@ class MiscTestCase(ChannelPluginTestCase): (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) + self.assertResponse('last', '<%s> foo bar baz' % self.nick) finally: conf.supybot.plugins.Misc.timestampFormat.setValue(orig) From 2feaddba08f5ba1b6cfda5e257daaca44a2369a1 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Fri, 12 Aug 2011 16:28:50 -0400 Subject: [PATCH 179/243] src/commands.py: make subprocesses raise an error on timeout, rather than return a string --- src/commands.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/commands.py b/src/commands.py index 5182ea582..478dfcc56 100644 --- a/src/commands.py +++ b/src/commands.py @@ -69,6 +69,10 @@ def thread(f): f(self, irc, msg, args, *L, **kwargs) return utils.python.changeFunctionName(newf, f.func_name, f.__doc__) +class ProcessTimeoutError(Exception): + """Gets raised when a process is killed due to timeout.""" + pass + def process(f, *args, **kwargs): """Runs a function in a subprocess. @@ -93,7 +97,7 @@ def process(f, *args, **kwargs): p.join(timeout) if p.is_alive(): p.terminate() - return "%s aborted due to timeout." % (p.name,) + raise ProcessTimeoutError, "%s aborted due to timeout." % (p.name,) try: v = q.get(block=False) except Queue.Empty: From 3e0375812ab61b21aed6eeac6c405879dfba62d9 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Fri, 12 Aug 2011 16:30:09 -0400 Subject: [PATCH 180/243] String: fix it up to work with the previously committed enhancement for subprocess timeout. --- plugins/String/plugin.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/plugins/String/plugin.py b/plugins/String/plugin.py index 4f3398824..96da6855f 100644 --- a/plugins/String/plugin.py +++ b/plugins/String/plugin.py @@ -139,8 +139,11 @@ class String(callbacks.Plugin): irc.error(s) else: t = self.registryValue('re.timeout') - v = commands.process(f, text, timeout=t, pn=self.name(), cn='re') - irc.reply(v) + try: + v = commands.process(f, text, timeout=t, pn=self.name(), cn='re') + irc.reply(v) + except commands.ProcessTimeoutError, e: + irc.error("ProcessTimeoutError: %s" % (e,)) re = thread(wrap(re, [first('regexpMatcher', 'regexpReplacer'), 'text'])) From 57884bba57df70d254d61dee4764734261b448a4 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Fri, 12 Aug 2011 16:30:46 -0400 Subject: [PATCH 181/243] Misc: fix potential ddos when misc.last command is fed a specially-crafted regexp. --- plugins/Misc/plugin.py | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/plugins/Misc/plugin.py b/plugins/Misc/plugin.py index e3edbfce0..06295cf8d 100644 --- a/plugins/Misc/plugin.py +++ b/plugins/Misc/plugin.py @@ -43,6 +43,7 @@ import supybot.irclib as irclib import supybot.ircmsgs as ircmsgs import supybot.ircutils as ircutils import supybot.callbacks as callbacks +from supybot import commands from supybot.utils.iter import ifilter @@ -314,10 +315,27 @@ class Misc(callbacks.Plugin): predicates.setdefault('without', []).append(f) elif option == 'regexp': def f(m, arg=arg): + def f1(s, arg): + """Since we can't enqueue match objects into the multiprocessing queue, + we'll just wrap the function to return bools.""" + if arg.search(s) is not None: + return True + else: + return False if ircmsgs.isAction(m): - return arg.search(ircmsgs.unAction(m)) + m1 = ircmsgs.unAction(m) + #return arg.search(ircmsgs.unAction(m)) else: - return arg.search(m.args[1]) + m1 = m.args[1] + #return arg.search(m.args[1]) + try: + # use a subprocess here, since specially crafted regexps can + # take exponential time and hang up the bot. + # timeout of 0.1 should be more than enough for any normal regexp. + v = commands.process(f1, m1, arg, timeout=0.1, pn=self.name(), cn='last') + return v + except commands.ProcessTimeoutError: + return False predicates.setdefault('regexp', []).append(f) elif option == 'nolimit': nolimit = True From 47fdfe2e9a60980330feee1a1385b1a2327f27cc Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Fri, 12 Aug 2011 16:38:36 -0400 Subject: [PATCH 182/243] String: set default re subprocess timeout to 0.1, since that should be quite enough. --- plugins/String/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/String/config.py b/plugins/String/config.py index 75d2cf446..9ec8b11fd 100644 --- a/plugins/String/config.py +++ b/plugins/String/config.py @@ -52,7 +52,7 @@ conf.registerGlobalValue(String.levenshtein, 'max', command.""")) conf.registerGroup(String, 're') conf.registerGlobalValue(String.re, 'timeout', - registry.PositiveFloat(5, """Determines the maximum time, in seconds, that + registry.PositiveFloat(0.1, """Determines the maximum time, in seconds, that a regular expression is given to execute before being terminated. Since there is a possibility that user input for the re command can cause it to eat up large amounts of ram or cpu time, it's a good idea to keep this From e23bd93ded8356a7adf7fe8d185cb26424a81b69 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Fri, 12 Aug 2011 18:10:41 -0400 Subject: [PATCH 183/243] Secure some more commands which take a regexp from untrusted user input. Namely todo.search, note.search, dunno.search. --- plugins/Note/plugin.py | 4 +++- plugins/Todo/plugin.py | 3 +++ plugins/__init__.py | 5 ++++- src/commands.py | 18 ++++++++++++++++++ src/version.py | 2 +- 5 files changed, 29 insertions(+), 3 deletions(-) diff --git a/plugins/Note/plugin.py b/plugins/Note/plugin.py index 3fd3cb326..d41487cb3 100644 --- a/plugins/Note/plugin.py +++ b/plugins/Note/plugin.py @@ -42,6 +42,7 @@ import supybot.ircmsgs as ircmsgs import supybot.plugins as plugins import supybot.ircutils as ircutils import supybot.callbacks as callbacks +from supybot import commands class NoteRecord(dbi.Record): __fields__ = [ @@ -292,7 +293,8 @@ class Note(callbacks.Plugin): own = to for (option, arg) in optlist: if option == 'regexp': - criteria.append(arg.search) + criteria.append(lambda x: commands.regexp_wrapper(x, reobj=arg, + timeout=0.1, plugin_name = self.name(), fcn_name='search')) elif option == 'sent': own = frm if glob: diff --git a/plugins/Todo/plugin.py b/plugins/Todo/plugin.py index b995140ba..d28454676 100644 --- a/plugins/Todo/plugin.py +++ b/plugins/Todo/plugin.py @@ -41,6 +41,7 @@ from supybot.commands import * import supybot.plugins as plugins import supybot.ircutils as ircutils import supybot.callbacks as callbacks +from supybot import commands class TodoRecord(dbi.Record): __fields__ = [ @@ -228,6 +229,8 @@ class Todo(callbacks.Plugin): criteria = [] for (option, arg) in optlist: if option == 'regexp': + criteria.append(lambda x: commands.regexp_wrapper(x, reobj=arg, + timeout=0.1, plugin_name = self.name(), fcn_name='search')) criteria.append(arg.search) for glob in globs: glob = utils.python.glob2re(glob) diff --git a/plugins/__init__.py b/plugins/__init__.py index 2e9b2c14f..5c86f5162 100644 --- a/plugins/__init__.py +++ b/plugins/__init__.py @@ -50,6 +50,7 @@ import supybot.world as world from supybot.commands import * import supybot.ircutils as ircutils import supybot.callbacks as callbacks +from supybot import commands ## i think we don't need any of this with sqlite3 #try: @@ -431,7 +432,9 @@ class ChannelIdDatabasePlugin(callbacks.Plugin): if opt == 'by': predicates.append(lambda r, arg=arg: r.by == arg.id) elif opt == 'regexp': - predicates.append(lambda r, arg=arg: arg.search(r.text)) + predicates.append(lambda x: commands.regexp_wrapper(x.text, reobj=arg, + timeout=0.1, plugin_name = self.name(), fcn_name='search')) + #predicates.append(lambda r, arg=arg: arg.search(r.text)) if glob: def globP(r, glob=glob.lower()): return fnmatch.fnmatch(r.text.lower(), glob) diff --git a/src/commands.py b/src/commands.py index 478dfcc56..9d3acd2d7 100644 --- a/src/commands.py +++ b/src/commands.py @@ -106,6 +106,24 @@ def process(f, *args, **kwargs): v = "Error: " + str(v) return v +def regexp_wrapper(s, reobj, timeout, plugin_name, fcn_name): + '''A convenient wrapper to stuff regexp search queries through a subprocess. + + This is used because specially-crafted regexps can use exponential time + and hang the bot.''' + def re_bool(s, reobj): + """Since we can't enqueue match objects into the multiprocessing queue, + we'll just wrap the function to return bools.""" + if reobj.search(s) is not None: + return True + else: + return False + try: + v = process(re_bool, s, reobj, timeout=timeout, pn=plugin_name, cn=fcn_name) + return v + except ProcessTimeoutError: + return False + class UrlSnarfThread(world.SupyThread): def __init__(self, *args, **kwargs): assert 'url' in kwargs diff --git a/src/version.py b/src/version.py index 723eec3b2..846f03342 100644 --- a/src/version.py +++ b/src/version.py @@ -1,3 +1,3 @@ """stick the various versioning attributes in here, so we only have to change them once.""" -version = '0.83.4.1+gribble (2011-06-27T14:41:49-0400)' +version = '0.83.4.1+gribble (2011-08-12T18:12:56-0400)' From af32d6bfd371a989afda14e82cfda214f61708c3 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Sun, 14 Aug 2011 01:42:08 -0400 Subject: [PATCH 184/243] RSS: add channel-specific blacklist and whitelist. also fix bug introduced with the initialannounce feature, which overwrote newheadlines list when doing channel-specific things with it. --- plugins/RSS/config.py | 8 ++++++++ plugins/RSS/plugin.py | 24 ++++++++++++++++++++++-- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/plugins/RSS/config.py b/plugins/RSS/config.py index 1027a462d..772aba1a2 100644 --- a/plugins/RSS/config.py +++ b/plugins/RSS/config.py @@ -76,6 +76,14 @@ conf.registerGlobalValue(RSS, 'defaultNumberOfHeadlines', conf.registerChannelValue(RSS, 'initialAnnounceHeadlines', registry.PositiveInteger(5, """Indicates how many headlines an rss feed will output when it is first added to announce for a channel.""")) +conf.registerChannelValue(RSS, 'keywordWhitelist', + registry.SpaceSeparatedSetOfStrings([], """Space separated list of + strings, lets you filter headlines to those containing one or more items + in this whitelist.""")) +conf.registerChannelValue(RSS, 'keywordBlacklist', + registry.SpaceSeparatedSetOfStrings([], """Space separated list of + strings, lets you filter headlines to those not containing any items + in this blacklist.""")) conf.registerGroup(RSS, 'announce') diff --git a/plugins/RSS/plugin.py b/plugins/RSS/plugin.py index 2f21f51f7..6cb73d438 100644 --- a/plugins/RSS/plugin.py +++ b/plugins/RSS/plugin.py @@ -182,9 +182,29 @@ class RSS(callbacks.Plugin): newheadlines[i] = None newheadlines = filter(None, newheadlines) # Removes Nones. if newheadlines: + def filter_whitelist(headline): + v = False + for kw in whitelist: + if kw in headline[0] or kw in headline[1]: + v = True + break + return v + def filter_blacklist(headline): + v = True + for kw in blacklist: + if kw in headline[0] or kw in headline[1]: + v = False + break + return v for channel in channels: if len(oldheadlines) == 0: - newheadlines = newheadlines[:self.registryValue('initialAnnounceHeadlines', channel)] + channelnewheadlines = newheadlines[:self.registryValue('initialAnnounceHeadlines', channel)] + whitelist = self.registryValue('keywordWhitelist', channel) + blacklist = self.registryValue('keywordBlacklist', channel) + if len(whitelist) != 0: + channelnewheadlines = filter(filter_whitelist, channelnewheadlines) + if len(blacklist) != 0: + channelnewheadlines = filter(filter_blacklist, channelnewheadlines) bold = self.registryValue('bold', channel) sep = self.registryValue('headlineSeparator', channel) prefix = self.registryValue('announcementPrefix', channel) @@ -192,7 +212,7 @@ class RSS(callbacks.Plugin): if bold: pre = ircutils.bold(pre) sep = ircutils.bold(sep) - headlines = self.buildHeadlines(newheadlines, channel) + headlines = self.buildHeadlines(channelnewheadlines, channel) irc.replies(headlines, prefixer=pre, joiner=sep, to=channel, prefixNick=False, private=True) finally: From a3452628463e4851448ca93c8815d82582431456 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Sun, 14 Aug 2011 14:58:11 -0400 Subject: [PATCH 185/243] Factoids: fix bug when making an alias to a newkey that already has a factoid associated with it. --- plugins/Factoids/plugin.py | 4 ++-- plugins/Factoids/test.py | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/plugins/Factoids/plugin.py b/plugins/Factoids/plugin.py index bb55ce6ca..cb2ff60a6 100644 --- a/plugins/Factoids/plugin.py +++ b/plugins/Factoids/plugin.py @@ -345,11 +345,11 @@ class Factoids(callbacks.Plugin, plugins.ChannelDBHandler): # check if we already have the requested relation cursor.execute("""SELECT id FROM relations WHERE key_id=? and fact_id=?""", - (arelation[1], arelation[2])) + (newkey_info[0][0], arelation[2])) existentrelation = cursor.fetchall() if len(existentrelation) != 0: newkey_info = False - if len(newkey_info) == 0: + elif len(newkey_info) == 0: cursor.execute("""INSERT INTO keys VALUES (NULL, ?)""", (newkey,)) db.commit() diff --git a/plugins/Factoids/test.py b/plugins/Factoids/test.py index 035e4efed..da9590c9a 100644 --- a/plugins/Factoids/test.py +++ b/plugins/Factoids/test.py @@ -191,6 +191,10 @@ class FactoidsTestCase(ChannelPluginTestCase): self.assertError('alias foo gnoop') self.assertNotError('alias foo gnoop 2') self.assertRegexp('whatis gnoop', 'snorp') + self.assertNotError('learn floop as meep') + self.assertNotError('learn bar as baz') + self.assertNotError('alias floop bar') + self.assertRegexp('whatis bar', 'meep.*baz') def testRank(self): self.assertNotError('learn foo as bar') From c270111c6b4ac2a5efa7dc96673ab3b454d71c0a Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Mon, 15 Aug 2011 11:13:54 -0400 Subject: [PATCH 186/243] RSS: fix bug failing to define a variable under some conditions before referencing it. --- plugins/RSS/plugin.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plugins/RSS/plugin.py b/plugins/RSS/plugin.py index 6cb73d438..97a0aa1ed 100644 --- a/plugins/RSS/plugin.py +++ b/plugins/RSS/plugin.py @@ -199,6 +199,8 @@ class RSS(callbacks.Plugin): for channel in channels: if len(oldheadlines) == 0: channelnewheadlines = newheadlines[:self.registryValue('initialAnnounceHeadlines', channel)] + else: + channelnewheadlines = newheadlines[:] whitelist = self.registryValue('keywordWhitelist', channel) blacklist = self.registryValue('keywordBlacklist', channel) if len(whitelist) != 0: From 7c14992fe87edfa6763bd8230f3892e637597691 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Mon, 15 Aug 2011 13:45:07 -0400 Subject: [PATCH 187/243] RSS: don't output anything if there are no headlines remaining after filtering. --- plugins/RSS/plugin.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plugins/RSS/plugin.py b/plugins/RSS/plugin.py index 97a0aa1ed..7481cb31e 100644 --- a/plugins/RSS/plugin.py +++ b/plugins/RSS/plugin.py @@ -207,6 +207,8 @@ class RSS(callbacks.Plugin): channelnewheadlines = filter(filter_whitelist, channelnewheadlines) if len(blacklist) != 0: channelnewheadlines = filter(filter_blacklist, channelnewheadlines) + if len(channelnewheadlines) == 0: + return bold = self.registryValue('bold', channel) sep = self.registryValue('headlineSeparator', channel) prefix = self.registryValue('announcementPrefix', channel) From 36eb3501cf2cb94ffc50b6c8cbb0da315bd369b6 Mon Sep 17 00:00:00 2001 From: James Vega Date: Mon, 6 Jun 2011 21:44:15 -0400 Subject: [PATCH 188/243] Add utils.net.isIPV4, with utils.net.isIP checking v4 or v6 Signed-off-by: James Vega --- src/conf.py | 4 ++-- src/ircutils.py | 10 +++++----- src/utils/net.py | 20 ++++++++++++++++---- test/test_utils.py | 6 ++---- 4 files changed, 25 insertions(+), 15 deletions(-) diff --git a/src/conf.py b/src/conf.py index 3768682f4..7517b32ae 100644 --- a/src/conf.py +++ b/src/conf.py @@ -1,6 +1,6 @@ ### # Copyright (c) 2002-2005, Jeremiah Fincher -# Copyright (c) 2008-2009, James Vega +# Copyright (c) 2008-2009,2011, James Vega # All rights reserved. # # Redistribution and use in source and binary forms, with or without @@ -1038,7 +1038,7 @@ registerGlobalValue(supybot, 'defaultIgnore', class IP(registry.String): """Value must be a valid IP.""" def setValue(self, v): - if v and not (utils.net.isIP(v) or utils.net.isIPV6(v)): + if v and not utils.net.isIP(v): self.error() else: registry.String.setValue(self, v) diff --git a/src/ircutils.py b/src/ircutils.py index b2ec6bd2d..3824cdc15 100644 --- a/src/ircutils.py +++ b/src/ircutils.py @@ -1,6 +1,6 @@ ### # Copyright (c) 2002-2005, Jeremiah Fincher -# Copyright (c) 2009, James Vega +# Copyright (c) 2011, James Vega # All rights reserved. # # Redistribution and use in source and binary forms, with or without @@ -187,7 +187,7 @@ def banmask(hostmask): """ assert isUserHostmask(hostmask) host = hostFromHostmask(hostmask) - if utils.net.isIP(host): + if utils.net.isIPV4(host): L = host.split('.') L[-1] = '*' return '*!*@' + '.'.join(L) @@ -458,9 +458,9 @@ def replyTo(msg): return msg.nick def dccIP(ip): - """Converts an IP string to the DCC integer form.""" - assert utils.net.isIP(ip), \ - 'argument must be a string ip in xxx.xxx.xxx.xxx format.' + """Returns an IP in the proper form for DCC.""" + assert utils.net.isIPV4(ip), \ + 'argument must be a string ip in xxx.yyy.zzz.www format.' i = 0 x = 256**3 for quad in ip.split('.'): diff --git a/src/utils/net.py b/src/utils/net.py index fa78fdcc3..ffe8c2005 100644 --- a/src/utils/net.py +++ b/src/utils/net.py @@ -1,5 +1,6 @@ ### # Copyright (c) 2002-2005, Jeremiah Fincher +# Copyright (c) 2011, James Vega # All rights reserved. # # Redistribution and use in source and binary forms, with or without @@ -44,7 +45,7 @@ def getSocket(host): """ addrinfo = socket.getaddrinfo(host, None) host = addrinfo[0][4][0] - if isIP(host): + if isIPV4(host): return socket.socket(socket.AF_INET, socket.SOCK_STREAM) elif isIPV6(host): return socket.socket(socket.AF_INET6, socket.SOCK_STREAM) @@ -52,16 +53,27 @@ def getSocket(host): raise socket.error, 'Something wonky happened.' def isIP(s): - """Returns whether or not a given string is an IPV4 address. + """Returns whether or not a given string is an IP address. >>> isIP('255.255.255.255') 1 - >>> isIP('abc.abc.abc.abc') + >>> isIP('::1') + 0 + """ + return isIPV4(s) or isIPV6(s) + +def isIPV4(s): + """Returns whether or not a given string is an IPV4 address. + + >>> isIPV4('255.255.255.255') + 1 + + >>> isIPV4('abc.abc.abc.abc') 0 """ try: - return bool(socket.inet_aton(s)) + return bool(socket.inet_pton(socket.AF_INET, s)) except socket.error: return False diff --git a/test/test_utils.py b/test/test_utils.py index c4efb4979..da94678e6 100644 --- a/test/test_utils.py +++ b/test/test_utils.py @@ -1,6 +1,6 @@ ### # Copyright (c) 2002-2005, Jeremiah Fincher -# Copyright (c) 2009, James Vega +# Copyright (c) 2009,2011, James Vega # All rights reserved. # # Redistribution and use in source and binary forms, with or without @@ -501,11 +501,9 @@ class NetTest(SupyTestCase): isIP = utils.net.isIP self.failIf(isIP('a.b.c')) self.failIf(isIP('256.0.0.0')) - self.failUnless(isIP('127.1')) self.failUnless(isIP('0.0.0.0')) self.failUnless(isIP('100.100.100.100')) - # This test is too flaky to bother with. - # self.failUnless(utils.isIP('255.255.255.255')) + self.failUnless(isIP('255.255.255.255')) def testIsIPV6(self): f = utils.net.isIPV6 From 5c9139990b524f6e3e1bfe39d81fedc8c1f31aa2 Mon Sep 17 00:00:00 2001 From: James Vega Date: Mon, 6 Jun 2011 22:28:35 -0400 Subject: [PATCH 189/243] Update Internet.dns to handle IPv6 IPs and responses Signed-off-by: James Vega (cherry picked from commit d56381436c3dc9e574384a3c935ecf18fbb024f7) Signed-off-by: Daniel Folkinshteyn --- plugins/Internet/plugin.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/Internet/plugin.py b/plugins/Internet/plugin.py index 490c9165c..eb5354394 100644 --- a/plugins/Internet/plugin.py +++ b/plugins/Internet/plugin.py @@ -1,6 +1,6 @@ ### # Copyright (c) 2003-2005, Jeremiah Fincher -# Copyright (c) 2010, James Vega +# Copyright (c) 2010-2011, James Vega # All rights reserved. # # Redistribution and use in source and binary forms, with or without @@ -53,7 +53,7 @@ class Internet(callbacks.Plugin): irc.reply(hostname) else: try: - ip = socket.gethostbyname(host) + ip = socket.getaddrinfo(host, None)[0][4][0] if ip == '64.94.110.11': # Verisign sucks! irc.reply('Host not found.') else: From 59936f52f319073324cda22707464728bcd9bbf3 Mon Sep 17 00:00:00 2001 From: James Vega Date: Mon, 6 Jun 2011 22:29:21 -0400 Subject: [PATCH 190/243] Update Internet.hexip to handle IPv6 Signed-off-by: James Vega (cherry picked from commit b0e595fbd2e9b244738f4f8b1b5b88831583ad03) Signed-off-by: Daniel Folkinshteyn --- plugins/Internet/plugin.py | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/plugins/Internet/plugin.py b/plugins/Internet/plugin.py index eb5354394..de94afcd9 100644 --- a/plugins/Internet/plugin.py +++ b/plugins/Internet/plugin.py @@ -149,12 +149,22 @@ class Internet(callbacks.Plugin): Returns the hexadecimal IP for that IP. """ - quads = ip.split('.') ret = "" - for quad in quads: - i = int(quad) - ret += '%02x' % i - irc.reply(ret.upper()) + if utils.net.isIPV4(ip): + quads = ip.split('.') + for quad in quads: + i = int(quad) + ret += '%02X' % i + else: + octets = ip.split(':') + for octet in octets: + if octet: + i = int(octet, 16) + ret += '%04X' % i + else: + missing = (8 - len(octets)) * 4 + ret += '0' * missing + irc.reply(ret) hexip = wrap(hexip, ['ip']) From a8736d9a644a8a3f7cd1ad360778ce71d1bec041 Mon Sep 17 00:00:00 2001 From: James Vega Date: Tue, 2 Aug 2011 22:19:47 -0400 Subject: [PATCH 191/243] Seen: Anchor nick regexp to ensure valid match. When searching for 'st*ke', 'stryker' would incorrectly match, 'stryke' would be added to the nick set and the subsequent lookup would cause a KeyError. This is fixed both by anchoring the regexp ('^st.*ke$' instead of 'st.*ke') and adding searchNick to the nick set instead of the string that matched the pattern. Closes: Sf#3377381 Signed-off-by: James Vega (cherry picked from commit 0cd4939678e5839c85b57460c7c4b000c8fc1751) Signed-off-by: Daniel Folkinshteyn --- plugins/Seen/plugin.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/plugins/Seen/plugin.py b/plugins/Seen/plugin.py index 6608202d0..2f42bd2b3 100644 --- a/plugins/Seen/plugin.py +++ b/plugins/Seen/plugin.py @@ -1,6 +1,6 @@ ### # Copyright (c) 2002-2004, Jeremiah Fincher -# Copyright (c) 2010, James Vega +# Copyright (c) 2010-2011, James Vega # All rights reserved. # # Redistribution and use in source and binary forms, with or without @@ -66,7 +66,7 @@ class SeenDB(plugins.ChannelUserDB): def seenWildcard(self, channel, nick): nicks = ircutils.IrcSet() - nickRe = re.compile('.*'.join(nick.split('*')), re.I) + nickRe = re.compile('^%s$' % '.*'.join(nick.split('*')), re.I) for (searchChan, searchNick) in self.keys(): #print 'chan: %s ... nick: %s' % (searchChan, searchNick) if isinstance(searchNick, int): @@ -75,11 +75,8 @@ class SeenDB(plugins.ChannelUserDB): # are keyed by nick-string continue if ircutils.strEqual(searchChan, channel): - try: - s = nickRe.match(searchNick).group() - except AttributeError: - continue - nicks.add(s) + if nickRe.search(searchNick) is not None: + nicks.add(searchNick) L = [[nick, self.seen(channel, nick)] for nick in nicks] def negativeTime(x): return -x[1][0] From 60834f239ff14d2be7a1bc64f6bf50394a2e457c Mon Sep 17 00:00:00 2001 From: James Vega Date: Mon, 22 Aug 2011 14:07:39 -0400 Subject: [PATCH 192/243] Honor supybot-test's timeout option and document the units Signed-off-by: James Vega (cherry picked from commit 4661acb3a3e1f6bd5773ead9aa290995cfc2bfa4) Signed-off-by: Daniel Folkinshteyn --- scripts/supybot-test | 6 ++++-- src/test.py | 5 ++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/scripts/supybot-test b/scripts/supybot-test index 2bec1826e..c0a500713 100644 --- a/scripts/supybot-test +++ b/scripts/supybot-test @@ -2,6 +2,7 @@ ### # Copyright (c) 2002-2005, Jeremiah Fincher +# Copyright (c) 2011, James Vega # All rights reserved. # # Redistribution and use in source and binary forms, with or without @@ -122,9 +123,10 @@ if __name__ == '__main__': parser.add_option('-c', '--clean', action='store_true', default=False, dest='clean', help='Cleans the various data/conf/logs' 'directories before running tests.') - parser.add_option('-t', '--timeout', action='store', type='int', + parser.add_option('-t', '--timeout', action='store', type='float', dest='timeout', - help='Sets the timeout for tests to return responses.') + help='Sets the timeout, in seconds, for tests to return ' + 'responses.') parser.add_option('-v', '--verbose', action='store_true', default=False, help='Sets the verbose flag, logging extra information ' 'about each test that runs.') diff --git a/src/test.py b/src/test.py index 50dad08a7..a9f716264 100644 --- a/src/test.py +++ b/src/test.py @@ -1,5 +1,6 @@ ### # Copyright (c) 2002-2005, Jeremiah Fincher +# Copyright (c) 2011, James Vega # All rights reserved. # # Redistribution and use in source and binary forms, with or without @@ -54,6 +55,8 @@ network = True # This is the global list of suites that are to be run. suites = [] +timeout = 10 + originalCallbacksGetHelp = callbacks.getHelp lastGetHelp = 'x' * 1000 def cachingGetHelp(method, name=None, doc=None): @@ -110,12 +113,12 @@ class PluginTestCase(SupyTestCase): """Subclass this to write a test case for a plugin. See plugins/Plugin/test.py for an example. """ - timeout = 10 plugins = None cleanConfDir = True cleanDataDir = True config = {} def __init__(self, methodName='runTest'): + self.timeout = timeout originalRunTest = getattr(self, methodName) def runTest(self): run = True From 1c321409b8018a3d8ac60897ad1dc331d4ca1838 Mon Sep 17 00:00:00 2001 From: James Vega Date: Mon, 1 Aug 2011 17:17:22 -0400 Subject: [PATCH 193/243] Topic: Ensure do315's response is for a channel in our state object Signed-off-by: James Vega (cherry picked from commit 44eb449ba41b37f3fa5e9f63575c696f38d4707c) Signed-off-by: Daniel Folkinshteyn --- plugins/Topic/plugin.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/plugins/Topic/plugin.py b/plugins/Topic/plugin.py index a9e50ab93..d7d9463e1 100644 --- a/plugins/Topic/plugin.py +++ b/plugins/Topic/plugin.py @@ -236,7 +236,9 @@ class Topic(callbacks.Plugin): def do315(self, irc, msg): # Try to restore the topic when not set yet. channel = msg.args[1] - c = irc.state.channels[channel] + c = irc.state.channels.get(channel) + if c is None: + return if irc.nick not in c.ops and 't' in c.modes: self.log.debug('Not trying to restore topic in %s. I\'m not opped ' 'and %s is +t.', channel, channel) From b533290c7a0ab14e7f855bba930a421e2e72b0f0 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Tue, 11 Oct 2011 13:06:27 -0400 Subject: [PATCH 194/243] Web: fix problems with title snarfer and unicode due to bug in HTMLParser in python 2.6+ Upstream bug: http://bugs.python.org/issue3932 Rather than override the unescape method with the patch posted, we just convert the page text to unicode before passing it to the HTMLParser. UTF8 and Latin1 will eat just about anything. --- plugins/Web/plugin.py | 8 ++++++++ plugins/Web/test.py | 19 +++++++++---------- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/plugins/Web/plugin.py b/plugins/Web/plugin.py index bbfbed992..caba230ef 100644 --- a/plugins/Web/plugin.py +++ b/plugins/Web/plugin.py @@ -90,6 +90,10 @@ class Web(callbacks.PluginRegexp): try: size = conf.supybot.protocols.http.peekSize() text = utils.web.getUrl(url, size=size) + try: + text = text.decode('utf8') + except UnicodeDecodeError: + text = text.decode('latin1') except utils.web.Error, e: self.log.info('Couldn\'t snarf title of %u: %s.', url, e) return @@ -170,6 +174,10 @@ class Web(callbacks.PluginRegexp): """ size = conf.supybot.protocols.http.peekSize() text = utils.web.getUrl(url, size=size) + try: + text = text.decode('utf8') + except UnicodeDecodeError: + text = text.decode('latin1') parser = Title() try: parser.feed(text) diff --git a/plugins/Web/test.py b/plugins/Web/test.py index 9e6ff4f8a..5d2d626fe 100644 --- a/plugins/Web/test.py +++ b/plugins/Web/test.py @@ -49,8 +49,8 @@ class WebTestCase(ChannelPluginTestCase): self.assertNotError('size http://www.slashdot.org/') def testTitle(self): - self.assertResponse('title http://www.slashdot.org/', - 'Slashdot - News for nerds, stuff that matters') + self.assertRegexp('title http://www.slashdot.org/', + 'Slashdot') # Amazon add a bunch of scripting stuff to the top of their page, # so we need to allow for a larger peekSize # Actually, screw Amazon. Even bumping this up to 10k doesn't give us enough @@ -66,11 +66,11 @@ class WebTestCase(ChannelPluginTestCase): # finally: # conf.supybot.protocols.http.peekSize.setValue(orig) # Checks the non-greediness of the regexp - self.assertResponse('title ' - 'http://www.space.com/scienceastronomy/' - 'jupiter_dark_spot_031023.html', - 'SPACE.com -- Mystery Spot on Jupiter Baffles ' - 'Astronomers') + #~ self.assertResponse('title ' + #~ 'http://www.space.com/scienceastronomy/' + #~ 'jupiter_dark_spot_031023.html', + #~ 'SPACE.com -- Mystery Spot on Jupiter Baffles ' + #~ 'Astronomers') # Checks for @title not-working correctly self.assertResponse('title ' 'http://www.catb.org/~esr/jargon/html/F/foo.html', @@ -97,9 +97,8 @@ class WebTestCase(ChannelPluginTestCase): def testTitleSnarfer(self): try: conf.supybot.plugins.Web.titleSnarfer.setValue(True) - self.assertSnarfResponse('http://microsoft.com/', - 'Title: Microsoft Corporation' - ' (at microsoft.com)') + self.assertSnarfRegexp('http://microsoft.com/', + 'Microsoft Corporation') finally: conf.supybot.plugins.Web.titleSnarfer.setValue(False) From 20bfa97649737be9cdfe6f3697426d649bfb424a Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Mon, 21 Nov 2011 15:09:38 -0500 Subject: [PATCH 195/243] core: make sure owner is never ignored. also simplify the logic flow in ignore checking. Thanks m4v for the patch! --- src/ircdb.py | 52 ++++++++++++++++++---------------------------------- 1 file changed, 18 insertions(+), 34 deletions(-) diff --git a/src/ircdb.py b/src/ircdb.py index edd5ad5db..2292e55fc 100644 --- a/src/ircdb.py +++ b/src/ircdb.py @@ -945,46 +945,30 @@ def checkIgnored(hostmask, recipient='', users=users, channels=channels): 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) + 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 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) + if conf.supybot.defaultIgnore(): + log.debug('Ignoring %s due to conf.supybot.defaultIgnore', + hostmask) + return True + if ignores.checkIgnored(hostmask): + log.debug('Ignoring %s due to ignore database.', 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 + if ircutils.isChannel(recipient): + channel = channels.getChannel(recipient) + if channel.checkIgnored(hostmask): + log.debug('Ignoring %s due to the channel ignores.', hostmask) + return True + return False def _x(capability, ret): if isAntiCapability(capability): From f3e5223f3f4a08c39fe85ad3121fba7b83ca1b18 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Mon, 21 Nov 2011 15:14:34 -0500 Subject: [PATCH 196/243] Undo the web title fix because it appears it broke more than it fixed. --- plugins/Web/plugin.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/plugins/Web/plugin.py b/plugins/Web/plugin.py index caba230ef..bbfbed992 100644 --- a/plugins/Web/plugin.py +++ b/plugins/Web/plugin.py @@ -90,10 +90,6 @@ class Web(callbacks.PluginRegexp): try: size = conf.supybot.protocols.http.peekSize() text = utils.web.getUrl(url, size=size) - try: - text = text.decode('utf8') - except UnicodeDecodeError: - text = text.decode('latin1') except utils.web.Error, e: self.log.info('Couldn\'t snarf title of %u: %s.', url, e) return @@ -174,10 +170,6 @@ class Web(callbacks.PluginRegexp): """ size = conf.supybot.protocols.http.peekSize() text = utils.web.getUrl(url, size=size) - try: - text = text.decode('utf8') - except UnicodeDecodeError: - text = text.decode('latin1') parser = Title() try: parser.feed(text) From 50e4b6baf1e4e8f63d45a4ee9b7a2fd9a4833b3f Mon Sep 17 00:00:00 2001 From: James McCoy Date: Sat, 22 Oct 2011 14:57:20 -0400 Subject: [PATCH 197/243] String.decode: Only encode('utf-8') when the decode string is unicode Closes: Sf#3165718 Signed-off-by: James McCoy (cherry picked from commit 01c8dc7f78352c6e11b75b67efa0f816e0881702) Signed-off-by: Daniel Folkinshteyn --- plugins/String/plugin.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/plugins/String/plugin.py b/plugins/String/plugin.py index 96da6855f..93f8942e8 100644 --- a/plugins/String/plugin.py +++ b/plugins/String/plugin.py @@ -81,7 +81,12 @@ class String(callbacks.Plugin): . """ try: - irc.reply(text.decode(encoding).encode('utf-8')) + s = text.decode(encoding) + # Not all encodings decode to a unicode object. Only encode those + # that do. + if isinstance(s, unicode): + s = s.encode('utf-8') + irc.reply(s) except LookupError: irc.errorInvalid('encoding', encoding) except binascii.Error: From b42b06fe79ee0e1c283d70c45ef29685a491e649 Mon Sep 17 00:00:00 2001 From: James McCoy Date: Sat, 22 Oct 2011 15:23:56 -0400 Subject: [PATCH 198/243] RSS._getConverter: Encode strings before handing them off to other functions When the feed has a specified encoding, we'll be dealing with unicode objects in the response from feedparser.parse(). To avoid possible UnicodeErrors, we need to encode() before handing the string off to other functions, so the other functions are always dealing with bytestrings instead of bytestrings and unicode objects. Mixing unicode and bytestrings will cause implicit conversions of the unicode objects, which will most likely use the wrong encoding. Signed-off-by: James McCoy (cherry picked from commit 964c73f591f7eafed94d7bcd6dd7b94dbb0afad5) Signed-off-by: Daniel Folkinshteyn --- plugins/RSS/plugin.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/plugins/RSS/plugin.py b/plugins/RSS/plugin.py index 7481cb31e..0617a1264 100644 --- a/plugins/RSS/plugin.py +++ b/plugins/RSS/plugin.py @@ -288,8 +288,14 @@ class RSS(callbacks.Plugin): def _getConverter(self, feed): toText = utils.web.htmlToText if 'encoding' in feed: - return lambda s: toText(s).strip().encode(feed['encoding'], - 'replace') + def conv(s): + # encode() first so there implicit encoding doesn't happen in + # other functions when unicode and bytestring objects are used + # together + s = s.encode(feed['encoding'], 'replace') + s = toText(s).strip() + return s + return conv else: return lambda s: toText(s).strip() From b99525db63922f1cdc9e7aa014e3abdbeb91052b Mon Sep 17 00:00:00 2001 From: Valentin Lorentz Date: Wed, 29 Jun 2011 13:56:22 +0200 Subject: [PATCH 199/243] Owner: Fix bug with @enable and @disable if a plugin is given. Closes GH-43. Closes GH-44. Signed-off-by: James McCoy (cherry picked from commit 8fb97c56bc4017c16689d74c113a6bac843fc590) Signed-off-by: Daniel Folkinshteyn --- plugins/Owner/plugin.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/Owner/plugin.py b/plugins/Owner/plugin.py index c025529db..c0a1ffe4d 100644 --- a/plugins/Owner/plugin.py +++ b/plugins/Owner/plugin.py @@ -537,11 +537,11 @@ class Owner(callbacks.Plugin): if plugin.isCommand(command): pluginCommand = '%s.%s' % (plugin.name(), command) conf.supybot.commands.disabled().add(pluginCommand) + plugin._disabled.add(command) else: irc.error('%s is not a command in the %s plugin.' % (command, plugin.name())) return - self._disabled.add(pluginCommand, plugin.name()) else: conf.supybot.commands.disabled().add(command) self._disabled.add(command) @@ -557,8 +557,8 @@ class Owner(callbacks.Plugin): """ try: if plugin: + plugin._disabled.remove(command, plugin.name()) command = '%s.%s' % (plugin.name(), command) - self._disabled.remove(command, plugin.name()) else: self._disabled.remove(command) conf.supybot.commands.disabled().remove(command) From a93d3fee8533a1b86764c0a823934c8bd80d8be5 Mon Sep 17 00:00:00 2001 From: Valentin Lorentz Date: Thu, 30 Jun 2011 19:06:22 +0200 Subject: [PATCH 200/243] AutoMode: fix bans. Signed-off-by: James McCoy (cherry picked from commit 5dcbe57fa3acea5db2d40343ca3f9b8116e6e827) Signed-off-by: Daniel Folkinshteyn --- plugins/AutoMode/plugin.py | 1 + 1 file changed, 1 insertion(+) diff --git a/plugins/AutoMode/plugin.py b/plugins/AutoMode/plugin.py index 8c7fd1a69..8100f80a9 100644 --- a/plugins/AutoMode/plugin.py +++ b/plugins/AutoMode/plugin.py @@ -30,6 +30,7 @@ import time +import supybot.conf as conf import supybot.ircdb as ircdb import supybot.ircmsgs as ircmsgs import supybot.ircutils as ircutils From f78803a82bc91cca0f3b3ae2437b21a4a6fa5d0b Mon Sep 17 00:00:00 2001 From: Valentin Lorentz Date: Thu, 30 Jun 2011 15:23:17 +0200 Subject: [PATCH 201/243] Relay: remove redundant nick on join/part when hostmasks enabled Signed-off-by: James McCoy (cherry picked from commit ce4d26514b96bf78c7496b28500816913058c3b0) Signed-off-by: Daniel Folkinshteyn --- plugins/Relay/plugin.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/Relay/plugin.py b/plugins/Relay/plugin.py index ffd31f80f..203159aa3 100644 --- a/plugins/Relay/plugin.py +++ b/plugins/Relay/plugin.py @@ -384,7 +384,7 @@ class Relay(callbacks.Plugin): return network = self._getIrcName(irc) if self.registryValue('hostmasks', channel): - hostmask = format(' (%s)', msg.prefix) + hostmask = format(' (%s)', msg.prefix.split('!')[1]) else: hostmask = '' s = format('%s%s has joined on %s', msg.nick, hostmask, network) @@ -398,7 +398,7 @@ class Relay(callbacks.Plugin): return network = self._getIrcName(irc) if self.registryValue('hostmasks', channel): - hostmask = format(' (%s)', msg.prefix) + hostmask = format(' (%s)', msg.prefix.split('!')[1]) else: hostmask = '' if len(msg.args) > 1: From a6f9c1c2d4c562ef5ca0ea895471245a1c17b16d Mon Sep 17 00:00:00 2001 From: Valentin Lorentz Date: Fri, 15 Jul 2011 23:04:49 +0200 Subject: [PATCH 202/243] Protector: Fix variable name. Signed-off-by: James McCoy (cherry picked from commit 70a6e6932d2b27dc516aa031fded1afe3359a850) Signed-off-by: Daniel Folkinshteyn --- plugins/Protector/plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/Protector/plugin.py b/plugins/Protector/plugin.py index cae90feeb..0867e48eb 100644 --- a/plugins/Protector/plugin.py +++ b/plugins/Protector/plugin.py @@ -96,7 +96,7 @@ class Protector(callbacks.Plugin): channel = msg.args[0] chanOp = ircdb.makeChannelCapability(channel, 'op') chanVoice = ircdb.makeChannelCapability(channel, 'voice') - chanhalfop = ircdb.makeChannelCapability(channel, 'halfop') + chanHalfOp = ircdb.makeChannelCapability(channel, 'halfop') if not ircdb.checkCapability(msg.prefix, chanOp): irc.sendMsg(ircmsgs.deop(channel, msg.nick)) for (mode, value) in ircutils.separateModes(msg.args[1:]): From 11b5ca3fd7ffbd43747a4a5af647b6d39adc548f Mon Sep 17 00:00:00 2001 From: Valentin Lorentz Date: Thu, 30 Jun 2011 19:28:20 +0200 Subject: [PATCH 203/243] Karma: fix typo. Closes GH-38. Conflicts: plugins/Karma/locale/fi.po plugins/Karma/locale/fr.po plugins/Karma/messages.pot Signed-off-by: James McCoy (cherry picked from commit fa8385596dc99a483da4b98946253c8282a5baa6) Signed-off-by: Daniel Folkinshteyn --- plugins/Karma/plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/Karma/plugin.py b/plugins/Karma/plugin.py index 773325a09..085e3a852 100644 --- a/plugins/Karma/plugin.py +++ b/plugins/Karma/plugin.py @@ -284,7 +284,7 @@ class Karma(callbacks.Plugin): N karmas, where N is determined by the config variable supybot.plugins.Karma.rankingDisplay. If one is given, returns the details of its karma; if more than one is given, returns - the total karma of each of the the things. is only necessary + the total karma of each of the things. is only necessary if the message isn't sent on the channel itself. """ if len(things) == 1: From 0a6d38f37a180abd6a7564a502af53fe91dcfb80 Mon Sep 17 00:00:00 2001 From: Valentin Lorentz Date: Sat, 9 Jul 2011 14:05:28 +0200 Subject: [PATCH 204/243] Channel: fix NameError: 'replyirc' -> 'replyIrc'. Closes GH-73. Conflicts: src/version.py Signed-off-by: James McCoy (cherry picked from commit 8056da06f64796a981defffe7b6e0bac462f0175) Signed-off-by: Daniel Folkinshteyn --- plugins/Channel/plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/Channel/plugin.py b/plugins/Channel/plugin.py index a8f3ec0e8..f74e18863 100644 --- a/plugins/Channel/plugin.py +++ b/plugins/Channel/plugin.py @@ -419,7 +419,7 @@ class Channel(callbacks.Plugin): nick = msg.args[1] nick = ircutils.toLower(nick) replyIrc = self.invites.pop((irc, nick), None) - if replyirc is not None: + if replyIrc is not None: replyIrc.error(format('There is no %s on this server.', nick)) class lobotomy(callbacks.Commands): From f3136655e7bc6c92b3cdd0396032b0ffa65584eb Mon Sep 17 00:00:00 2001 From: Valentin Lorentz Date: Mon, 18 Jul 2011 15:23:06 +0200 Subject: [PATCH 205/243] Factoids: Fix typo. Conflicts: plugins/Factoids/locale/fi.po plugins/Factoids/locale/fr.po plugins/Factoids/messages.pot src/version.py Signed-off-by: James McCoy (cherry picked from commit 8fb4cbcdc61c2108309afd46e5a3a516fe676fb1) Signed-off-by: Daniel Folkinshteyn --- plugins/Factoids/plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/Factoids/plugin.py b/plugins/Factoids/plugin.py index cb2ff60a6..ddc285cea 100644 --- a/plugins/Factoids/plugin.py +++ b/plugins/Factoids/plugin.py @@ -643,7 +643,7 @@ class Factoids(callbacks.Plugin, plugins.ChannelDBHandler): """[] [--values] [--{regexp} ] [ ...] Searches the keyspace for keys matching . If --regexp is given, - it associated value is taken as a regexp and matched against the keys. + its associated value is taken as a regexp and matched against the keys. If --values is given, search the value space instead of the keyspace. """ if not optlist and not globs: From 3d5f92a61f7d23c97661ed630eaeae03fccded2b Mon Sep 17 00:00:00 2001 From: Valentin Lorentz Date: Sat, 30 Oct 2010 12:24:02 +0200 Subject: [PATCH 206/243] Topic: Fix bad doctring Conflicts: plugins/Topic/messages.pot Signed-off-by: James McCoy (cherry picked from commit 9561c9f41744d70677bf7d8e15a73c9e30ac2502) Signed-off-by: Daniel Folkinshteyn --- plugins/Topic/plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/Topic/plugin.py b/plugins/Topic/plugin.py index d7d9463e1..9d121fa80 100644 --- a/plugins/Topic/plugin.py +++ b/plugins/Topic/plugin.py @@ -465,7 +465,7 @@ class Topic(callbacks.Plugin): def unlock(self, irc, msg, args, channel): """[] - Locks the topic (sets the mode +t) in . is only + Unlocks the topic (sets the mode +t) in . is only necessary if the message isn't sent in the channel itself. """ if not self._checkManageCapabilities(irc, msg, channel): From 90b7a83127f13134705764e9992f7b1261f24fd8 Mon Sep 17 00:00:00 2001 From: Valentin Lorentz Date: Wed, 20 Jul 2011 21:28:43 +0200 Subject: [PATCH 207/243] Topic: fix typo in @unlock help. Conflicts: plugins/Topic/locale/fr.po plugins/Topic/messages.pot src/version.py Signed-off-by: James McCoy (cherry picked from commit f1690e68677b1c433e25b2c8766338bf995bceee) Signed-off-by: Daniel Folkinshteyn --- plugins/Topic/plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/Topic/plugin.py b/plugins/Topic/plugin.py index 9d121fa80..622e059c4 100644 --- a/plugins/Topic/plugin.py +++ b/plugins/Topic/plugin.py @@ -465,7 +465,7 @@ class Topic(callbacks.Plugin): def unlock(self, irc, msg, args, channel): """[] - Unlocks the topic (sets the mode +t) in . is only + Unlocks the topic (sets the mode -t) in . is only necessary if the message isn't sent in the channel itself. """ if not self._checkManageCapabilities(irc, msg, channel): From 3248bd79c4343c65dc8793ab733b511a918264ea Mon Sep 17 00:00:00 2001 From: Valentin Lorentz Date: Sun, 7 Aug 2011 12:02:06 +0200 Subject: [PATCH 208/243] NickCapture: Fix plugin help and l10n-fr. Closes GH-116. Conflicts: plugins/NickCapture/locale/fr.po plugins/NickCapture/messages.pot src/version.py Signed-off-by: James McCoy (cherry picked from commit a1a90f76735295a02c5a1450fcbcc571fcb18c24) Signed-off-by: Daniel Folkinshteyn --- plugins/NickCapture/plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/NickCapture/plugin.py b/plugins/NickCapture/plugin.py index e81ad5718..56db6347c 100644 --- a/plugins/NickCapture/plugin.py +++ b/plugins/NickCapture/plugin.py @@ -35,7 +35,7 @@ import supybot.ircutils as ircutils import supybot.callbacks as callbacks class NickCapture(callbacks.Plugin): - """This module constantly tries to take whatever nick is configured as + """This plugin constantly tries to take whatever nick is configured as supybot.nick. Just make sure that's set appropriately, and thus plugin will do the rest.""" public = False From c2b6633fe7b8c1957385a9f2e091027af3d174d9 Mon Sep 17 00:00:00 2001 From: James McCoy Date: Sun, 23 Oct 2011 20:26:39 -0400 Subject: [PATCH 209/243] Channel.nicks: Raise error so we don't actually reply with the nicks. Closes: Sf#3396388 Signed-off-by: James McCoy (cherry picked from commit 0869a8e271e9951219dcddd228bec9cb08fc291f) Signed-off-by: Daniel Folkinshteyn --- plugins/Channel/plugin.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugins/Channel/plugin.py b/plugins/Channel/plugin.py index f74e18863..00df9e9f9 100644 --- a/plugins/Channel/plugin.py +++ b/plugins/Channel/plugin.py @@ -790,7 +790,8 @@ class Channel(callbacks.Plugin): msg.args[0] != channel and \ (ircutils.isChannel(msg.args[0]) or \ msg.nick not in irc.state.channels[channel].users): - irc.error('You don\'t have access to that information.') + irc.error('You don\'t have access to that information.', + Raise=True) L = list(irc.state.channels[channel].users) keys = [option for (option, arg) in optlist] if 'count' not in keys: From edbf43f81f8786607b77ef896ce4ce8e4b550358 Mon Sep 17 00:00:00 2001 From: James McCoy Date: Sat, 29 Oct 2011 17:22:27 -0400 Subject: [PATCH 210/243] irclib: Add support for 004 messages from the server At a minimum, the message gives us the server name, ircd version, supported umodes, and supported channel modes. Add the umodes and channel modes to self.supported. Some IRCds (e.g., hybrid and ircd-seven) have an extra arg which seems to be the channel modes that require arguments. Signed-off-by: James McCoy (cherry picked from commit c9e548bdd926f1f4542a5b0121a0b9251706d36b) Signed-off-by: Daniel Folkinshteyn --- src/irclib.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/irclib.py b/src/irclib.py index 0efa1abc8..10112e573 100644 --- a/src/irclib.py +++ b/src/irclib.py @@ -401,6 +401,15 @@ class IrcState(IrcCommandDispatcher): """Returns the hostmask for a given nick.""" return self.nicksToHostmasks[nick] + def do004(self, irc, msg): + """Handles parsing the 004 reply + + Supported user and channel modes are cached""" + # msg.args = [nick, server, ircd-version, umodes, modes, + # modes that require arguments? (non-standard)] + self.supported['umodes'] = msg.args[3] + self.supported['chanmodes'] = msg.args[4] + _005converters = utils.InsensitivePreservingDict({ 'modes': int, 'keylen': int, From 23bfe116fff18f596439776fedc22c3c46ae0d44 Mon Sep 17 00:00:00 2001 From: James McCoy Date: Sat, 29 Oct 2011 17:22:42 -0400 Subject: [PATCH 211/243] irclib: Filter out unsupported umodes before sending them to the server Closes: Sf#3075891 Signed-off-by: James McCoy (cherry picked from commit b23480b915682eb35b77bf3b688c731eb7c8d72e) Signed-off-by: Daniel Folkinshteyn --- src/irclib.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/irclib.py b/src/irclib.py index 10112e573..ffded1652 100644 --- a/src/irclib.py +++ b/src/irclib.py @@ -929,9 +929,14 @@ class Irc(IrcCommandDispatcher): # 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() + supported = self.supported.get('umodes') if umodes: - if umodes[0] not in '+-': - umodes = '+' + umodes + addSub = '+' + if umodes[0] in '+-': + (addSub, umodes) = (umodes[0], umodes[1:]) + if supported: + umodes = filter(lamda m: m in supported, umodes) + umodes = ''.join(addSub, umodes) log.info('Sending user modes to %s: %s', self.network, umodes) self.sendMsg(ircmsgs.mode(self.nick, umodes)) do377 = do422 = do376 From 865e87cf74ed1a514b395634595f18fdb8cefb7e Mon Sep 17 00:00:00 2001 From: James McCoy Date: Sat, 29 Oct 2011 17:53:35 -0400 Subject: [PATCH 212/243] Misc: Avoid setting up "invalid command" flood handling if its not enabled Closes: Sf#3088554 Signed-off-by: James McCoy --- plugins/Misc/plugin.py | 44 ++++++++++++++++++++++-------------------- 1 file changed, 23 insertions(+), 21 deletions(-) diff --git a/plugins/Misc/plugin.py b/plugins/Misc/plugin.py index 06295cf8d..650b28312 100644 --- a/plugins/Misc/plugin.py +++ b/plugins/Misc/plugin.py @@ -60,31 +60,33 @@ class Misc(callbacks.Plugin): 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 \ - conf.supybot.abuse.flood.command.invalid() 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. Consider ignoring this bot ' - 'permanently.') - ircdb.ignores.add(banmask, time.time() + punishment) - if conf.supybot.abuse.flood.command.invalid.notify(): + channel = msg.args[0] + # Only bother with the invaildCommand flood handling if it's actually + # enabled + if conf.supybot.abuse.flood.command.invalid(): + # 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() + banmasker = conf.supybot.protocols.irc.banmask.makeBanmask + self.invalidCommands.enqueue(msg) + if self.invalidCommands.len(msg) > maximum and \ + not ircdb.checkCapability(msg.prefix, 'owner'): + penalty = conf.supybot.abuse.flood.command.invalid.punishment() + banmask = banmasker(msg.prefix) + self.log.info('Ignoring %s for %s seconds due to an apparent ' + 'invalid command flood.', banmask, penalty) + if tokens and tokens[0] == 'Error:': + self.log.warning('Apparent error loop with another Supybot ' + 'observed. Consider ignoring this bot ' + 'permanently.') + ircdb.ignores.add(banmask, time.time() + penalty) 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 + utils.timeElapsed(penalty, seconds=False))) + return # Now, for normal handling. - channel = msg.args[0] if conf.get(conf.supybot.reply.whenNotCommand, channel): if len(tokens) >= 2: cb = irc.getCallback(tokens[0]) From ab9365f172dbd697bf959f3f79275b30b5b6091e Mon Sep 17 00:00:00 2001 From: James McCoy Date: Sun, 6 Nov 2011 22:20:55 -0500 Subject: [PATCH 213/243] Fix filtering of unsupported umodes Signed-off-by: James McCoy (cherry picked from commit 3bfda3cc7ac52ff0fd13150f044f9b5b9c4f03e2) Signed-off-by: Daniel Folkinshteyn --- src/irclib.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/irclib.py b/src/irclib.py index ffded1652..4989312e5 100644 --- a/src/irclib.py +++ b/src/irclib.py @@ -935,7 +935,7 @@ class Irc(IrcCommandDispatcher): if umodes[0] in '+-': (addSub, umodes) = (umodes[0], umodes[1:]) if supported: - umodes = filter(lamda m: m in supported, umodes) + umodes = [m for m in umodes if m in supported] umodes = ''.join(addSub, umodes) log.info('Sending user modes to %s: %s', self.network, umodes) self.sendMsg(ircmsgs.mode(self.nick, umodes)) From 23f07cadc2f8ee7980485cc5d22442ac7fcf428d Mon Sep 17 00:00:00 2001 From: James McCoy Date: Mon, 7 Nov 2011 05:58:27 -0500 Subject: [PATCH 214/243] Fix parsing of 004 message for supported umodes/chanmodes Signed-off-by: James McCoy (cherry picked from commit 4232e40e262cbbdb675b1cf315c43f576d8b1f43) Signed-off-by: Daniel Folkinshteyn --- src/irclib.py | 6 +++--- test/test_irclib.py | 7 +++++++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/irclib.py b/src/irclib.py index 4989312e5..82248c7e0 100644 --- a/src/irclib.py +++ b/src/irclib.py @@ -405,10 +405,10 @@ class IrcState(IrcCommandDispatcher): """Handles parsing the 004 reply Supported user and channel modes are cached""" - # msg.args = [nick, server, ircd-version, umodes, modes, + # msg.args = [server, ircd-version, umodes, modes, # modes that require arguments? (non-standard)] - self.supported['umodes'] = msg.args[3] - self.supported['chanmodes'] = msg.args[4] + self.supported['umodes'] = msg.args[2] + self.supported['chanmodes'] = msg.args[3] _005converters = utils.InsensitivePreservingDict({ 'modes': int, diff --git a/test/test_irclib.py b/test/test_irclib.py index 52dc7e258..bda696be6 100644 --- a/test/test_irclib.py +++ b/test/test_irclib.py @@ -290,6 +290,13 @@ class IrcStateTestCase(SupyTestCase): state.addMsg(self.irc, ircmsgs.IrcMsg(':irc.inet.tele.dk 005 adkwbot WALLCHOPS KNOCK EXCEPTS INVEX MODES=4 MAXCHANNELS=20 MAXBANS=beI:100 MAXTARGETS=4 NICKLEN=9 TOPICLEN=120 KICKLEN=90 :are supported by this server')) self.assertEqual(state.supported['maxbans'], 100) + def testSupportedUmodes(self): + state = irclib.IrcState() + state.addMsg(self.irc, ircmsgs.IrcMsg(':charm.oftc.net 004 charm.oftc.net hybrid-7.2.2+oftc1.6.8 CDGPRSabcdfgiklnorsuwxyz biklmnopstveI bkloveI')) + self.assertEqual(state.supported['umodes'], 'CDGPRSabcdfgiklnorsuwxyz') + self.assertEqual(state.supported['chanmodes'], + 'biklmnopstveI') + def testEmptyTopic(self): state = irclib.IrcState() state.addMsg(self.irc, ircmsgs.topic('#foo')) From 2fb9ac2024227463f2a76211b295617fddf0dd1f Mon Sep 17 00:00:00 2001 From: James McCoy Date: Mon, 7 Nov 2011 05:58:48 -0500 Subject: [PATCH 215/243] Fix IRC.do376's handling of supported state Signed-off-by: James McCoy (cherry picked from commit d6336421e0940120005b73676baf20560f4f8c78) Signed-off-by: Daniel Folkinshteyn --- src/irclib.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/irclib.py b/src/irclib.py index 82248c7e0..e6aedc2c7 100644 --- a/src/irclib.py +++ b/src/irclib.py @@ -929,13 +929,13 @@ class Irc(IrcCommandDispatcher): # 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() - supported = self.supported.get('umodes') + supported = self.state.supported.get('umodes') if umodes: addSub = '+' if umodes[0] in '+-': (addSub, umodes) = (umodes[0], umodes[1:]) if supported: - umodes = [m for m in umodes if m in supported] + umodes = ''.join([m for m in umodes if m in supported]) umodes = ''.join(addSub, umodes) log.info('Sending user modes to %s: %s', self.network, umodes) self.sendMsg(ircmsgs.mode(self.nick, umodes)) From d8ead9bc2907fdabe7745c97d8fad80abc4eb097 Mon Sep 17 00:00:00 2001 From: James McCoy Date: Sun, 13 Nov 2011 15:10:16 -0500 Subject: [PATCH 216/243] One last fix to umode filtering. Signed-off-by: James McCoy (cherry picked from commit 4833976294805763f7f7ebfd6adaa04864c2b500) Signed-off-by: Daniel Folkinshteyn --- src/irclib.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/irclib.py b/src/irclib.py index e6aedc2c7..4f8f73ff8 100644 --- a/src/irclib.py +++ b/src/irclib.py @@ -936,7 +936,7 @@ class Irc(IrcCommandDispatcher): (addSub, umodes) = (umodes[0], umodes[1:]) if supported: umodes = ''.join([m for m in umodes if m in supported]) - umodes = ''.join(addSub, umodes) + umodes = ''.join([addSub, umodes]) log.info('Sending user modes to %s: %s', self.network, umodes) self.sendMsg(ircmsgs.mode(self.nick, umodes)) do377 = do422 = do376 From 63ec70bc08feb20dccb22ef8148f8dc71a3a5444 Mon Sep 17 00:00:00 2001 From: James McCoy Date: Mon, 5 Dec 2011 22:48:09 -0500 Subject: [PATCH 217/243] Use socket.inet_aton for isIPV4 since Windows doesn't (always?) have inet_pton Closes: Sf#3430008 Signed-off-by: James McCoy (cherry picked from commit 360a2036ac26d78c3f98db37239da9987f5c326a) Signed-off-by: Daniel Folkinshteyn --- src/utils/net.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/net.py b/src/utils/net.py index ffe8c2005..17abb3b5b 100644 --- a/src/utils/net.py +++ b/src/utils/net.py @@ -73,7 +73,7 @@ def isIPV4(s): 0 """ try: - return bool(socket.inet_pton(socket.AF_INET, s)) + return bool(socket.inet_aton(s)) except socket.error: return False From bcdc8ecb007e485f9a1069d31475cff7afd583cd Mon Sep 17 00:00:00 2001 From: James McCoy Date: Mon, 5 Dec 2011 23:13:09 -0500 Subject: [PATCH 218/243] Prevent nesting of Misc.tell Signed-off-by: James McCoy (cherry picked from commit 5b4c150d037ddfcd7358e00850178a4b60fd44b8) Signed-off-by: Daniel Folkinshteyn --- plugins/Misc/plugin.py | 2 ++ plugins/Misc/test.py | 3 +++ 2 files changed, 5 insertions(+) diff --git a/plugins/Misc/plugin.py b/plugins/Misc/plugin.py index 650b28312..de42dbf66 100644 --- a/plugins/Misc/plugin.py +++ b/plugins/Misc/plugin.py @@ -404,6 +404,8 @@ class Misc(callbacks.Plugin): Tells the whatever is. Use nested commands to your benefit here. """ + if irc.nested: + irc.error('This command cannot be nested.', Raise=True) if target.lower() == 'me': target = msg.nick if ircutils.isChannel(target): diff --git a/plugins/Misc/test.py b/plugins/Misc/test.py index 2479cb96f..a90e97a87 100644 --- a/plugins/Misc/test.py +++ b/plugins/Misc/test.py @@ -142,6 +142,9 @@ class MiscTestCase(ChannelPluginTestCase): m = self.getMsg('tell me you love me') self.failUnless(m.args[0] == self.nick) + def testNoNestedTell(self): + self.assertRegexp('echo [tell %s foo]' % self.nick, 'nested') + def testTellDoesNotPropogateAction(self): m = self.getMsg('tell foo [action bar]') self.failIf(ircmsgs.isAction(m)) From c606ba6d803d1f97fbe7e12b04c3010086d833dc Mon Sep 17 00:00:00 2001 From: Valentin Lorentz Date: Sun, 13 Nov 2011 16:34:01 +0100 Subject: [PATCH 219/243] Debug: Fix import. (cherry picked from commit a79e9c0cad23cb6917a95a17598e46ed00643fdf) Signed-off-by: Daniel Folkinshteyn --- sandbox/Debug/plugin.py | 1 + 1 file changed, 1 insertion(+) diff --git a/sandbox/Debug/plugin.py b/sandbox/Debug/plugin.py index 26175ef44..3ee72750f 100644 --- a/sandbox/Debug/plugin.py +++ b/sandbox/Debug/plugin.py @@ -46,6 +46,7 @@ import supybot.conf as conf import supybot.utils as utils import supybot.ircdb as ircdb from supybot.commands import * +import supybot.ircmsgs as ircmsgs import supybot.callbacks as callbacks def getTracer(fd): From be9415912cd4777070e6e360b6c0000ed14cb0a4 Mon Sep 17 00:00:00 2001 From: James McCoy Date: Mon, 5 Dec 2011 23:52:38 -0500 Subject: [PATCH 220/243] Perform all received* IrcMsg tagging in one spot. This also fixes a long-standing failing Misc test since it was relying on the receivedAt tag. Signed-off-by: James McCoy (cherry picked from commit 4ddfae427f9846c77d72cbb3fe3b6a8cdef658e9) Signed-off-by: Daniel Folkinshteyn --- src/drivers/__init__.py | 2 -- src/irclib.py | 1 + 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/drivers/__init__.py b/src/drivers/__init__.py index 333ec8258..03cf9e59b 100644 --- a/src/drivers/__init__.py +++ b/src/drivers/__init__.py @@ -210,11 +210,9 @@ def newDriver(irc, moduleName=None): return driver def parseMsg(s): - start = time.time() s = s.strip() if s: msg = ircmsgs.IrcMsg(s) - msg.tag('receivedAt', start) return msg else: return None diff --git a/src/irclib.py b/src/irclib.py index 4f8f73ff8..0c560d1e5 100644 --- a/src/irclib.py +++ b/src/irclib.py @@ -778,6 +778,7 @@ class Irc(IrcCommandDispatcher): """Called by the IrcDriver; feeds a message received.""" msg.tag('receivedBy', self) msg.tag('receivedOn', self.network) + msg.tag('receivedAt', time.time()) if msg.args and self.isChannel(msg.args[0]): channel = msg.args[0] else: From e2f27512bcfc4e4dc0d75088d198a5cdef84c6d6 Mon Sep 17 00:00:00 2001 From: James McCoy Date: Tue, 6 Dec 2011 00:08:01 -0500 Subject: [PATCH 221/243] Only use ircmsg.whois's mask argument if it's non-empty. Closes: Sf#3121298 Signed-off-by: James McCoy (cherry picked from commit f6f9e654cb5801baa05adfe141df640a29dc3c90) Signed-off-by: Daniel Folkinshteyn --- src/ircmsgs.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/ircmsgs.py b/src/ircmsgs.py index 8db844c3f..d5f9fcaa1 100644 --- a/src/ircmsgs.py +++ b/src/ircmsgs.py @@ -727,7 +727,10 @@ def whois(nick, mask='', prefix='', msg=None): assert isNick(nick), repr(nick) if msg and not prefix: prefix = msg.prefix - return IrcMsg(prefix=prefix, command='WHOIS', args=(nick, mask), msg=msg) + args = (nick,) + if mask: + args = (nick, mask) + return IrcMsg(prefix=prefix, command='WHOIS', args=args, msg=msg) def names(channel=None, prefix='', msg=None): if conf.supybot.protocols.irc.strictRfc(): From faf2608c83da13e2c46d7196e7b6023445a4be58 Mon Sep 17 00:00:00 2001 From: James McCoy Date: Tue, 6 Dec 2011 00:22:40 -0500 Subject: [PATCH 222/243] Bug fixes in src/ircmsgs.py unbans did send the repr() of the ban list, and IrcMsg.__hash__ did try to hash a list. Conflicts: src/version.py Signed-off-by: James McCoy (cherry picked from commit 998819da58aa79858d9d683c345d8dac0f49b721) Signed-off-by: Daniel Folkinshteyn --- src/ircmsgs.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/ircmsgs.py b/src/ircmsgs.py index d5f9fcaa1..b5e50b4e5 100644 --- a/src/ircmsgs.py +++ b/src/ircmsgs.py @@ -182,7 +182,7 @@ class IrcMsg(object): return self._hash self._hash = hash(self.command) ^ \ hash(self.prefix) ^ \ - hash(self.args) + hash(repr(self.args)) return self._hash def __repr__(self): @@ -522,7 +522,8 @@ def unbans(channel, hostmasks, prefix='', msg=None): if msg and not prefix: prefix = msg.prefix return IrcMsg(prefix=prefix, command='MODE', msg=msg, - args=(channel, '-' + ('b'*len(hostmasks)), hostmasks)) + args=(channel, '-' + ('b'*len(hostmasks)), + ' '.join(hostmasks))) def kick(channel, nick, s='', prefix='', msg=None): """Returns a KICK to kick nick from channel with the message msg.""" From 05adad617d5f0e85f3a61b164fa41952e39bd51d Mon Sep 17 00:00:00 2001 From: James McCoy Date: Tue, 6 Dec 2011 00:55:29 -0500 Subject: [PATCH 223/243] Simplify handling of per-network waitingJoins Signed-off-by: James McCoy (cherry picked from commit c90fafebe790f86291e3560938da573ab0f837d4) Signed-off-by: Daniel Folkinshteyn --- plugins/Services/plugin.py | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/plugins/Services/plugin.py b/plugins/Services/plugin.py index 28d719544..5ca8484f1 100644 --- a/plugins/Services/plugin.py +++ b/plugins/Services/plugin.py @@ -30,7 +30,6 @@ import re import time -import copy import config @@ -62,7 +61,7 @@ class Services(callbacks.Plugin): self.channels = [] self.sentGhost = None self.identified = False - self.waitingJoins = [] + self.waitingJoins = {} def disabled(self, irc): disabled = self.registryValue('disabledNetworks') @@ -77,7 +76,8 @@ class Services(callbacks.Plugin): if self.registryValue('noJoinsUntilIdentified'): self.log.info('Holding JOIN to %s until identified.', msg.args[0]) - self.waitingJoins.append((irc.network, msg,)) + self.waitingJoins.setdefault(irc.network, []) + self.waitingJoins[irc.network].append(msg) return None return msg @@ -314,15 +314,10 @@ class Services(callbacks.Plugin): self.checkPrivileges(irc, channel) for channel in self.channels: irc.queueMsg(networkGroup.channels.join(channel)) - if self.waitingJoins: - tmp_wj = copy.deepcopy(self.waitingJoins) # can't iterate over list if we're modifying it - for netname, m in tmp_wj: - if netname == irc.network: - irc.sendMsg(m) - try: - self.waitingJoins.remove((netname, m,)) - except ValueError: - pass # weird stuff happen sometimes + waitingJoins = self.waitingJoins.pop(irc.network, None) + if waitingJoins: + for m in waitingJoins: + irc.sendMsg(m) elif 'not yet authenticated' in s: # zirc.org has this, it requires an auth code. email = s.split()[-1] From 4fe8fe4b109fc5cab377305a35366a30d7803ca9 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Mon, 12 Dec 2011 12:59:27 -0500 Subject: [PATCH 224/243] Google: use google ig api for the calc. no more web scraping. let's hope this stays alive. --- plugins/Google/plugin.py | 27 +++++++++------------------ 1 file changed, 9 insertions(+), 18 deletions(-) diff --git a/plugins/Google/plugin.py b/plugins/Google/plugin.py index 8a7cbe770..4a2b37a0b 100644 --- a/plugins/Google/plugin.py +++ b/plugins/Google/plugin.py @@ -316,33 +316,24 @@ class Google(callbacks.PluginRegexp): def _googleUrl(self, s): s = s.replace('+', '%2B') s = s.replace(' ', '+') - url = r'http://google.com/search?q=' + s + url = r'http://www.google.com/ig/calculator?hl=en&q=' + s return url - _calcRe1 = re.compile(r']*>(.*?)', re.I) - _calcRe2 = re.compile(r'(.*?)', re.I) - _calcSupRe = re.compile(r'(.*?)', re.I) - _calcFontRe = re.compile(r'(.*?)') - _calcTimesRe = re.compile(r'&(?:times|#215);') def calc(self, irc, msg, args, expr): """ Uses Google's calculator to calculate the value of . """ url = self._googleUrl(expr) - html = utils.web.getUrl(url) - match = self._calcRe1.search(html) - if match is None: - match = self._calcRe2.search(html) - if match is not None: - s = match.group(1) - s = self._calcSupRe.sub(r'^(\1)', s) - s = self._calcFontRe.sub(r',', s) - s = self._calcTimesRe.sub(r'*', s) - s = utils.web.htmlToText(s) - irc.reply(s) + js = utils.web.getUrl(url) + # fix bad google json + js = js.replace('lhs:','"lhs":').replace('rhs:','"rhs":').replace('error:','"error":').replace('icc:','"icc":') + js = simplejson.loads(js) + + if js['error'] == '': + irc.reply("%s = %s" % (js['lhs'], js['rhs'],)) else: - irc.reply('Google\'s calculator didn\'t come up with anything.') + irc.reply('Google says: Error: %s.' % (js['error'],)) calc = wrap(calc, ['text']) _phoneRe = re.compile(r'Phonebook.*?(.*?) Date: Mon, 12 Dec 2011 14:57:10 -0500 Subject: [PATCH 225/243] Google: use web scraping as fallback to ig api ig api doesn't have everything (for one, timezones), and also, in case the IG api ever dies. --- plugins/Google/plugin.py | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/plugins/Google/plugin.py b/plugins/Google/plugin.py index 4a2b37a0b..9421783cc 100644 --- a/plugins/Google/plugin.py +++ b/plugins/Google/plugin.py @@ -314,26 +314,51 @@ class Google(callbacks.PluginRegexp): googleSnarfer = urlSnarfer(googleSnarfer) def _googleUrl(self, s): + s = s.replace('+', '%2B') + s = s.replace(' ', '+') + url = r'http://google.com/search?q=' + s + return url + + def _googleUrlIG(self, s): s = s.replace('+', '%2B') s = s.replace(' ', '+') url = r'http://www.google.com/ig/calculator?hl=en&q=' + s return url + _calcRe1 = re.compile(r']*>(.*?)', re.I) + _calcRe2 = re.compile(r']*>(?:)?(.*?)(?:)?', re.I | re.S) + _calcSupRe = re.compile(r'(.*?)', re.I) + _calcFontRe = re.compile(r'(.*?)') + _calcTimesRe = re.compile(r'&(?:times|#215);') def calc(self, irc, msg, args, expr): """ Uses Google's calculator to calculate the value of . """ - url = self._googleUrl(expr) - js = utils.web.getUrl(url) + urlig = self._googleUrlIG(expr) + js = utils.web.getUrl(urlig) # fix bad google json js = js.replace('lhs:','"lhs":').replace('rhs:','"rhs":').replace('error:','"error":').replace('icc:','"icc":') js = simplejson.loads(js) if js['error'] == '': irc.reply("%s = %s" % (js['lhs'], js['rhs'],)) + return + + url = self._googleUrl(expr) + html = utils.web.getUrl(url) + match = self._calcRe1.search(html) + if match is None: + match = self._calcRe2.search(html) + if match is not None: + s = match.group(1) + s = self._calcSupRe.sub(r'^(\1)', s) + s = self._calcFontRe.sub(r',', s) + s = self._calcTimesRe.sub(r'*', s) + s = utils.web.htmlToText(s) + irc.reply(s) else: - irc.reply('Google says: Error: %s.' % (js['error'],)) + irc.reply('Google\'s calculator didn\'t come up with anything.') calc = wrap(calc, ['text']) _phoneRe = re.compile(r'Phonebook.*?(.*?) Date: Tue, 13 Dec 2011 18:21:38 -0500 Subject: [PATCH 226/243] core: avoid casting data to string if it is already an instance of basestring, in irc.reply. --- src/callbacks.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/callbacks.py b/src/callbacks.py index 45b34650c..11e41d2e9 100644 --- a/src/callbacks.py +++ b/src/callbacks.py @@ -824,7 +824,8 @@ class NestedCommandsIrcProxy(ReplyIrcProxy): # action=True implies noLengthCheck=True and prefixNick=False self.noLengthCheck=noLengthCheck or self.noLengthCheck or self.action target = self.private and self.to or self.msg.args[0] - s = str(s) # Allow non-string esses. + if not isinstance(s, basestring): # avoid trying to str() unicode + s = str(s) # Allow non-string esses. if self.finalEvaled: try: if isinstance(self.irc, self.__class__): From 37023f5616d339e8c5724d1c53d5bb883449b4f3 Mon Sep 17 00:00:00 2001 From: Valentin Lorentz Date: Tue, 2 Nov 2010 12:17:41 +0100 Subject: [PATCH 227/243] MessageParser: fix two misspell In config.py config var help and in plugin.py docstring --- plugins/MessageParser/config.py | 2 +- plugins/MessageParser/plugin.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/MessageParser/config.py b/plugins/MessageParser/config.py index f70dbdb24..2e5221a5e 100644 --- a/plugins/MessageParser/config.py +++ b/plugins/MessageParser/config.py @@ -65,7 +65,7 @@ conf.registerChannelValue(MessageParser, 'requireManageCapability', Note that absence of an explicit anticapability means user has capability.""")) conf.registerChannelValue(MessageParser, 'listSeparator', - registry.String(', ', """Determines the separator used between rexeps when + registry.String(', ', """Determines the separator used between regexps when shown by the list command.""")) # vim:set shiftwidth=4 tabstop=4 expandtab textwidth=79: diff --git a/plugins/MessageParser/plugin.py b/plugins/MessageParser/plugin.py index 62d8e9dad..944395aef 100644 --- a/plugins/MessageParser/plugin.py +++ b/plugins/MessageParser/plugin.py @@ -204,7 +204,7 @@ class MessageParser(callbacks.Plugin, plugins.ChannelDBHandler): add = wrap(add, ['channel', 'something', 'something']) def remove(self, irc, msg, args, channel, optlist, regexp): - """[] [--id] ] + """[] [--id] Removes the trigger for from the triggers database. is only necessary if From 7283235caf7902a3cb02fffc89cfc45b5cd01665 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Sat, 25 Feb 2012 12:28:27 -0500 Subject: [PATCH 228/243] Seen: require caller to be in target channel when using commands in this plugin. This fixes information leakage from private channels. --- plugins/Seen/plugin.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/plugins/Seen/plugin.py b/plugins/Seen/plugin.py index 2f42bd2b3..4e3b1d95b 100644 --- a/plugins/Seen/plugin.py +++ b/plugins/Seen/plugin.py @@ -218,6 +218,9 @@ class Seen(callbacks.Plugin): saying. is only necessary if the message isn't sent on the channel itself. may contain * as a wildcard. """ + if msg.nick not in irc.state.channels[channel].users: + irc.error(format('You must be in %s to use this command.', channel)) + return self._seen(irc, channel, name) seen = wrap(seen, ['channel', 'something']) @@ -231,6 +234,9 @@ class Seen(callbacks.Plugin): and returns the last time user was active in . is only necessary if the message isn't sent on the channel itself. """ + if msg.nick not in irc.state.channels[channel].users: + irc.error(format('You must be in %s to use this command.', channel)) + return if name and optlist: raise callbacks.ArgumentError elif name: @@ -264,6 +270,9 @@ class Seen(callbacks.Plugin): Returns the last thing said in . is only necessary if the message isn't sent in the channel itself. """ + if msg.nick not in irc.state.channels[channel].users: + irc.error(format('You must be in %s to use this command.', channel)) + return self._last(irc, channel) last = wrap(last, ['channel']) @@ -289,6 +298,9 @@ class Seen(callbacks.Plugin): is only necessary if the message isn't sent in the channel itself. """ + if msg.nick not in irc.state.channels[channel].users: + irc.error(format('You must be in %s to use this command.', channel)) + return self._user(irc, channel, user) user = wrap(user, ['channel', 'otherUser']) @@ -297,11 +309,11 @@ class Seen(callbacks.Plugin): Returns the messages since last left the channel. """ - if nick is None: - nick = msg.nick - if nick not in irc.state.channels[channel].users: + if msg.nick not in irc.state.channels[channel].users: irc.error(format('You must be in %s to use this command.', channel)) return + if nick is None: + nick = msg.nick end = None # By default, up until the most recent message. for (i, m) in utils.seq.renumerate(irc.state.history): if end is None and m.command == 'JOIN' and \ From ce121459f7666d194d4ffa9df59df51920e1bb97 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Sat, 25 Feb 2012 12:35:55 -0500 Subject: [PATCH 229/243] Channelstats: require caller to be in target channel when using commands in this plugin. This fixes information leakage from private channels. --- plugins/ChannelStats/plugin.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/plugins/ChannelStats/plugin.py b/plugins/ChannelStats/plugin.py index d09129bed..dd7943c7a 100644 --- a/plugins/ChannelStats/plugin.py +++ b/plugins/ChannelStats/plugin.py @@ -245,6 +245,9 @@ class ChannelStats(callbacks.Plugin): necessary if the message isn't sent on the channel itself. If isn't given, it defaults to the user sending the command. """ + if msg.nick not in irc.state.channels[channel].users: + irc.error(format('You must be in %s to use this command.', channel)) + return if name and ircutils.strEqual(name, irc.nick): id = 0 elif not name: @@ -304,6 +307,9 @@ class ChannelStats(callbacks.Plugin): 'kicks', 'kicked', 'topics', and 'modes'. Any simple mathematical expression involving those variables is permitted. """ + if msg.nick not in irc.state.channels[channel].users: + irc.error(format('You must be in %s to use this command.', channel)) + return # XXX I could do this the right way, and abstract out a safe eval, # or I could just copy/paste from the Math plugin. if expr != expr.translate(utils.str.chars, '_[]'): @@ -345,6 +351,9 @@ class ChannelStats(callbacks.Plugin): Returns the statistics for . is only necessary if the message isn't sent on the channel itself. """ + if msg.nick not in irc.state.channels[channel].users: + irc.error(format('You must be in %s to use this command.', channel)) + return try: stats = self.db.getChannelStats(channel) curUsers = len(irc.state.channels[channel].users) From 6dbbb8d06eaf5ffb9ecaca35fb16eb33ba98b8e9 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Thu, 31 May 2012 22:53:21 -0400 Subject: [PATCH 230/243] core: force inet_aton argument to string to prevent occasional error on reconnect. it /should/ always be a string anyway, but sometimes things break with a TypeError that it is an int instead of the expected string and hangs up the bot. --- src/utils/net.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/net.py b/src/utils/net.py index 17abb3b5b..08656252c 100644 --- a/src/utils/net.py +++ b/src/utils/net.py @@ -73,7 +73,7 @@ def isIPV4(s): 0 """ try: - return bool(socket.inet_aton(s)) + return bool(socket.inet_aton(str(s))) except socket.error: return False From c991175425f4fcb29d0e47de955ab46523fa3c51 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Tue, 12 Jun 2012 12:28:26 -0400 Subject: [PATCH 231/243] Math: default %f formatting rounds to 6 decimal places. increase that to 16. --- plugins/Math/plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/Math/plugin.py b/plugins/Math/plugin.py index d3bb5aeb0..a863d484b 100644 --- a/plugins/Math/plugin.py +++ b/plugins/Math/plugin.py @@ -186,7 +186,7 @@ class Math(callbacks.Plugin): # use of str() on large numbers loses information: # str(float(33333333333333)) => '3.33333333333e+13' # float('3.33333333333e+13') => 33333333333300.0 - return '%f' % x + return '%.16f' % x return str(x) text = self._mathRe.sub(handleMatch, text) try: From 27857ff6f850bb9edf1843270de89c8d5a3352a2 Mon Sep 17 00:00:00 2001 From: Valentin Lorentz Date: Mon, 7 May 2012 15:16:20 +0000 Subject: [PATCH 232/243] Math: Block factorial() in calc functions. Signed-off-by: James McCoy --- plugins/Math/plugin.py | 1 + 1 file changed, 1 insertion(+) diff --git a/plugins/Math/plugin.py b/plugins/Math/plugin.py index a863d484b..4fdd38c78 100644 --- a/plugins/Math/plugin.py +++ b/plugins/Math/plugin.py @@ -103,6 +103,7 @@ class Math(callbacks.Plugin): _mathEnv['abs'] = abs _mathEnv['max'] = max _mathEnv['min'] = min + _mathEnv.pop('factorial') _mathRe = re.compile(r'((?:(? Date: Mon, 7 May 2012 17:52:02 +0200 Subject: [PATCH 233/243] Math: Allow 'factorial()' in icalc. Signed-off-by: James McCoy --- plugins/Math/plugin.py | 5 +++-- plugins/Math/test.py | 2 ++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/plugins/Math/plugin.py b/plugins/Math/plugin.py index 4fdd38c78..252744f35 100644 --- a/plugins/Math/plugin.py +++ b/plugins/Math/plugin.py @@ -103,7 +103,8 @@ class Math(callbacks.Plugin): _mathEnv['abs'] = abs _mathEnv['max'] = max _mathEnv['min'] = min - _mathEnv.pop('factorial') + _mathSafeEnv = dict([(x,y) for x,y in _mathEnv.items() + if x not in ['factorial']]) _mathRe = re.compile(r'((?:(? Date: Thu, 30 Aug 2012 15:34:28 -0400 Subject: [PATCH 234/243] Seen: check to see if the bot is in target channel before doing anything. Otherwise the check to see if user is in channel generated an error. --- plugins/Seen/plugin.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/plugins/Seen/plugin.py b/plugins/Seen/plugin.py index 4e3b1d95b..852fe665d 100644 --- a/plugins/Seen/plugin.py +++ b/plugins/Seen/plugin.py @@ -218,6 +218,9 @@ class Seen(callbacks.Plugin): saying. is only necessary if the message isn't sent on the channel itself. may contain * as a wildcard. """ + if channel not in irc.state.channels.keys(): + irc.error(format('I am not in %s.', channel)) + return if msg.nick not in irc.state.channels[channel].users: irc.error(format('You must be in %s to use this command.', channel)) return @@ -234,6 +237,9 @@ class Seen(callbacks.Plugin): and returns the last time user was active in . is only necessary if the message isn't sent on the channel itself. """ + if channel not in irc.state.channels.keys(): + irc.error(format('I am not in %s.', channel)) + return if msg.nick not in irc.state.channels[channel].users: irc.error(format('You must be in %s to use this command.', channel)) return @@ -270,6 +276,9 @@ class Seen(callbacks.Plugin): Returns the last thing said in . is only necessary if the message isn't sent in the channel itself. """ + if channel not in irc.state.channels.keys(): + irc.error(format('I am not in %s.', channel)) + return if msg.nick not in irc.state.channels[channel].users: irc.error(format('You must be in %s to use this command.', channel)) return @@ -298,6 +307,9 @@ class Seen(callbacks.Plugin): is only necessary if the message isn't sent in the channel itself. """ + if channel not in irc.state.channels.keys(): + irc.error(format('I am not in %s.', channel)) + return if msg.nick not in irc.state.channels[channel].users: irc.error(format('You must be in %s to use this command.', channel)) return @@ -309,6 +321,9 @@ class Seen(callbacks.Plugin): Returns the messages since last left the channel. """ + if channel not in irc.state.channels.keys(): + irc.error(format('I am not in %s.', channel)) + return if msg.nick not in irc.state.channels[channel].users: irc.error(format('You must be in %s to use this command.', channel)) return From 463f0c01845ca4173c5d3353c8f6113d0a20a40e Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Thu, 30 Aug 2012 15:58:51 -0400 Subject: [PATCH 235/243] core: make network.channels and channel keys private by default. Otherwise these can reveal secret information. --- src/conf.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/conf.py b/src/conf.py index 7517b32ae..d35f2d62e 100644 --- a/src/conf.py +++ b/src/conf.py @@ -267,12 +267,12 @@ def registerNetwork(name, password='', ssl=False): 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)) + """Determines what channels the bot will join only on %s.""" % name, private=True)) registerGlobalValue(network, 'ssl', registry.Boolean(ssl, """Determines whether the bot will attempt to connect with SSL sockets to %s.""" % name)) registerChannelValue(network.channels, 'key', registry.String('', - """Determines what key (if any) will be used to join the channel.""")) + """Determines what key (if any) will be used to join the channel.""", private=True)) return network # Let's fill our networks. From 40bdec92ca655052d7645198df2f728b6936114f Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Mon, 22 Oct 2012 11:24:28 -0400 Subject: [PATCH 236/243] Math: calc: coerce argument to ascii string. working with unicode errors on the translate() step. --- plugins/Math/plugin.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/plugins/Math/plugin.py b/plugins/Math/plugin.py index 252744f35..6efa6da03 100644 --- a/plugins/Math/plugin.py +++ b/plugins/Math/plugin.py @@ -160,6 +160,13 @@ class Math(callbacks.Plugin): crash to the bot with something like '10**10**10**10'. One consequence is that large values such as '10**24' might not be exact. """ + try: + text = str(text) + except UnicodeEncodeError: + irc.error("There's no reason you should have fancy non-ASCII " + "characters in your mathematical expression. " + "Please remove them.") + return if text != text.translate(utils.str.chars, '_[]'): irc.error('There\'s really no reason why you should have ' 'underscores or brackets in your mathematical ' From 5d6a3c5a46013112b102342409f004c1a3696c28 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Sun, 16 Dec 2012 23:53:13 -0500 Subject: [PATCH 237/243] String: cap maximum soundex length to 1024. --- plugins/String/plugin.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/plugins/String/plugin.py b/plugins/String/plugin.py index 93f8942e8..084f59a15 100644 --- a/plugins/String/plugin.py +++ b/plugins/String/plugin.py @@ -114,8 +114,11 @@ class String(callbacks.Plugin): Returns the Soundex hash to a given length. The length defaults to 4, since that's the standard length for a soundex hash. For unlimited - length, use 0. + length, use 0. Maximum length 1024. """ + if length > 1024: + irc.error("Maximum allowed length is 1024.") + return irc.reply(utils.str.soundex(text, length)) soundex = wrap(soundex, ['somethingWithoutSpaces', additional('int', 4)]) From bc0d16a4e173d3ae0b471a778a31003f3725b96d Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Fri, 3 May 2013 23:39:34 -0400 Subject: [PATCH 238/243] RSS: keep track of headlines over multiple feed fetches, with configurable expiration. This is better at avoiding repeats than just keeping the last fetch, since some feeds shuffle items around (like google news search). --- plugins/RSS/config.py | 5 ++++- plugins/RSS/plugin.py | 22 +++++++++++++--------- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/plugins/RSS/config.py b/plugins/RSS/config.py index 772aba1a2..597712a17 100644 --- a/plugins/RSS/config.py +++ b/plugins/RSS/config.py @@ -62,6 +62,7 @@ conf.registerGlobalValue(RSS, 'waitPeriod', registry.PositiveInteger(1800, """Indicates how many seconds the bot will wait between retrieving RSS feeds; requests made within this period will return cached results.""")) + conf.registerGlobalValue(RSS, 'feeds', FeedNames([], """Determines what feeds should be accessible as commands.""")) @@ -91,7 +92,9 @@ conf.registerChannelValue(RSS.announce, 'showLinks', registry.Boolean(False, """Determines whether the bot will list the link along with the title of the feed when a feed is automatically announced.""")) - +conf.registerGlobalValue(RSS.announce, 'cachePeriod', + registry.PositiveInteger(86400, """Maximum age of cached RSS headlines, + in seconds. Headline cache is used to avoid re-announcing old news.""")) # vim:set shiftwidth=4 softtabstop=4 expandtab textwidth=79: diff --git a/plugins/RSS/plugin.py b/plugins/RSS/plugin.py index 0617a1264..d60c2fe5c 100644 --- a/plugins/RSS/plugin.py +++ b/plugins/RSS/plugin.py @@ -70,6 +70,7 @@ class RSS(callbacks.Plugin): self.locks = {} self.lastRequest = {} self.cachedFeeds = {} + self.cachedHeadlines = {} self.gettingLockLock = threading.Lock() for name in self.registryValue('feeds'): self._registerFeed(name) @@ -161,9 +162,12 @@ class RSS(callbacks.Plugin): # Note that we're allowed to acquire this lock twice within the # same thread because it's an RLock and not just a normal Lock. self.acquireLock(url) + t = time.time() try: - oldresults = self.cachedFeeds[url] - oldheadlines = self.getHeadlines(oldresults) + #oldresults = self.cachedFeeds[url] + #oldheadlines = self.getHeadlines(oldresults) + oldheadlines = self.cachedHeadlines[url] + oldheadlines = filter(lambda x: t - x[2] < self.registryValue('announce.cachePeriod'), oldheadlines) except KeyError: oldheadlines = [] newresults = self.getFeed(url) @@ -176,11 +180,13 @@ class RSS(callbacks.Plugin): return def normalize(headline): return (tuple(headline[0].lower().split()), headline[1]) - oldheadlines = set(map(normalize, oldheadlines)) + oldheadlinesset = set(map(normalize, oldheadlines)) for (i, headline) in enumerate(newheadlines): - if normalize(headline) in oldheadlines: + if normalize(headline) in oldheadlinesset: newheadlines[i] = None newheadlines = filter(None, newheadlines) # Removes Nones. + oldheadlines.extend(newheadlines) + self.cachedHeadlines[url] = oldheadlines if newheadlines: def filter_whitelist(headline): v = False @@ -301,15 +307,13 @@ class RSS(callbacks.Plugin): def getHeadlines(self, feed): headlines = [] + t = time.time() conv = self._getConverter(feed) for d in feed['items']: if 'title' in d: title = conv(d['title']) - link = d.get('link') - if link: - headlines.append((title, link)) - else: - headlines.append((title, None)) + link = d.get('link') # defaults to None + headlines.append((title, link, t)) return headlines def makeFeedCommand(self, name, url): From 0b36a2997794de80cc1499a614db18cea8a94b66 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Fri, 3 May 2013 23:43:17 -0400 Subject: [PATCH 239/243] Owner: Prevent use of uninitialized variable. (pulled from Limnoria) --- plugins/Owner/plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/Owner/plugin.py b/plugins/Owner/plugin.py index c0a1ffe4d..e2897854f 100644 --- a/plugins/Owner/plugin.py +++ b/plugins/Owner/plugin.py @@ -456,7 +456,7 @@ class Owner(callbacks.Plugin): x = module.reload() try: module = plugin.loadPluginModule(name) - if hasattr(module, 'reload'): + if hasattr(module, 'reload') and 'x' in locals(): module.reload(x) for callback in callbacks: callback.die() From 02a2a056a41daa9e6bfe119d3778472b5c1dfa13 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Fri, 3 May 2013 23:44:27 -0400 Subject: [PATCH 240/243] Owner: Reload module configuration in reload. Pulled from Limnoria --- plugins/Owner/plugin.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plugins/Owner/plugin.py b/plugins/Owner/plugin.py index e2897854f..533d41902 100644 --- a/plugins/Owner/plugin.py +++ b/plugins/Owner/plugin.py @@ -458,6 +458,8 @@ class Owner(callbacks.Plugin): module = plugin.loadPluginModule(name) if hasattr(module, 'reload') and 'x' in locals(): module.reload(x) + if hasattr(module, 'config'): + reload(module.config) for callback in callbacks: callback.die() del callback From 78659113c13d66b8ad2decf467d1081c0f4fbb65 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Sat, 4 May 2013 00:01:52 -0400 Subject: [PATCH 241/243] RSS: add option to strip url redirects from headlines --- plugins/RSS/config.py | 3 +++ plugins/RSS/plugin.py | 7 ++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/plugins/RSS/config.py b/plugins/RSS/config.py index 597712a17..e4fe98faa 100644 --- a/plugins/RSS/config.py +++ b/plugins/RSS/config.py @@ -62,6 +62,9 @@ conf.registerGlobalValue(RSS, 'waitPeriod', registry.PositiveInteger(1800, """Indicates how many seconds the bot will wait between retrieving RSS feeds; requests made within this period will return cached results.""")) +conf.registerGlobalValue(RSS, 'stripRedirect', registry.Boolean( + True, """Determines whether the bot will attempt to strip url redirection + from headline links, by taking things after the last http://.""")) conf.registerGlobalValue(RSS, 'feeds', FeedNames([], """Determines what feeds should be accessible as diff --git a/plugins/RSS/plugin.py b/plugins/RSS/plugin.py index d60c2fe5c..8ef5a4a11 100644 --- a/plugins/RSS/plugin.py +++ b/plugins/RSS/plugin.py @@ -33,6 +33,7 @@ import time import socket import sgmllib import threading +import re import supybot.conf as conf import supybot.utils as utils @@ -143,9 +144,13 @@ class RSS(callbacks.Plugin): if self.registryValue(config, channel): for headline in headlines: if headline[1]: + if self.registryValue('stripRedirect'): + h = re.sub('^.*http://', 'http://', headline[1]) + else: + h = headline[1] newheadlines.append(format('%s %u', headline[0], - headline[1].encode('utf-8'))) + h.encode('utf-8'))) else: newheadlines.append(format('%s', headline[0])) else: From af1931b3db05fe435b2a248aabd7ce01b106012e Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Sun, 5 May 2013 11:23:15 -0400 Subject: [PATCH 242/243] RSS: add option display headline timestamp. --- plugins/RSS/config.py | 12 +++++++++++- plugins/RSS/plugin.py | 36 +++++++++++++++++++----------------- 2 files changed, 30 insertions(+), 18 deletions(-) diff --git a/plugins/RSS/config.py b/plugins/RSS/config.py index e4fe98faa..11d201441 100644 --- a/plugins/RSS/config.py +++ b/plugins/RSS/config.py @@ -74,6 +74,12 @@ conf.registerChannelValue(RSS, 'showLinks', along with the title of the feed when the rss command is called. supybot.plugins.RSS.announce.showLinks affects whether links will be listed when a feed is automatically announced.""")) +conf.registerChannelValue(RSS, 'showPubDate', + registry.Boolean(False, """Determines whether the bot will list the + publication datetime stamp along with the title of the feed when the rss + command is called. + supybot.plugins.RSS.announce.showPubDate affects whether this will be + listed when a feed is automatically announced.""")) conf.registerGlobalValue(RSS, 'defaultNumberOfHeadlines', registry.PositiveInteger(1, """Indicates how many headlines an rss feed will output by default, if no number is provided.""")) @@ -95,8 +101,12 @@ conf.registerChannelValue(RSS.announce, 'showLinks', registry.Boolean(False, """Determines whether the bot will list the link along with the title of the feed when a feed is automatically announced.""")) +conf.registerChannelValue(RSS.announce, 'showPubDate', + registry.Boolean(False, """Determines whether the bot will list the + publication datetime stamp along with the title of the feed when a feed + is automatically announced.""")) conf.registerGlobalValue(RSS.announce, 'cachePeriod', - registry.PositiveInteger(86400, """Maximum age of cached RSS headlines, + registry.PositiveInteger(604800, """Maximum age of cached RSS headlines, in seconds. Headline cache is used to avoid re-announcing old news.""")) diff --git a/plugins/RSS/plugin.py b/plugins/RSS/plugin.py index 8ef5a4a11..f8c3394f5 100644 --- a/plugins/RSS/plugin.py +++ b/plugins/RSS/plugin.py @@ -139,23 +139,24 @@ class RSS(callbacks.Plugin): self.releaseLock(url) time.sleep(0.1) # So other threads can run. - def buildHeadlines(self, headlines, channel, config='announce.showLinks'): + def buildHeadlines(self, headlines, channel, linksconfig='announce.showLinks', dateconfig='announce.showPubDate'): newheadlines = [] - if self.registryValue(config, channel): - for headline in headlines: + for headline in headlines: + link = '' + pubDate = '' + if self.registryValue(linksconfig, channel): if headline[1]: if self.registryValue('stripRedirect'): - h = re.sub('^.*http://', 'http://', headline[1]) + link = ' <%s>' % (re.sub('^.*http://', 'http://', headline[1]),) else: - h = headline[1] - newheadlines.append(format('%s %u', - headline[0], - h.encode('utf-8'))) - else: - newheadlines.append(format('%s', headline[0])) - else: - for headline in headlines: - newheadlines = [format('%s', h[0]) for h in headlines] + link = ' <%s>' % (headline[1],) + if self.registryValue(dateconfig, channel): + if headline[2]: + pubDate = ' [%s]' % (headline[2],) + newheadlines.append(format('%s%s%s', + headline[0], + link.encode('utf-8'), + pubDate)) return newheadlines def _newHeadlines(self, irc, channels, name, url): @@ -172,7 +173,7 @@ class RSS(callbacks.Plugin): #oldresults = self.cachedFeeds[url] #oldheadlines = self.getHeadlines(oldresults) oldheadlines = self.cachedHeadlines[url] - oldheadlines = filter(lambda x: t - x[2] < self.registryValue('announce.cachePeriod'), oldheadlines) + oldheadlines = filter(lambda x: t - x[3] < self.registryValue('announce.cachePeriod'), oldheadlines) except KeyError: oldheadlines = [] newresults = self.getFeed(url) @@ -317,8 +318,9 @@ class RSS(callbacks.Plugin): for d in feed['items']: if 'title' in d: title = conv(d['title']) - link = d.get('link') # defaults to None - headlines.append((title, link, t)) + link = d.get('link') + pubDate = d.get('pubDate', d.get('updated')) + headlines.append((title, link, pubDate, t)) return headlines def makeFeedCommand(self, name, url): @@ -430,7 +432,7 @@ class RSS(callbacks.Plugin): if not headlines: irc.error('Couldn\'t get RSS feed.') return - headlines = self.buildHeadlines(headlines, channel, 'showLinks') + headlines = self.buildHeadlines(headlines, channel, 'showLinks', 'showPubDate') if n: headlines = headlines[:n] else: From 81c366a6bea7e172d6fe9d273b9dcd20c2243d12 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Sat, 11 May 2013 14:11:57 -0400 Subject: [PATCH 243/243] Web: create a cofigurable url whitelist Prevent various forms of abuse that result via the Web plugin, such as fetching or titling malicious content, or revealing bot IP. --- plugins/Web/config.py | 6 ++++++ plugins/Web/plugin.py | 28 ++++++++++++++++++++++++++++ plugins/Web/test.py | 19 ++++++++++++++++++- 3 files changed, 52 insertions(+), 1 deletion(-) diff --git a/plugins/Web/config.py b/plugins/Web/config.py index f22e0ad15..f7e3e7ea4 100644 --- a/plugins/Web/config.py +++ b/plugins/Web/config.py @@ -53,6 +53,12 @@ conf.registerChannelValue(Web, 'nonSnarfingRegexp', snarfed. Give the empty string if you have no URLs that you'd like to exclude from being snarfed.""")) +conf.registerGlobalValue(Web, 'urlWhitelist', + registry.SpaceSeparatedListOfStrings([], """If set, bot will only fetch data + from urls in the whitelist, i.e. starting with http://domain/optionalpath/. This will + apply to all commands that retrieve data from user-supplied URLs, + including fetch, headers, title, doctype.""")) + conf.registerGroup(Web, 'fetch') conf.registerGlobalValue(Web.fetch, 'maximum', registry.NonNegativeInteger(0, """Determines the maximum number of diff --git a/plugins/Web/plugin.py b/plugins/Web/plugin.py index bbfbed992..c43238b18 100644 --- a/plugins/Web/plugin.py +++ b/plugins/Web/plugin.py @@ -107,12 +107,28 @@ class Web(callbacks.PluginRegexp): titleSnarfer = urlSnarfer(titleSnarfer) titleSnarfer.__doc__ = utils.web._httpUrlRe + def _checkURLWhitelist(self, url): + if not self.registryValue('urlWhitelist'): + return True + passed = False + for wu in self.registryValue('urlWhitelist'): + if wu.endswith('/') and url.find(wu) == 0: + passed = True + break + if (not wu.endswith('/')) and (url.find(wu + '/') == 0 or url == wu): + passed = True + break + return passed + def headers(self, irc, msg, args, url): """ Returns the HTTP headers of . Only HTTP urls are valid, of course. """ + if not self._checkURLWhitelist(url): + irc.error("This url is not on the whitelist.") + return fd = utils.web.getUrlFd(url) try: s = ', '.join([format('%s: %s', k, v) @@ -129,6 +145,9 @@ class Web(callbacks.PluginRegexp): Returns the DOCTYPE string of . Only HTTP urls are valid, of course. """ + if not self._checkURLWhitelist(url): + irc.error("This url is not on the whitelist.") + return size = conf.supybot.protocols.http.peekSize() s = utils.web.getUrl(url, size=size) m = self._doctypeRe.search(s) @@ -145,6 +164,9 @@ class Web(callbacks.PluginRegexp): Returns the Content-Length header of . Only HTTP urls are valid, of course. """ + if not self._checkURLWhitelist(url): + irc.error("This url is not on the whitelist.") + return fd = utils.web.getUrlFd(url) try: try: @@ -168,6 +190,9 @@ class Web(callbacks.PluginRegexp): Returns the HTML ... of a URL. """ + if not self._checkURLWhitelist(url): + irc.error("This url is not on the whitelist.") + return size = conf.supybot.protocols.http.peekSize() text = utils.web.getUrl(url, size=size) parser = Title() @@ -231,6 +256,9 @@ class Web(callbacks.PluginRegexp): supybot.plugins.Web.fetch.maximum. If that configuration variable is set to 0, this command will be effectively disabled. """ + if not self._checkURLWhitelist(url): + irc.error("This url is not on the whitelist.") + return max = self.registryValue('fetch.maximum') if not max: irc.error('This command is disabled ' diff --git a/plugins/Web/test.py b/plugins/Web/test.py index 5d2d626fe..f759cedef 100644 --- a/plugins/Web/test.py +++ b/plugins/Web/test.py @@ -98,7 +98,7 @@ class WebTestCase(ChannelPluginTestCase): try: conf.supybot.plugins.Web.titleSnarfer.setValue(True) self.assertSnarfRegexp('http://microsoft.com/', - 'Microsoft Corporation') + 'Microsoft') finally: conf.supybot.plugins.Web.titleSnarfer.setValue(False) @@ -125,5 +125,22 @@ class WebTestCase(ChannelPluginTestCase): finally: conf.supybot.plugins.Web.nonSnarfingRegexp.set('') + def testWhitelist(self): + fm = conf.supybot.plugins.Web.fetch.maximum() + uw = conf.supybot.plugins.Web.urlWhitelist() + try: + conf.supybot.plugins.Web.fetch.maximum.set(1024) + self.assertNotError('web fetch http://fsf.org') + conf.supybot.plugins.Web.urlWhitelist.set('http://slashdot.org') + self.assertError('web fetch http://fsf.org') + self.assertError('wef title http://fsf.org') + self.assertError('web fetch http://slashdot.org.evildomain.com') + self.assertNotError('web fetch http://slashdot.org') + self.assertNotError('web fetch http://slashdot.org/recent') + conf.supybot.plugins.Web.urlWhitelist.set('http://slashdot.org http://fsf.org') + self.assertNotError('doctype http://fsf.org') + finally: + conf.supybot.plugins.Web.urlWhitelist.set('') + conf.supybot.plugins.Web.fetch.maximum.set(fm) # vim:set shiftwidth=4 softtabstop=4 expandtab textwidth=79: