From 3884ab605cb76f37ff6025d6a8e0763998e73d77 Mon Sep 17 00:00:00 2001 From: Ben Simner Date: Tue, 28 Mar 2017 16:26:41 +0100 Subject: [PATCH 01/13] Add a quote plugin with tests Adds extra commands: - !quote [] - !quote.list [] # on a channel - !qote.list [] # in a privmsg - !quote.remove [, ]* --- src/csbot/plugins/quote.py | 265 +++++++++++++++++++++++++++++++++++++ src/csbot/util.py | 12 ++ tests/test_plugin_quote.py | 169 +++++++++++++++++++++++ 3 files changed, 446 insertions(+) create mode 100644 src/csbot/plugins/quote.py create mode 100644 tests/test_plugin_quote.py diff --git a/src/csbot/plugins/quote.py b/src/csbot/plugins/quote.py new file mode 100644 index 00000000..b7f92de5 --- /dev/null +++ b/src/csbot/plugins/quote.py @@ -0,0 +1,265 @@ +import re +import functools +import collections + +import pymongo +import requests + +from csbot.plugin import Plugin +from csbot.util import nick, subdict + +class Quote(Plugin): + """Attach channel specific quotes to a user + """ + + PLUGIN_DEPENDS = ['usertrack'] + + quotedb = Plugin.use('mongodb', collection='quotedb') + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.channel_logs = collections.defaultdict(functools.partial(collections.deque, maxlen=100)) + + def quote_from_id(self, quoteId): + """gets a quote with some `quoteId` from the database + returns None if no such quote exists + """ + return self.quotedb.find_one({'quoteId': quoteId}) + + def format_quote(self, q): + current = self.get_current_quote_id() + len_current = len(str(current)) + quoteId = str(q['quoteId']).ljust(len_current) + return '{quoteId} - {channel} - <{nick}> {message}'.format(quoteId=quoteId, + channel=q['channel'], + nick=q['nick'], + message=q['message']) + + def paste_quotes(self, quotes): + if len(quotes) > 5: + paste_content = '\n'.join(self.format_quote(q) for q in quotes) + req = requests.post('http://dpaste.com/api/v2/', {'content': paste_content}) + if req: + return req.content.decode('utf-8').strip() + + def set_current_quote_id(self, id): + """sets the last quote id + """ + self.quotedb.remove({'header': 'currentQuoteId'}) + self.quotedb.insert({'header': 'currentQuoteId', 'maxQuoteId': id}) + + def get_current_quote_id(self): + """gets the current maximum quote id + """ + id_dict = self.quotedb.find_one({'header': 'currentQuoteId'}) + if id_dict is not None: + current_id = id_dict['maxQuoteId'] + else: + current_id = -1 + + return current_id + + def insert_quote(self, udict): + """inserts a {'user': user, 'channel': channel, 'message': msg} + or {'account': accnt, 'channel': channel, 'message': msg} + quote into the database + """ + + id = self.get_current_quote_id() + sId = id + 1 + udict['quoteId'] = sId + self.quotedb.insert(udict) + self.set_current_quote_id(sId) + return sId + + def message_matches(self, msg, pattern=None): + """returns True if `msg` matches `pattern` + """ + if pattern is None: + return True + + return re.search(pattern, msg) is not None + + def quote_set(self, nick, channel, pattern=None): + """writes the last quote that matches `pattern` to the database + and returns its id + returns None if no match found + """ + user = self.identify_user(nick, channel) + + for udict in self.channel_logs[channel]: + if subdict(user, udict): + if self.message_matches(udict['message'], pattern=pattern): + return self.insert_quote(udict) + + return None + + def find_quotes(self, nick, channel, pattern=None): + """finds and yields all quotes from nick + on channel `channel` (optionally matching on `pattern`) + """ + user = self.identify_user(nick, channel) + for quote in self.quotedb.find(user, sort=[('quoteId', pymongo.ASCENDING)]): + if self.message_matches(quote['message'], pattern=pattern): + yield quote + + def quote_summary(self, channel, pattern=None, dpaste=True): + quotes = list(self.quotedb.find({'channel': channel}, sort=[('quoteId', pymongo.ASCENDING)])) + if not quotes: + if pattern: + yield 'Cannot find quotes for channel {} that match "{}"'.format(channel, pattern) + else: + yield 'Cannot find quotes for channel {}'.format(channel) + + return + + for q in quotes[:5]: + yield self.format_quote(q) + + if dpaste: + paste_link = self.paste_quotes(quotes) + if paste_link: + yield 'Full summary at: {}'.format(paste_link) + + @Plugin.command('quote', help=("quote []: adds last quote that matches to the database")) + def quote(self, e): + """Lookup the nick given + """ + data = e['data'].split(maxsplit=1) + + if len(data) < 1: + return e.reply('Expected more arguments, see !help quote') + + nick_ = data[0].strip() + + if len(data) == 1: + pattern = '' + else: + pattern = data[1].strip() + + res = self.quote_set(nick_, e['channel'], pattern) + + if res is None: + if pattern: + e.reply('Found no messages from {} found matching "{}"'.format(nick_, pattern)) + else: + e.reply('Unknown nick {}'.format(nick_)) + + @Plugin.command('quotes', help=("quote []: looks up quotes from " + " (optionally only those matching )")) + def quotes(self, e): + """Lookup the nick given + """ + data = e['data'].split(maxsplit=1) + channel = e['channel'] + + if len(data) < 1: + return e.reply('Expected arguments, see !help quote') + + nick_ = data[0].strip() + + if len(data) == 1: + pattern = '' + else: + pattern = data[1].strip() + + res = self.find_quotes(nick_, channel, pattern) + out = next(res, None) + if out is None: + e.reply('No quotes recorded for {}'.format(nick_)) + else: + e.reply('<{}> {}'.format(out['nick'], out['message'])) + + + @Plugin.command('quotes.list', help=("quotes.list []: looks up all quotes on the channel")) + def quoteslist(self, e): + """Lookup the nick given + """ + channel = e['channel'] + nick_ = nick(e['user']) + + if nick_ == channel: + # first argument must be a channel + data = e['data'].split(maxsplit=1) + if len(data) < 1: + return e.reply('Expected at least argument in PRIVMSGs, see !help quotes.list') + + quote_channel = data[0] + + if len(data) == 1: + pattern = None + else: + pattern = data[1] + + for line in self.quote_summary(quote_channel, pattern=pattern): + e.reply(line) + else: + pattern = e['data'] + + for line in self.quote_summary(channel, pattern=pattern): + self.bot.reply(nick_, line) + + @Plugin.command('quotes.remove', help=("quotes.remove [, ]*: removes quotes from the database")) + def quotes_remove(self, e): + """Lookup the given quotes and remove them from the database transcationally + """ + data = e['data'].split(',') + channel = e['channel'] + + if len(data) < 1: + return e.reply('Expected at least 1 quoteID to remove.') + + ids = [qId.strip() for qId in data] + invalid_ids = [] + quotes = [] + for id in ids: + if id == '-1': + # special case -1, to be the last + _id = self.quotedb.find_one({'channel': channel}, sort=[('quoteId', pymongo.DESCENDING)]) + if _id: + id = _id['quoteId'] + + try: + id = int(id) + except ValueError: + invalid_ids.append(id) + else: + q = self.quote_from_id(id) + if q: + quotes.append(q) + else: + invalid_ids.append(id) + + if invalid_ids: + str_invalid_ids = ', '.join(str(id) for id in invalid_ids) + return e.reply('Cannot find quotes with ids {ids} (request aborted)'.format(ids=str_invalid_ids)) + else: + for q in quotes: + self.quotedb.remove(q) + + @Plugin.hook('core.message.privmsg') + def log_privmsgs(self, e): + """Register privmsgs for a channel and stick them into the log for that channel + this is merely an in-memory deque, so won't survive across restarts/crashes + """ + msg = e['message'] + + channel = e['channel'] + user = nick(e['user']) + ident = self.identify_user(user, channel) + ident['message'] = msg + ident['nick'] = user # even for auth'd user, save their nick + self.channel_logs[channel].appendleft(ident) + + def identify_user(self, nick, channel): + """Identify a user: by account if authed, if not, by nick. Produces a dict + suitable for throwing at mongo.""" + + user = self.bot.plugins['usertrack'].get_user(nick) + + if user['account'] is not None: + return {'account': user['account'], + 'channel': channel} + else: + return {'nick': nick, + 'channel': channel} diff --git a/src/csbot/util.py b/src/csbot/util.py index 4c011672..4003bfe6 100644 --- a/src/csbot/util.py +++ b/src/csbot/util.py @@ -189,6 +189,18 @@ def ordinal(value): return ordval +def subdict(d1, d2): + """returns True if d1 is a "subset" of d2 + i.e. if forall keys k in d1, k in d2 and d1[k] == d2[k] + """ + for k1 in d1: + if k1 not in d2: + return False + if d1[k1] != d2[k1]: + return False + return True + + def pluralize(n, singular, plural): return '{0} {1}'.format(n, singular if n == 1 else plural) diff --git a/tests/test_plugin_quote.py b/tests/test_plugin_quote.py new file mode 100644 index 00000000..df10ac60 --- /dev/null +++ b/tests/test_plugin_quote.py @@ -0,0 +1,169 @@ +import functools +import unittest +import unittest.mock + +import mongomock + +from csbot.util import subdict +from csbot.test import BotTestCase, run_client + + +def failsafe(f): + """forces the test to fail if not using a mock + this prevents the tests from accidentally polluting a real database in the event of failure""" + @functools.wraps(f) + def decorator(self, *args, **kwargs): + assert isinstance(self.quote.quotedb, + mongomock.Collection), 'Not mocking MongoDB -- may be writing to actual database (!) (aborted test)' + return f(self, *args, **kwargs) + return decorator + +class TestQuotePlugin(BotTestCase): + CONFIG = """\ + [@bot] + plugins = mongodb usertrack quote + + [mongodb] + mode = mock + """ + + PLUGINS = ['quote'] + + def setUp(self): + super().setUp() + + if not isinstance(self.quote.paste_quotes, unittest.mock.Mock): + self.quote.paste_quotes = unittest.mock.MagicMock(wraps=self.quote.paste_quotes, return_value='N/A') + + self.quote.paste_quotes.reset_mock() + + def _recv_privmsg(self, name, channel, msg): + yield from self.client.line_received(':{} PRIVMSG {} :{}'.format(name, channel, msg)) + + @failsafe + def test_quote_empty(self): + assert list(self.quote.find_quotes('noQuotesForMe', '#anyChannel')) == [] + + @failsafe + @run_client + def test_client_quote_add(self): + yield from self._recv_privmsg('Nick!~user@host', '#First', 'test data') + yield from self._recv_privmsg('Other!~user@host', '#First', '!quote Nick') + yield from self._recv_privmsg('Nick!~user@host', '#First', '!quotes Nick') + self.assert_sent('NOTICE {} :{}'.format('#First', ' test data')) + + @failsafe + @run_client + def test_client_quote_add_pattern_find(self): + yield from self._recv_privmsg('Nick!~user@host', '#First', 'test data#1') + yield from self._recv_privmsg('Other!~user@host', '#First', '!quote Nick') + + yield from self._recv_privmsg('Nick!~user@host', '#First', 'test data#2') + yield from self._recv_privmsg('Other!~user@host', '#First', '!quote Nick') + + yield from self._recv_privmsg('Nick!~user@host', '#First', '!quotes Nick data#2') + self.assert_sent('NOTICE {} :{}'.format('#First', ' test data#2')) + + + @failsafe + @run_client + def test_client_quotes_not_exist(self): + yield from self._recv_privmsg('Nick!~user@host', '#First', '!quotes Nick') + self.assert_sent('NOTICE {} :{}'.format('#First', 'No quotes recorded for Nick')) + + @failsafe + @run_client + def test_client_quote_add_multi(self): + yield from self._recv_privmsg('Nick!~user@host', '#First', 'test data') + yield from self._recv_privmsg('Nick!~user@host', '#First', 'other data') + yield from self._recv_privmsg('Other!~user@host', '#First', '!quote Nick test') + yield from self._recv_privmsg('Nick!~user@host', '#First', '!quotes Nick') + self.assert_sent('NOTICE {} :{}'.format('#First', ' test data')) + + @failsafe + @run_client + def test_client_quote_channel_specific_logs(self): + yield from self._recv_privmsg('Nick!~user@host', '#First', 'test data') + yield from self._recv_privmsg('Nick!~user@host', '#First', 'other data') + + yield from self._recv_privmsg('Other!~user@host', '#Second', '!quote Nick') + self.assert_sent('NOTICE {} :{}'.format('#Second', 'Unknown nick Nick')) + + yield from self._recv_privmsg('Other!~user@host', '#Second', '!quotes Nick') + self.assert_sent('NOTICE {} :{}'.format('#Second', 'No quotes recorded for Nick')) + + @failsafe + @run_client + def test_client_quote_channel_specific_quotes(self): + yield from self._recv_privmsg('Nick!~user@host', '#First', 'test data') + yield from self._recv_privmsg('Nick!~user@host', '#Second', 'other data') + + yield from self._recv_privmsg('Other!~user@host', '#Second', '!quote Nick') + yield from self._recv_privmsg('Other!~user@host', '#Second', '!quotes Nick') + self.assert_sent('NOTICE {} :{}'.format('#Second', ' other data')) + + yield from self._recv_privmsg('Another!~user@host', '#First', '!quote Nick') + yield from self._recv_privmsg('Other!~user@host', '#First', '!quotes Nick') + self.assert_sent('NOTICE {} :{}'.format('#First', ' test data')) + + @failsafe + @run_client + def test_client_quote_channel_fill_logs(self): + for i in range(150): + yield from self._recv_privmsg('Nick!~user@host', '#First', 'test data#{}'.format(i)) + yield from self._recv_privmsg('Nick!~user@host', '#Second', 'other data#{}'.format(i)) + + yield from self._recv_privmsg('Other!~user@host', '#Second', '!quote Nick data#135') + yield from self._recv_privmsg('Other!~user@host', '#Second', '!quotes Nick') + self.assert_sent('NOTICE {} :{}'.format('#Second', ' other data#135')) + + @failsafe + @run_client + def test_client_quotes_format(self): + """make sure the format !quotes.list yields is readable and goes to the right place + """ + yield from self._recv_privmsg('Nick!~user@host', '#First', 'data test') + yield from self._recv_privmsg('Other!~user@host', '#First', '!quote Nick') + + yield from self._recv_privmsg('Other!~user@host', '#First', '!quotes.list') + self.assert_sent('NOTICE Other :0 - #First - data test') + + @failsafe + @run_client + def test_client_quotes_list(self): + """ensure the list !quotes.list sends is short and redirects to pastebin + """ + # stick some quotes in a thing + data = ['test data#{}'.format(i) for i in range(10)] + for msg in data: + yield from self._recv_privmsg('Nick!~user@host', '#First', msg) + yield from self._recv_privmsg('Other!~user@host', '#First', '!quote Nick') + + yield from self._recv_privmsg('Other!~user@host', '#First', '!quotes.list') + + quotes = [{'nick': 'Nick', 'channel': '#First', 'message': d, 'quoteId': i} for i, d in enumerate(data)] + msgs = ['NOTICE {channel} :{msg}'.format(channel='Other', msg=self.quote.format_quote(q)) for q in quotes] + self.assert_sent(msgs[:5]) + + # manually unroll the call args to map subdict over it + # so we can ignore the cruft mongo inserts + quote_calls = self.quote.paste_quotes.call_args + qarg, = quote_calls[0] # args + for quote, document in zip(quotes, qarg): + assert subdict(quote, document) + + @failsafe + @run_client + def test_client_quote_remove(self): + yield from self._recv_privmsg('Nick!~user@host', '#First', 'test data#1') + yield from self._recv_privmsg('Other!~user@host', '#First', '!quote Nick') + + yield from self._recv_privmsg('Nick!~user@host', '#First', 'test data#2') + yield from self._recv_privmsg('Other!~user@host', '#First', '!quote Nick') + + yield from self._recv_privmsg('Nick!~user@host', '#First', '!quotes.remove -1') + yield from self._recv_privmsg('Nick!~user@host', '#First', '!quotes.remove 0') + + yield from self._recv_privmsg('Nick!~user@host', '#First', '!quotes Nick') + self.assert_sent('NOTICE {} :{}'.format('#First', 'No quotes recorded for Nick')) + From 679efdbce985d4cce894bd1057ba9e5daa1b685e Mon Sep 17 00:00:00 2001 From: Ben Simner Date: Tue, 28 Mar 2017 18:04:43 +0100 Subject: [PATCH 02/13] Clean up format to include quoteId Also adds `!quotes * `, for channel-wide quoting. --- src/csbot/plugins/quote.py | 62 ++++++++++++++++++++++---------------- tests/test_plugin_quote.py | 43 ++++++++++++++++++++------ 2 files changed, 69 insertions(+), 36 deletions(-) diff --git a/src/csbot/plugins/quote.py b/src/csbot/plugins/quote.py index b7f92de5..10cdaf67 100644 --- a/src/csbot/plugins/quote.py +++ b/src/csbot/plugins/quote.py @@ -1,4 +1,5 @@ import re +import random import functools import collections @@ -26,21 +27,23 @@ def quote_from_id(self, quoteId): """ return self.quotedb.find_one({'quoteId': quoteId}) - def format_quote(self, q): + def format_quote(self, q, show_channel=False): current = self.get_current_quote_id() len_current = len(str(current)) quoteId = str(q['quoteId']).ljust(len_current) - return '{quoteId} - {channel} - <{nick}> {message}'.format(quoteId=quoteId, - channel=q['channel'], - nick=q['nick'], - message=q['message']) + fmt_channel = '({quoteId}) - {channel} - <{nick}> {message}' + fmt_nochannel = '({quoteId}) <{nick}> {message}' + fmt = fmt_channel if show_channel else fmt_nochannel + return fmt.format(quoteId=quoteId, channel=q['channel'], nick=q['nick'], message=q['message']) def paste_quotes(self, quotes): - if len(quotes) > 5: - paste_content = '\n'.join(self.format_quote(q) for q in quotes) - req = requests.post('http://dpaste.com/api/v2/', {'content': paste_content}) - if req: - return req.content.decode('utf-8').strip() + paste_content = '\n'.join(self.format_quote(q) for q in quotes[:100]) + if len(quotes) > 100: + paste_content = 'Latest 100 quotes:\n' + paste_content + + req = requests.post('http://dpaste.com/api/v2/', {'content': paste_content}) + if req: + return req.content.decode('utf-8').strip() def set_current_quote_id(self, id): """sets the last quote id @@ -98,7 +101,11 @@ def find_quotes(self, nick, channel, pattern=None): """finds and yields all quotes from nick on channel `channel` (optionally matching on `pattern`) """ - user = self.identify_user(nick, channel) + if nick == '*': + user = {'channel': channel} + else: + user = self.identify_user(nick, channel) + for quote in self.quotedb.find(user, sort=[('quoteId', pymongo.ASCENDING)]): if self.message_matches(quote['message'], pattern=pattern): yield quote @@ -114,12 +121,13 @@ def quote_summary(self, channel, pattern=None, dpaste=True): return for q in quotes[:5]: - yield self.format_quote(q) + yield self.format_quote(q, show_channel=True) if dpaste: - paste_link = self.paste_quotes(quotes) - if paste_link: - yield 'Full summary at: {}'.format(paste_link) + if len(quotes) > 5: + paste_link = self.paste_quotes(quotes) + if paste_link: + yield 'Full summary at: {}'.format(paste_link) @Plugin.command('quote', help=("quote []: adds last quote that matches to the database")) def quote(self, e): @@ -145,7 +153,7 @@ def quote(self, e): else: e.reply('Unknown nick {}'.format(nick_)) - @Plugin.command('quotes', help=("quote []: looks up quotes from " + @Plugin.command('quotes', help=("quote [ []]: looks up quotes from " " (optionally only those matching )")) def quotes(self, e): """Lookup the nick given @@ -154,22 +162,24 @@ def quotes(self, e): channel = e['channel'] if len(data) < 1: - return e.reply('Expected arguments, see !help quote') - - nick_ = data[0].strip() + nick_ = '*' + else: + nick_ = data[0].strip() - if len(data) == 1: + if len(data) <= 1: pattern = '' else: pattern = data[1].strip() - res = self.find_quotes(nick_, channel, pattern) - out = next(res, None) - if out is None: - e.reply('No quotes recorded for {}'.format(nick_)) + res = list(self.find_quotes(nick_, channel, pattern)) + if not res: + if nick_ == '*': + e.reply('No quotes recorded') + else: + e.reply('No quotes recorded for {}'.format(nick_)) else: - e.reply('<{}> {}'.format(out['nick'], out['message'])) - + out = random.choice(res) + e.reply(self.format_quote(out, show_channel=False)) @Plugin.command('quotes.list', help=("quotes.list []: looks up all quotes on the channel")) def quoteslist(self, e): diff --git a/tests/test_plugin_quote.py b/tests/test_plugin_quote.py index df10ac60..994d3fed 100644 --- a/tests/test_plugin_quote.py +++ b/tests/test_plugin_quote.py @@ -33,13 +33,17 @@ def setUp(self): super().setUp() if not isinstance(self.quote.paste_quotes, unittest.mock.Mock): - self.quote.paste_quotes = unittest.mock.MagicMock(wraps=self.quote.paste_quotes, return_value='N/A') + self.quote.paste_quotes = unittest.mock.MagicMock(wraps=self.quote.paste_quotes, return_value='') self.quote.paste_quotes.reset_mock() def _recv_privmsg(self, name, channel, msg): yield from self.client.line_received(':{} PRIVMSG {} :{}'.format(name, channel, msg)) + def assert_sent_quote(self, channel, quote_id, quoted_user, quoted_channel, quoted_text, show_channel=False): + quote = {'quoteId': quote_id, 'channel': quoted_channel, 'message': quoted_text, 'nick': quoted_user} + self.assert_sent('NOTICE {} :{}'.format(channel, self.quote.format_quote(quote))) + @failsafe def test_quote_empty(self): assert list(self.quote.find_quotes('noQuotesForMe', '#anyChannel')) == [] @@ -50,7 +54,7 @@ def test_client_quote_add(self): yield from self._recv_privmsg('Nick!~user@host', '#First', 'test data') yield from self._recv_privmsg('Other!~user@host', '#First', '!quote Nick') yield from self._recv_privmsg('Nick!~user@host', '#First', '!quotes Nick') - self.assert_sent('NOTICE {} :{}'.format('#First', ' test data')) + self.assert_sent_quote('#First', 0, 'Nick', '#First', 'test data') @failsafe @run_client @@ -62,8 +66,7 @@ def test_client_quote_add_pattern_find(self): yield from self._recv_privmsg('Other!~user@host', '#First', '!quote Nick') yield from self._recv_privmsg('Nick!~user@host', '#First', '!quotes Nick data#2') - self.assert_sent('NOTICE {} :{}'.format('#First', ' test data#2')) - + self.assert_sent_quote('#First', 1, 'Nick', '#First', 'test data#2') @failsafe @run_client @@ -78,7 +81,7 @@ def test_client_quote_add_multi(self): yield from self._recv_privmsg('Nick!~user@host', '#First', 'other data') yield from self._recv_privmsg('Other!~user@host', '#First', '!quote Nick test') yield from self._recv_privmsg('Nick!~user@host', '#First', '!quotes Nick') - self.assert_sent('NOTICE {} :{}'.format('#First', ' test data')) + self.assert_sent_quote('#First', 0, 'Nick', '#First', 'test data') @failsafe @run_client @@ -100,11 +103,11 @@ def test_client_quote_channel_specific_quotes(self): yield from self._recv_privmsg('Other!~user@host', '#Second', '!quote Nick') yield from self._recv_privmsg('Other!~user@host', '#Second', '!quotes Nick') - self.assert_sent('NOTICE {} :{}'.format('#Second', ' other data')) + self.assert_sent_quote('#Second', 0, 'Nick', '#Second', 'other data') yield from self._recv_privmsg('Another!~user@host', '#First', '!quote Nick') yield from self._recv_privmsg('Other!~user@host', '#First', '!quotes Nick') - self.assert_sent('NOTICE {} :{}'.format('#First', ' test data')) + self.assert_sent_quote('#First', 1, 'Nick', '#First', 'test data') @failsafe @run_client @@ -115,7 +118,7 @@ def test_client_quote_channel_fill_logs(self): yield from self._recv_privmsg('Other!~user@host', '#Second', '!quote Nick data#135') yield from self._recv_privmsg('Other!~user@host', '#Second', '!quotes Nick') - self.assert_sent('NOTICE {} :{}'.format('#Second', ' other data#135')) + self.assert_sent_quote('#Second', 0, 'Nick', '#Second', 'other data#135') @failsafe @run_client @@ -126,7 +129,7 @@ def test_client_quotes_format(self): yield from self._recv_privmsg('Other!~user@host', '#First', '!quote Nick') yield from self._recv_privmsg('Other!~user@host', '#First', '!quotes.list') - self.assert_sent('NOTICE Other :0 - #First - data test') + self.assert_sent('NOTICE Other :(0) - #First - data test') @failsafe @run_client @@ -142,7 +145,8 @@ def test_client_quotes_list(self): yield from self._recv_privmsg('Other!~user@host', '#First', '!quotes.list') quotes = [{'nick': 'Nick', 'channel': '#First', 'message': d, 'quoteId': i} for i, d in enumerate(data)] - msgs = ['NOTICE {channel} :{msg}'.format(channel='Other', msg=self.quote.format_quote(q)) for q in quotes] + msgs = ['NOTICE {channel} :{msg}'.format(channel='Other', + msg=self.quote.format_quote(q, show_channel=True)) for q in quotes] self.assert_sent(msgs[:5]) # manually unroll the call args to map subdict over it @@ -167,3 +171,22 @@ def test_client_quote_remove(self): yield from self._recv_privmsg('Nick!~user@host', '#First', '!quotes Nick') self.assert_sent('NOTICE {} :{}'.format('#First', 'No quotes recorded for Nick')) + @failsafe + @run_client + def test_client_quote_channelwide(self): + yield from self._recv_privmsg('Nick!~user@host', '#First', 'test data!') + yield from self._recv_privmsg('Other!~other@host', '#First', '!quote Nick') + yield from self._recv_privmsg('Other!~other@host', '#First', '!quotes') + self.assert_sent_quote('#First', 0, 'Nick', '#First', 'test data!') + + @failsafe + @run_client + def test_client_quote_channelwide_with_pattern(self): + yield from self._recv_privmsg('Nick!~user@host', '#First', 'test data!') + yield from self._recv_privmsg('Other!~other@host', '#First', '!quote Nick') + + yield from self._recv_privmsg('Other!~other@host', '#First', 'other data') + yield from self._recv_privmsg('Nick!~user@host', '#First', '!quote Other') + + yield from self._recv_privmsg('Other!~other@host', '#First', '!quotes * other') + self.assert_sent_quote('#First', 1, 'Other', '#First', 'other data') From 5df6db85f3ffd8484d6b4a8877102b7b95829f40 Mon Sep 17 00:00:00 2001 From: Ben Simner Date: Tue, 28 Mar 2017 18:59:51 +0100 Subject: [PATCH 03/13] Re-name some of the quote commands Does some re-naming !quote -> !remember and !quotes -> !quote, also limits the use of !quote.list and !quote.remove to only authenticated users with the correct permissions. --- src/csbot/plugins/quote.py | 26 ++++++---- tests/test_plugin_quote.py | 104 ++++++++++++++++++++++++------------- 2 files changed, 84 insertions(+), 46 deletions(-) diff --git a/src/csbot/plugins/quote.py b/src/csbot/plugins/quote.py index 10cdaf67..c176ab66 100644 --- a/src/csbot/plugins/quote.py +++ b/src/csbot/plugins/quote.py @@ -13,7 +13,7 @@ class Quote(Plugin): """Attach channel specific quotes to a user """ - PLUGIN_DEPENDS = ['usertrack'] + PLUGIN_DEPENDS = ['usertrack', 'auth'] quotedb = Plugin.use('mongodb', collection='quotedb') @@ -129,14 +129,14 @@ def quote_summary(self, channel, pattern=None, dpaste=True): if paste_link: yield 'Full summary at: {}'.format(paste_link) - @Plugin.command('quote', help=("quote []: adds last quote that matches to the database")) - def quote(self, e): - """Lookup the nick given + @Plugin.command('remember', help=("remember []: adds last quote that matches to the database")) + def remember(self, e): + """Remembers something said """ data = e['data'].split(maxsplit=1) if len(data) < 1: - return e.reply('Expected more arguments, see !help quote') + return e.reply('Expected more arguments, see !help remember') nick_ = data[0].strip() @@ -153,10 +153,10 @@ def quote(self, e): else: e.reply('Unknown nick {}'.format(nick_)) - @Plugin.command('quotes', help=("quote [ []]: looks up quotes from " + @Plugin.command('quote', help=("quote [ []]: looks up quotes from " " (optionally only those matching )")) - def quotes(self, e): - """Lookup the nick given + def quote(self, e): + """ Lookup quotes for the given channel/nick and outputs one """ data = e['data'].split(maxsplit=1) channel = e['channel'] @@ -181,13 +181,16 @@ def quotes(self, e): out = random.choice(res) e.reply(self.format_quote(out, show_channel=False)) - @Plugin.command('quotes.list', help=("quotes.list []: looks up all quotes on the channel")) + @Plugin.command('quote.list', help=("quote.list []: looks up all quotes on the channel")) def quoteslist(self, e): """Lookup the nick given """ channel = e['channel'] nick_ = nick(e['user']) + if not self.bot.plugins['auth'].check_or_error(e, 'quote', channel): + return + if nick_ == channel: # first argument must be a channel data = e['data'].split(maxsplit=1) @@ -209,13 +212,16 @@ def quoteslist(self, e): for line in self.quote_summary(channel, pattern=pattern): self.bot.reply(nick_, line) - @Plugin.command('quotes.remove', help=("quotes.remove [, ]*: removes quotes from the database")) + @Plugin.command('quote.remove', help=("quote.remove [, ]*: removes quotes from the database")) def quotes_remove(self, e): """Lookup the given quotes and remove them from the database transcationally """ data = e['data'].split(',') channel = e['channel'] + if not self.bot.plugins['auth'].check_or_error(e, 'quote', e['channel']): + return + if len(data) < 1: return e.reply('Expected at least 1 quoteID to remove.') diff --git a/tests/test_plugin_quote.py b/tests/test_plugin_quote.py index 994d3fed..73cd67f6 100644 --- a/tests/test_plugin_quote.py +++ b/tests/test_plugin_quote.py @@ -21,7 +21,11 @@ def decorator(self, *args, **kwargs): class TestQuotePlugin(BotTestCase): CONFIG = """\ [@bot] - plugins = mongodb usertrack quote + plugins = mongodb usertrack auth quote + + [auth] + nickaccount = #First:quote + otheraccount = #Second:quote [mongodb] mode = mock @@ -52,26 +56,26 @@ def test_quote_empty(self): @run_client def test_client_quote_add(self): yield from self._recv_privmsg('Nick!~user@host', '#First', 'test data') - yield from self._recv_privmsg('Other!~user@host', '#First', '!quote Nick') - yield from self._recv_privmsg('Nick!~user@host', '#First', '!quotes Nick') + yield from self._recv_privmsg('Other!~user@host', '#First', '!remember Nick') + yield from self._recv_privmsg('Nick!~user@host', '#First', '!quote Nick') self.assert_sent_quote('#First', 0, 'Nick', '#First', 'test data') @failsafe @run_client def test_client_quote_add_pattern_find(self): yield from self._recv_privmsg('Nick!~user@host', '#First', 'test data#1') - yield from self._recv_privmsg('Other!~user@host', '#First', '!quote Nick') + yield from self._recv_privmsg('Other!~user@host', '#First', '!remember Nick') yield from self._recv_privmsg('Nick!~user@host', '#First', 'test data#2') - yield from self._recv_privmsg('Other!~user@host', '#First', '!quote Nick') + yield from self._recv_privmsg('Other!~user@host', '#First', '!remember Nick') - yield from self._recv_privmsg('Nick!~user@host', '#First', '!quotes Nick data#2') + yield from self._recv_privmsg('Nick!~user@host', '#First', '!quote Nick data#2') self.assert_sent_quote('#First', 1, 'Nick', '#First', 'test data#2') @failsafe @run_client def test_client_quotes_not_exist(self): - yield from self._recv_privmsg('Nick!~user@host', '#First', '!quotes Nick') + yield from self._recv_privmsg('Nick!~user@host', '#First', '!quote Nick') self.assert_sent('NOTICE {} :{}'.format('#First', 'No quotes recorded for Nick')) @failsafe @@ -79,8 +83,8 @@ def test_client_quotes_not_exist(self): def test_client_quote_add_multi(self): yield from self._recv_privmsg('Nick!~user@host', '#First', 'test data') yield from self._recv_privmsg('Nick!~user@host', '#First', 'other data') - yield from self._recv_privmsg('Other!~user@host', '#First', '!quote Nick test') - yield from self._recv_privmsg('Nick!~user@host', '#First', '!quotes Nick') + yield from self._recv_privmsg('Other!~user@host', '#First', '!remember Nick test') + yield from self._recv_privmsg('Nick!~user@host', '#First', '!quote Nick') self.assert_sent_quote('#First', 0, 'Nick', '#First', 'test data') @failsafe @@ -89,10 +93,10 @@ def test_client_quote_channel_specific_logs(self): yield from self._recv_privmsg('Nick!~user@host', '#First', 'test data') yield from self._recv_privmsg('Nick!~user@host', '#First', 'other data') - yield from self._recv_privmsg('Other!~user@host', '#Second', '!quote Nick') + yield from self._recv_privmsg('Other!~user@host', '#Second', '!remember Nick') self.assert_sent('NOTICE {} :{}'.format('#Second', 'Unknown nick Nick')) - yield from self._recv_privmsg('Other!~user@host', '#Second', '!quotes Nick') + yield from self._recv_privmsg('Other!~user@host', '#Second', '!quote Nick') self.assert_sent('NOTICE {} :{}'.format('#Second', 'No quotes recorded for Nick')) @failsafe @@ -101,12 +105,12 @@ def test_client_quote_channel_specific_quotes(self): yield from self._recv_privmsg('Nick!~user@host', '#First', 'test data') yield from self._recv_privmsg('Nick!~user@host', '#Second', 'other data') + yield from self._recv_privmsg('Other!~user@host', '#Second', '!remember Nick') yield from self._recv_privmsg('Other!~user@host', '#Second', '!quote Nick') - yield from self._recv_privmsg('Other!~user@host', '#Second', '!quotes Nick') self.assert_sent_quote('#Second', 0, 'Nick', '#Second', 'other data') - yield from self._recv_privmsg('Another!~user@host', '#First', '!quote Nick') - yield from self._recv_privmsg('Other!~user@host', '#First', '!quotes Nick') + yield from self._recv_privmsg('Another!~user@host', '#First', '!remember Nick') + yield from self._recv_privmsg('Other!~user@host', '#First', '!quote Nick') self.assert_sent_quote('#First', 1, 'Nick', '#First', 'test data') @failsafe @@ -116,35 +120,40 @@ def test_client_quote_channel_fill_logs(self): yield from self._recv_privmsg('Nick!~user@host', '#First', 'test data#{}'.format(i)) yield from self._recv_privmsg('Nick!~user@host', '#Second', 'other data#{}'.format(i)) - yield from self._recv_privmsg('Other!~user@host', '#Second', '!quote Nick data#135') - yield from self._recv_privmsg('Other!~user@host', '#Second', '!quotes Nick') + yield from self._recv_privmsg('Other!~user@host', '#Second', '!remember Nick data#135') + yield from self._recv_privmsg('Other!~user@host', '#Second', '!quote Nick') self.assert_sent_quote('#Second', 0, 'Nick', '#Second', 'other data#135') @failsafe @run_client def test_client_quotes_format(self): - """make sure the format !quotes.list yields is readable and goes to the right place + """make sure the format !quote.list yields is readable and goes to the right place """ - yield from self._recv_privmsg('Nick!~user@host', '#First', 'data test') - yield from self._recv_privmsg('Other!~user@host', '#First', '!quote Nick') + yield from self.client.line_received(":Other!~other@otherhost ACCOUNT otheraccount") + + yield from self._recv_privmsg('Nick!~user@host', '#Second', 'data test') + yield from self._recv_privmsg('Other!~user@host', '#Second', '!remember Nick') - yield from self._recv_privmsg('Other!~user@host', '#First', '!quotes.list') - self.assert_sent('NOTICE Other :(0) - #First - data test') + yield from self._recv_privmsg('Other!~user@host', '#Second', '!quote.list') + self.assert_sent('NOTICE Other :(0) - #Second - data test') @failsafe @run_client def test_client_quotes_list(self): - """ensure the list !quotes.list sends is short and redirects to pastebin + """ensure the list !quote.list sends is short and redirects to pastebin """ + yield from self.client.line_received(":Nick!~user@host ACCOUNT nickaccount") + yield from self.client.line_received(":Other!~other@otherhost ACCOUNT otheraccount") + # stick some quotes in a thing data = ['test data#{}'.format(i) for i in range(10)] for msg in data: - yield from self._recv_privmsg('Nick!~user@host', '#First', msg) - yield from self._recv_privmsg('Other!~user@host', '#First', '!quote Nick') + yield from self._recv_privmsg('Nick!~user@host', '#Second', msg) + yield from self._recv_privmsg('Other!~user@host', '#Second', '!remember Nick') - yield from self._recv_privmsg('Other!~user@host', '#First', '!quotes.list') + yield from self._recv_privmsg('Other!~user@host', '#Second', '!quote.list') - quotes = [{'nick': 'Nick', 'channel': '#First', 'message': d, 'quoteId': i} for i, d in enumerate(data)] + quotes = [{'nick': 'Nick', 'channel': '#Second', 'message': d, 'quoteId': i} for i, d in enumerate(data)] msgs = ['NOTICE {channel} :{msg}'.format(channel='Other', msg=self.quote.format_quote(q, show_channel=True)) for q in quotes] self.assert_sent(msgs[:5]) @@ -159,34 +168,57 @@ def test_client_quotes_list(self): @failsafe @run_client def test_client_quote_remove(self): + yield from self.client.line_received(":Nick!~user@host ACCOUNT nickaccount") + yield from self._recv_privmsg('Nick!~user@host', '#First', 'test data#1') - yield from self._recv_privmsg('Other!~user@host', '#First', '!quote Nick') + yield from self._recv_privmsg('Other!~user@host', '#First', '!remember Nick') yield from self._recv_privmsg('Nick!~user@host', '#First', 'test data#2') - yield from self._recv_privmsg('Other!~user@host', '#First', '!quote Nick') + yield from self._recv_privmsg('Other!~user@host', '#First', '!remember Nick') - yield from self._recv_privmsg('Nick!~user@host', '#First', '!quotes.remove -1') - yield from self._recv_privmsg('Nick!~user@host', '#First', '!quotes.remove 0') + yield from self._recv_privmsg('Nick!~user@host', '#First', '!quote.remove -1') + yield from self._recv_privmsg('Nick!~user@host', '#First', '!quote.remove 0') - yield from self._recv_privmsg('Nick!~user@host', '#First', '!quotes Nick') + yield from self._recv_privmsg('Nick!~user@host', '#First', '!quote Nick') self.assert_sent('NOTICE {} :{}'.format('#First', 'No quotes recorded for Nick')) @failsafe + @run_client + def test_client_quote_remove_no_permission(self): + yield from self.client.line_received(":Other!~other@otherhost ACCOUNT otheraccount") + + yield from self._recv_privmsg('Nick!~user@host', '#First', 'test data#1') + yield from self._recv_privmsg('Other!~user@host', '#First', '!remember Nick') + yield from self._recv_privmsg('Other!~user@host', '#First', '!quote.remove -1') + + self.assert_sent('NOTICE {} :{}'.format('#First', 'error: otheraccount not authorised for #First:quote')) + + @failsafe + @run_client + def test_client_quote_list_no_permission(self): + yield from self.client.line_received(":Other!~other@otherhost ACCOUNT otheraccount") + + yield from self._recv_privmsg('Nick!~user@host', '#First', 'test data#1') + yield from self._recv_privmsg('Other!~user@host', '#First', '!remember Nick') + yield from self._recv_privmsg('Other!~user@host', '#First', '!quote.list') + + self.assert_sent('NOTICE {} :{}'.format('#First', 'error: otheraccount not authorised for #First:quote')) + @run_client def test_client_quote_channelwide(self): yield from self._recv_privmsg('Nick!~user@host', '#First', 'test data!') - yield from self._recv_privmsg('Other!~other@host', '#First', '!quote Nick') - yield from self._recv_privmsg('Other!~other@host', '#First', '!quotes') + yield from self._recv_privmsg('Other!~other@host', '#First', '!remember Nick') + yield from self._recv_privmsg('Other!~other@host', '#First', '!quote') self.assert_sent_quote('#First', 0, 'Nick', '#First', 'test data!') @failsafe @run_client def test_client_quote_channelwide_with_pattern(self): yield from self._recv_privmsg('Nick!~user@host', '#First', 'test data!') - yield from self._recv_privmsg('Other!~other@host', '#First', '!quote Nick') + yield from self._recv_privmsg('Other!~other@host', '#First', '!remember Nick') yield from self._recv_privmsg('Other!~other@host', '#First', 'other data') - yield from self._recv_privmsg('Nick!~user@host', '#First', '!quote Other') + yield from self._recv_privmsg('Nick!~user@host', '#First', '!remember Other') - yield from self._recv_privmsg('Other!~other@host', '#First', '!quotes * other') + yield from self._recv_privmsg('Other!~other@host', '#First', '!quote * other') self.assert_sent_quote('#First', 1, 'Other', '#First', 'other data') From d67df9180341ce3937c0f6c125d35ce9113297f9 Mon Sep 17 00:00:00 2001 From: Ben Simner Date: Tue, 28 Mar 2017 19:02:18 +0100 Subject: [PATCH 04/13] Change quoteId format to use [] rather than () --- src/csbot/plugins/quote.py | 6 +++--- tests/test_plugin_quote.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/csbot/plugins/quote.py b/src/csbot/plugins/quote.py index c176ab66..844018d0 100644 --- a/src/csbot/plugins/quote.py +++ b/src/csbot/plugins/quote.py @@ -30,9 +30,9 @@ def quote_from_id(self, quoteId): def format_quote(self, q, show_channel=False): current = self.get_current_quote_id() len_current = len(str(current)) - quoteId = str(q['quoteId']).ljust(len_current) - fmt_channel = '({quoteId}) - {channel} - <{nick}> {message}' - fmt_nochannel = '({quoteId}) <{nick}> {message}' + quoteId = str(q['quoteId']) if not show_channel else str(q['quoteId']).ljust(len_current) + fmt_channel = '[{quoteId}] - {channel} - <{nick}> {message}' + fmt_nochannel = '[{quoteId}] <{nick}> {message}' fmt = fmt_channel if show_channel else fmt_nochannel return fmt.format(quoteId=quoteId, channel=q['channel'], nick=q['nick'], message=q['message']) diff --git a/tests/test_plugin_quote.py b/tests/test_plugin_quote.py index 73cd67f6..b6dbd9eb 100644 --- a/tests/test_plugin_quote.py +++ b/tests/test_plugin_quote.py @@ -135,7 +135,7 @@ def test_client_quotes_format(self): yield from self._recv_privmsg('Other!~user@host', '#Second', '!remember Nick') yield from self._recv_privmsg('Other!~user@host', '#Second', '!quote.list') - self.assert_sent('NOTICE Other :(0) - #Second - data test') + self.assert_sent('NOTICE Other :[0] - #Second - data test') @failsafe @run_client From e437f7cdb876f2db3149ddae5648323801994d97 Mon Sep 17 00:00:00 2001 From: Ben Simner Date: Tue, 28 Mar 2017 19:25:08 +0100 Subject: [PATCH 05/13] Improve error messages --- src/csbot/plugins/quote.py | 16 ++++++++-------- tests/test_plugin_quote.py | 8 ++++---- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/csbot/plugins/quote.py b/src/csbot/plugins/quote.py index 844018d0..65b7ae49 100644 --- a/src/csbot/plugins/quote.py +++ b/src/csbot/plugins/quote.py @@ -114,9 +114,9 @@ def quote_summary(self, channel, pattern=None, dpaste=True): quotes = list(self.quotedb.find({'channel': channel}, sort=[('quoteId', pymongo.ASCENDING)])) if not quotes: if pattern: - yield 'Cannot find quotes for channel {} that match "{}"'.format(channel, pattern) + yield 'No quotes for channel {} that match "{}"'.format(channel, pattern) else: - yield 'Cannot find quotes for channel {}'.format(channel) + yield 'No quotes for channel {}'.format(channel) return @@ -149,9 +149,9 @@ def remember(self, e): if res is None: if pattern: - e.reply('Found no messages from {} found matching "{}"'.format(nick_, pattern)) + e.reply('No data for {} found matching "{}"'.format(nick_, pattern)) else: - e.reply('Unknown nick {}'.format(nick_)) + e.reply('No data for {}'.format(nick_)) @Plugin.command('quote', help=("quote [ []]: looks up quotes from " " (optionally only those matching )")) @@ -174,9 +174,9 @@ def quote(self, e): res = list(self.find_quotes(nick_, channel, pattern)) if not res: if nick_ == '*': - e.reply('No quotes recorded') + e.reply('No data') else: - e.reply('No quotes recorded for {}'.format(nick_)) + e.reply('No data for {}'.format(nick_)) else: out = random.choice(res) e.reply(self.format_quote(out, show_channel=False)) @@ -195,7 +195,7 @@ def quoteslist(self, e): # first argument must be a channel data = e['data'].split(maxsplit=1) if len(data) < 1: - return e.reply('Expected at least argument in PRIVMSGs, see !help quotes.list') + return e.reply('No channel supplied. Syntax for privmsg version is !quote.list []') quote_channel = data[0] @@ -223,7 +223,7 @@ def quotes_remove(self, e): return if len(data) < 1: - return e.reply('Expected at least 1 quoteID to remove.') + return e.reply('No quoteID supplied') ids = [qId.strip() for qId in data] invalid_ids = [] diff --git a/tests/test_plugin_quote.py b/tests/test_plugin_quote.py index b6dbd9eb..5387a45b 100644 --- a/tests/test_plugin_quote.py +++ b/tests/test_plugin_quote.py @@ -76,7 +76,7 @@ def test_client_quote_add_pattern_find(self): @run_client def test_client_quotes_not_exist(self): yield from self._recv_privmsg('Nick!~user@host', '#First', '!quote Nick') - self.assert_sent('NOTICE {} :{}'.format('#First', 'No quotes recorded for Nick')) + self.assert_sent('NOTICE {} :{}'.format('#First', 'No data for Nick')) @failsafe @run_client @@ -94,10 +94,10 @@ def test_client_quote_channel_specific_logs(self): yield from self._recv_privmsg('Nick!~user@host', '#First', 'other data') yield from self._recv_privmsg('Other!~user@host', '#Second', '!remember Nick') - self.assert_sent('NOTICE {} :{}'.format('#Second', 'Unknown nick Nick')) + self.assert_sent('NOTICE {} :{}'.format('#Second', 'No data for Nick')) yield from self._recv_privmsg('Other!~user@host', '#Second', '!quote Nick') - self.assert_sent('NOTICE {} :{}'.format('#Second', 'No quotes recorded for Nick')) + self.assert_sent('NOTICE {} :{}'.format('#Second', 'No data for Nick')) @failsafe @run_client @@ -180,7 +180,7 @@ def test_client_quote_remove(self): yield from self._recv_privmsg('Nick!~user@host', '#First', '!quote.remove 0') yield from self._recv_privmsg('Nick!~user@host', '#First', '!quote Nick') - self.assert_sent('NOTICE {} :{}'.format('#First', 'No quotes recorded for Nick')) + self.assert_sent('NOTICE {} :{}'.format('#First', 'No data for Nick')) @failsafe @run_client From 6271117f7cf427f94728f41f1f5a36a1c42b1b9b Mon Sep 17 00:00:00 2001 From: Ben Simner Date: Tue, 28 Mar 2017 19:40:31 +0100 Subject: [PATCH 06/13] Clean up !quote.list to make it look nicer --- src/csbot/plugins/quote.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/csbot/plugins/quote.py b/src/csbot/plugins/quote.py index 65b7ae49..45259006 100644 --- a/src/csbot/plugins/quote.py +++ b/src/csbot/plugins/quote.py @@ -37,7 +37,7 @@ def format_quote(self, q, show_channel=False): return fmt.format(quoteId=quoteId, channel=q['channel'], nick=q['nick'], message=q['message']) def paste_quotes(self, quotes): - paste_content = '\n'.join(self.format_quote(q) for q in quotes[:100]) + paste_content = '\n'.join(self.format_quote(q, show_channel=True) for q in quotes[:100]) if len(quotes) > 100: paste_content = 'Latest 100 quotes:\n' + paste_content @@ -111,7 +111,7 @@ def find_quotes(self, nick, channel, pattern=None): yield quote def quote_summary(self, channel, pattern=None, dpaste=True): - quotes = list(self.quotedb.find({'channel': channel}, sort=[('quoteId', pymongo.ASCENDING)])) + quotes = list(self.quotedb.find({'channel': channel}, sort=[('quoteId', pymongo.DESCENDING)])) if not quotes: if pattern: yield 'No quotes for channel {} that match "{}"'.format(channel, pattern) @@ -191,7 +191,7 @@ def quoteslist(self, e): if not self.bot.plugins['auth'].check_or_error(e, 'quote', channel): return - if nick_ == channel: + if channel == self.bot.nick: # first argument must be a channel data = e['data'].split(maxsplit=1) if len(data) < 1: @@ -248,7 +248,7 @@ def quotes_remove(self, e): if invalid_ids: str_invalid_ids = ', '.join(str(id) for id in invalid_ids) - return e.reply('Cannot find quotes with ids {ids} (request aborted)'.format(ids=str_invalid_ids)) + return e.reply('No quotes with id(s) {ids} (request aborted)'.format(ids=str_invalid_ids)) else: for q in quotes: self.quotedb.remove(q) From 4ad2f2edbea86ece3adda542e6cac4aa19dd483f Mon Sep 17 00:00:00 2001 From: Ben Simner Date: Tue, 28 Mar 2017 20:13:20 +0100 Subject: [PATCH 07/13] Add a reply on remembering Also adds tests for the formatter --- src/csbot/plugins/quote.py | 39 +++++++++++++++++++++++++++++--------- tests/test_plugin_quote.py | 16 ++++++++++++++++ 2 files changed, 46 insertions(+), 9 deletions(-) diff --git a/src/csbot/plugins/quote.py b/src/csbot/plugins/quote.py index 45259006..0ef2c091 100644 --- a/src/csbot/plugins/quote.py +++ b/src/csbot/plugins/quote.py @@ -27,13 +27,29 @@ def quote_from_id(self, quoteId): """ return self.quotedb.find_one({'quoteId': quoteId}) - def format_quote(self, q, show_channel=False): - current = self.get_current_quote_id() - len_current = len(str(current)) - quoteId = str(q['quoteId']) if not show_channel else str(q['quoteId']).ljust(len_current) - fmt_channel = '[{quoteId}] - {channel} - <{nick}> {message}' - fmt_nochannel = '[{quoteId}] <{nick}> {message}' - fmt = fmt_channel if show_channel else fmt_nochannel + def format_quote_id(self, quote_id, long=False): + if long: + current = self.get_current_quote_id() + len_current = len(str(current)) + + if current == -1: # no quotes yet + return str(quote_id) + return str(quote_id).ljust(len_current) + else: + return str(quote_id) + + def format_quote(self, q, show_channel=False, show_id=True): + quoteId = self.format_quote_id(q['quoteId'], long=show_channel) + + if show_channel: + fmt = '{channel} - <{nick}> {message}' + if show_id: + fmt = '[{quoteId}] - ' + fmt + else: + fmt = '<{nick}> {message}' + if show_id: + fmt = '[{quoteId}] ' + fmt + return fmt.format(quoteId=quoteId, channel=q['channel'], nick=q['nick'], message=q['message']) def paste_quotes(self, quotes): @@ -93,7 +109,8 @@ def quote_set(self, nick, channel, pattern=None): for udict in self.channel_logs[channel]: if subdict(user, udict): if self.message_matches(udict['message'], pattern=pattern): - return self.insert_quote(udict) + self.insert_quote(udict) + return udict return None @@ -134,6 +151,8 @@ def remember(self, e): """Remembers something said """ data = e['data'].split(maxsplit=1) + channel = e['channel'] + user_nick = nick(e['user']) if len(data) < 1: return e.reply('Expected more arguments, see !help remember') @@ -145,13 +164,15 @@ def remember(self, e): else: pattern = data[1].strip() - res = self.quote_set(nick_, e['channel'], pattern) + res = self.quote_set(nick_, channel, pattern) if res is None: if pattern: e.reply('No data for {} found matching "{}"'.format(nick_, pattern)) else: e.reply('No data for {}'.format(nick_)) + else: + self.bot.reply(user_nick, 'remembered "{}"'.format(self.format_quote(res, show_channel=False, show_id=False))) @Plugin.command('quote', help=("quote [ []]: looks up quotes from " " (optionally only those matching )")) diff --git a/tests/test_plugin_quote.py b/tests/test_plugin_quote.py index 5387a45b..be4d8419 100644 --- a/tests/test_plugin_quote.py +++ b/tests/test_plugin_quote.py @@ -48,6 +48,14 @@ def assert_sent_quote(self, channel, quote_id, quoted_user, quoted_channel, quot quote = {'quoteId': quote_id, 'channel': quoted_channel, 'message': quoted_text, 'nick': quoted_user} self.assert_sent('NOTICE {} :{}'.format(channel, self.quote.format_quote(quote))) + @failsafe + def test_quote_formatter(self): + quote = {'quoteId': 0, 'channel': '#First', 'message': 'test', 'nick': 'Nick'} + assert self.quote.format_quote(quote) == '[0] test' + assert self.quote.format_quote(quote, show_id=False) == ' test' + assert self.quote.format_quote(quote, show_channel=True) == '[0] - #First - test' + assert self.quote.format_quote(quote, show_channel=True, show_id=False) == '#First - test' + @failsafe def test_quote_empty(self): assert list(self.quote.find_quotes('noQuotesForMe', '#anyChannel')) == [] @@ -60,6 +68,13 @@ def test_client_quote_add(self): yield from self._recv_privmsg('Nick!~user@host', '#First', '!quote Nick') self.assert_sent_quote('#First', 0, 'Nick', '#First', 'test data') + @failsafe + @run_client + def test_client_quote_remember_send_privmsg(self): + yield from self._recv_privmsg('Nick!~user@host', '#First', 'test data') + yield from self._recv_privmsg('Other!~user@host', '#First', '!remember Nick') + self.assert_sent('NOTICE Other :remembered " test data"') + @failsafe @run_client def test_client_quote_add_pattern_find(self): @@ -154,6 +169,7 @@ def test_client_quotes_list(self): yield from self._recv_privmsg('Other!~user@host', '#Second', '!quote.list') quotes = [{'nick': 'Nick', 'channel': '#Second', 'message': d, 'quoteId': i} for i, d in enumerate(data)] + quotes = reversed(quotes) msgs = ['NOTICE {channel} :{msg}'.format(channel='Other', msg=self.quote.format_quote(q, show_channel=True)) for q in quotes] self.assert_sent(msgs[:5]) From a989eee0233ddf9ebb77ee8d600d4e3040facd28 Mon Sep 17 00:00:00 2001 From: Ben Simner Date: Sat, 30 Jun 2018 13:13:53 +0100 Subject: [PATCH 08/13] Reorganise quote plugin file to be more command-structured Each @command is now a dispatch to a simpler function based off a regexp cli definition. This could probably be formalised into a @command.group() decorator or something later. --- src/csbot/plugins/quote.py | 292 ++++++++++++++++++++++--------------- 1 file changed, 171 insertions(+), 121 deletions(-) diff --git a/src/csbot/plugins/quote.py b/src/csbot/plugins/quote.py index 0ef2c091..4a86e64b 100644 --- a/src/csbot/plugins/quote.py +++ b/src/csbot/plugins/quote.py @@ -27,48 +27,61 @@ def quote_from_id(self, quoteId): """ return self.quotedb.find_one({'quoteId': quoteId}) - def format_quote_id(self, quote_id, long=False): - if long: + def format_quote_id(self, quote_id, pad=False): + """Formats the quote_id as a string. + + Can ask for a long-form version, which pads and aligns, or a short version: + + >>> self.format_quote_id(3) + '3' + >>> self.format_quote_id(23, pad=True) + '23 ' + """ + + if not pad: + return str(quote_id) + else: current = self.get_current_quote_id() - len_current = len(str(current)) - if current == -1: # no quotes yet + if current == -1: # quote_id is the first quote return str(quote_id) - return str(quote_id).ljust(len_current) - else: - return str(quote_id) + + length = len(str(current)) + return '{:<{length}}'.format(quote_id, length=length) def format_quote(self, q, show_channel=False, show_id=True): - quoteId = self.format_quote_id(q['quoteId'], long=show_channel) + """ Formats a quote into a prettified string. - if show_channel: + >>> self.format_quote({'quoteId': 3}) + "[3] some silly quote..." + + >>> self.format_quote({'quoteId': 3}, show_channel=True, show_id=False) + "[1 ] - #test - silly quote" + """ + quote_id_fmt = self.format_quote_id(q['quoteId'], pad=show_channel) + + if show_channel and show_id: + fmt = '[{quoteId}] - {channel} - <{nick}> {message}' + elif show_channel and not show_id: fmt = '{channel} - <{nick}> {message}' - if show_id: - fmt = '[{quoteId}] - ' + fmt + elif not show_channel and show_id: + fmt = '[{quoteId}] <{nick}> {message}' else: fmt = '<{nick}> {message}' - if show_id: - fmt = '[{quoteId}] ' + fmt - return fmt.format(quoteId=quoteId, channel=q['channel'], nick=q['nick'], message=q['message']) - - def paste_quotes(self, quotes): - paste_content = '\n'.join(self.format_quote(q, show_channel=True) for q in quotes[:100]) - if len(quotes) > 100: - paste_content = 'Latest 100 quotes:\n' + paste_content - - req = requests.post('http://dpaste.com/api/v2/', {'content': paste_content}) - if req: - return req.content.decode('utf-8').strip() + return fmt.format(quoteId=quote_id_fmt, channel=q['channel'], nick=q['nick'], message=q['message']) def set_current_quote_id(self, id): - """sets the last quote id + """ Sets the last quote id + + We keep track of the latest quote ID (they're sequential) in the database + To update it we remove the old one and insert a new record. """ self.quotedb.remove({'header': 'currentQuoteId'}) self.quotedb.insert({'header': 'currentQuoteId', 'maxQuoteId': id}) def get_current_quote_id(self): - """gets the current maximum quote id + """ Gets the current maximum quote ID """ id_dict = self.quotedb.find_one({'header': 'currentQuoteId'}) if id_dict is not None: @@ -79,9 +92,11 @@ def get_current_quote_id(self): return current_id def insert_quote(self, udict): - """inserts a {'user': user, 'channel': channel, 'message': msg} - or {'account': accnt, 'channel': channel, 'message': msg} - quote into the database + """ Remember a quote by storing it in the database + + Inserts a {'user': user, 'channel': channel, 'message': msg} + or {'account': accnt, 'channel': channel, 'message': msg} + quote into the persistent storage. """ id = self.get_current_quote_id() @@ -92,7 +107,9 @@ def insert_quote(self, udict): return sId def message_matches(self, msg, pattern=None): - """returns True if `msg` matches `pattern` + """ Check whether the given message matches the given pattern + + If there is no pattern, it is treated as a wildcard and all messages match. """ if pattern is None: return True @@ -100,9 +117,7 @@ def message_matches(self, msg, pattern=None): return re.search(pattern, msg) is not None def quote_set(self, nick, channel, pattern=None): - """writes the last quote that matches `pattern` to the database - and returns its id - returns None if no match found + """ Insert the last matching quote from a user on a particular channel into the quotes database. """ user = self.identify_user(nick, channel) @@ -114,97 +129,87 @@ def quote_set(self, nick, channel, pattern=None): return None - def find_quotes(self, nick, channel, pattern=None): - """finds and yields all quotes from nick - on channel `channel` (optionally matching on `pattern`) - """ - if nick == '*': - user = {'channel': channel} - else: - user = self.identify_user(nick, channel) - - for quote in self.quotedb.find(user, sort=[('quoteId', pymongo.ASCENDING)]): - if self.message_matches(quote['message'], pattern=pattern): - yield quote - - def quote_summary(self, channel, pattern=None, dpaste=True): - quotes = list(self.quotedb.find({'channel': channel}, sort=[('quoteId', pymongo.DESCENDING)])) - if not quotes: - if pattern: - yield 'No quotes for channel {} that match "{}"'.format(channel, pattern) - else: - yield 'No quotes for channel {}'.format(channel) - - return - - for q in quotes[:5]: - yield self.format_quote(q, show_channel=True) - - if dpaste: - if len(quotes) > 5: - paste_link = self.paste_quotes(quotes) - if paste_link: - yield 'Full summary at: {}'.format(paste_link) - @Plugin.command('remember', help=("remember []: adds last quote that matches to the database")) def remember(self, e): - """Remembers something said + """ Remembers the last matching quote from a user """ - data = e['data'].split(maxsplit=1) + data = e['data'].strip() channel = e['channel'] user_nick = nick(e['user']) - if len(data) < 1: - return e.reply('Expected more arguments, see !help remember') - - nick_ = data[0].strip() + m = re.fullmatch(r'(?P\S+)', data) + if m: + print('fullmatch nick!') + return self.remember_quote(e, user_nick, m.group('nick'), channel, None) - if len(data) == 1: - pattern = '' - else: - pattern = data[1].strip() + m = re.fullmatch(r'(?P\S+)\s+(?P.+)', data) + if m: + print('fullmatch pat') + return self.remember_quote(e, user_nick, m.group('nick'), channel, m.group('pattern').strip()) - res = self.quote_set(nick_, channel, pattern) + e.reply('Invalid nick or pattern') + def remember_quote(self, e, user, nick, channel, pattern): + res = self.quote_set(nick, channel, pattern) if res is None: - if pattern: - e.reply('No data for {} found matching "{}"'.format(nick_, pattern)) + if pattern is not None: + e.reply(f'No data for {nick} found matching "{pattern}"') else: - e.reply('No data for {}'.format(nick_)) + e.reply( f'No data for {nick}') else: - self.bot.reply(user_nick, 'remembered "{}"'.format(self.format_quote(res, show_channel=False, show_id=False))) + self.bot.reply(user, 'remembered "{}"'.format(self.format_quote(res, show_id=False))) @Plugin.command('quote', help=("quote [ []]: looks up quotes from " - " (optionally only those matching )")) + " (optionally only those matching )")) def quote(self, e): """ Lookup quotes for the given channel/nick and outputs one """ - data = e['data'].split(maxsplit=1) + data = e['data'] channel = e['channel'] - if len(data) < 1: - nick_ = '*' - else: - nick_ = data[0].strip() + if data.strip() == '': + return e.reply(self.find_a_quote('*', channel, None)) - if len(data) <= 1: - pattern = '' - else: - pattern = data[1].strip() + m = re.fullmatch(r'(?P\S+)', data) + if m: + return e.reply(self.find_a_quote(m.group('nick'), channel, None)) + + m = re.fullmatch(r'(?P\S+)\s+(?P.+)', data) + if m: + return e.reply(self.find_a_quote(m.group('nick'), channel, m.group('pattern'))) + + def find_a_quote(self, nick, channel, pattern): + """ Finds a random matching quote from a user on a specific channel - res = list(self.find_quotes(nick_, channel, pattern)) + Returns the formatted quote string + """ + res = list(self.find_quotes(nick, channel, pattern)) if not res: - if nick_ == '*': - e.reply('No data') + if nick == '*': + return 'No data' else: - e.reply('No data for {}'.format(nick_)) + return 'No data for {}'.format(nick) else: out = random.choice(res) - e.reply(self.format_quote(out, show_channel=False)) + return self.format_quote(out, show_channel=False) + + def find_quotes(self, nick, channel, pattern=None): + """ Finds and yields all quotes for a particular nick on a given channel + """ + if nick == '*': + user = {'channel': channel} + else: + user = self.identify_user(nick, channel) + + for quote in self.quotedb.find(user, sort=[('quoteId', pymongo.ASCENDING)]): + if self.message_matches(quote['message'], pattern=pattern): + yield quote @Plugin.command('quote.list', help=("quote.list []: looks up all quotes on the channel")) - def quoteslist(self, e): - """Lookup the nick given + def quote_list(self, e): + """ Look for all quotes that match a given pattern in a channel + + This action pastes multiple lines and so needs authorization. """ channel = e['channel'] nick_ = nick(e['user']) @@ -215,23 +220,63 @@ def quoteslist(self, e): if channel == self.bot.nick: # first argument must be a channel data = e['data'].split(maxsplit=1) - if len(data) < 1: - return e.reply('No channel supplied. Syntax for privmsg version is !quote.list []') - quote_channel = data[0] + # TODO: use assignment expressions here when they come out + # https://www.python.org/dev/peps/pep-0572/ + just_channel = re.fullmatch(r'(?P\S+)', data) + channel_and_pat = re.fullmatch(r'(?P\S+)\s+(?P.+)', data) + if just_channel: + return self.reply_with_summary(nick_, just_channel.group('channel'), None) + elif channel_and_pat: + return self.reply_with_summary(nick_, channel_and_pat.group('channel'), channel_and_pat.group('pattern')) - if len(data) == 1: - pattern = None - else: - pattern = data[1] - - for line in self.quote_summary(quote_channel, pattern=pattern): - e.reply(line) + return e.reply('Invalid command. Syntax in privmsg is !quote.list []') else: pattern = e['data'] + return self.reply_with_summary(nick_, channel, pattern) + + def reply_with_summary(self, to, channel, pattern): + """ Helper to list all quotes for a summary paste. + """ + for line in self.quote_summary(channel, pattern=pattern): + self.bot.reply(to, line) + + def quote_summary(self, channel, pattern=None, dpaste=True): + """ Search through all quotes for a channel and optionally paste a list of them + + Returns the last 5 matching quotes only, the remainder are added to a pastebin. + """ + quotes = list(self.quotedb.find({'channel': channel}, sort=[('quoteId', pymongo.DESCENDING)])) + if not quotes: + if pattern: + yield 'No quotes for channel {} that match "{}"'.format(channel, pattern) + else: + yield 'No quotes for channel {}'.format(channel) + + return + + for q in quotes[:5]: + yield self.format_quote(q, show_channel=True) - for line in self.quote_summary(channel, pattern=pattern): - self.bot.reply(nick_, line) + if dpaste and len(quotes) > 5: + paste_link = self.paste_quotes(quotes) + if paste_link: + yield 'Full summary at: {}'.format(paste_link) + else: + self.log.warn(f'Failed to upload full summary: {paste_link}') + + def paste_quotes(self, quotes): + """ Pastebins a the last 100 quotes and returns the url + """ + paste_content = '\n'.join(self.format_quote(q, show_channel=True) for q in quotes[:100]) + if len(quotes) > 100: + paste_content = 'Latest 100 quotes:\n' + paste_content + + req = requests.post('http://dpaste.com/api/v2/', {'content': paste_content}) + if req: + return req.content.decode('utf-8').strip() + + return req # return the failed request to handle error later @Plugin.command('quote.remove', help=("quote.remove [, ]*: removes quotes from the database")) def quotes_remove(self, e): @@ -248,7 +293,6 @@ def quotes_remove(self, e): ids = [qId.strip() for qId in data] invalid_ids = [] - quotes = [] for id in ids: if id == '-1': # special case -1, to be the last @@ -256,23 +300,29 @@ def quotes_remove(self, e): if _id: id = _id['quoteId'] - try: - id = int(id) - except ValueError: + if not self.remove_quote(id): invalid_ids.append(id) - else: - q = self.quote_from_id(id) - if q: - quotes.append(q) - else: - invalid_ids.append(id) if invalid_ids: str_invalid_ids = ', '.join(str(id) for id in invalid_ids) - return e.reply('No quotes with id(s) {ids} (request aborted)'.format(ids=str_invalid_ids)) + return e.reply('Could not remove quotes with IDs: {ids} (error: quote does not exist)'.format(ids=str_invalid_ids)) + + def remove_quote(self, quoteId): + """ Remove a given quote from the database + + Returns False if the quoteId is invalid or does not exist. + """ + + try: + id = int(quoteId) + except ValueError: + return False else: - for q in quotes: - self.quotedb.remove(q) + q = self.quote_from_id(id) + if not q: + return False + + self.quotedb.remove(q) @Plugin.hook('core.message.privmsg') def log_privmsgs(self, e): From 9a366807536b8d4f9ee3825834604e4114044946 Mon Sep 17 00:00:00 2001 From: Ben Simner Date: Sat, 30 Jun 2018 13:20:20 +0100 Subject: [PATCH 09/13] Remove redundant whitespace --- src/csbot/plugins/quote.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/csbot/plugins/quote.py b/src/csbot/plugins/quote.py index 4a86e64b..b3d52424 100644 --- a/src/csbot/plugins/quote.py +++ b/src/csbot/plugins/quote.py @@ -54,7 +54,6 @@ def format_quote(self, q, show_channel=False, show_id=True): >>> self.format_quote({'quoteId': 3}) "[3] some silly quote..." - >>> self.format_quote({'quoteId': 3}, show_channel=True, show_id=False) "[1 ] - #test - silly quote" """ From 621e250277abba12ff14350b19f763b4f3df126d Mon Sep 17 00:00:00 2001 From: Ben Simner Date: Sat, 30 Jun 2018 14:20:22 +0100 Subject: [PATCH 10/13] Break Quote plugin into record and database types Moving all the quote data into an attrs class makes it easier to work with over a dictionary. Then hiding the database implementation as a mixin made the plugin class itself easier to understand. --- src/csbot/plugins/quote.py | 220 +++++++++++++++++++------------------ tests/test_plugin_quote.py | 48 ++++++-- 2 files changed, 150 insertions(+), 118 deletions(-) diff --git a/src/csbot/plugins/quote.py b/src/csbot/plugins/quote.py index b3d52424..a54d6101 100644 --- a/src/csbot/plugins/quote.py +++ b/src/csbot/plugins/quote.py @@ -3,62 +3,29 @@ import functools import collections +import attr import pymongo import requests from csbot.plugin import Plugin from csbot.util import nick, subdict -class Quote(Plugin): - """Attach channel specific quotes to a user - """ - - PLUGIN_DEPENDS = ['usertrack', 'auth'] - - quotedb = Plugin.use('mongodb', collection='quotedb') - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.channel_logs = collections.defaultdict(functools.partial(collections.deque, maxlen=100)) - - def quote_from_id(self, quoteId): - """gets a quote with some `quoteId` from the database - returns None if no such quote exists - """ - return self.quotedb.find_one({'quoteId': quoteId}) - def format_quote_id(self, quote_id, pad=False): - """Formats the quote_id as a string. - - Can ask for a long-form version, which pads and aligns, or a short version: - - >>> self.format_quote_id(3) - '3' - >>> self.format_quote_id(23, pad=True) - '23 ' - """ +@attr.s +class QuoteRecord: + quote_id = attr.ib() + channel = attr.ib() + nick = attr.ib() + message = attr.ib() - if not pad: - return str(quote_id) - else: - current = self.get_current_quote_id() - - if current == -1: # quote_id is the first quote - return str(quote_id) - - length = len(str(current)) - return '{:<{length}}'.format(quote_id, length=length) - - def format_quote(self, q, show_channel=False, show_id=True): + def format(self, show_channel=False, show_id=True): """ Formats a quote into a prettified string. - >>> self.format_quote({'quoteId': 3}) + >>> self.format() "[3] some silly quote..." - >>> self.format_quote({'quoteId': 3}, show_channel=True, show_id=False) - "[1 ] - #test - silly quote" + >>> self.format(show_channel=True, show_id=False) + "#test - silly quote" """ - quote_id_fmt = self.format_quote_id(q['quoteId'], pad=show_channel) - if show_channel and show_id: fmt = '[{quoteId}] - {channel} - <{nick}> {message}' elif show_channel and not show_id: @@ -68,7 +35,31 @@ def format_quote(self, q, show_channel=False, show_id=True): else: fmt = '<{nick}> {message}' - return fmt.format(quoteId=quote_id_fmt, channel=q['channel'], nick=q['nick'], message=q['message']) + return fmt.format(quoteId=self.quote_id, channel=self.channel, nick=self.nick, message=self.message) + + def __bool__(self): + return True + + def to_udict(self): + return {'quoteId': self.quote_id, 'nick': self.nick, 'channel': self.channel, 'message': self.message} + + @classmethod + def from_udict(cls, udict): + return cls(quote_id=udict['quoteId'], + channel=udict['channel'], + nick=udict['nick'], + message=udict['message'], + ) + +class QuoteDB: + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def quote_from_id(self, quote_id): + """gets a quote with some `quoteId` from the database + returns None if no such quote exists + """ + return QuoteRecord.from_udict(self.quotedb.find_one({'quoteId': quote_id})) def set_current_quote_id(self, id): """ Sets the last quote id @@ -90,7 +81,7 @@ def get_current_quote_id(self): return current_id - def insert_quote(self, udict): + def insert_quote(self, quote): """ Remember a quote by storing it in the database Inserts a {'user': user, 'channel': channel, 'message': msg} @@ -100,32 +91,65 @@ def insert_quote(self, udict): id = self.get_current_quote_id() sId = id + 1 - udict['quoteId'] = sId - self.quotedb.insert(udict) + quote.quote_id = sId + self.quotedb.insert(quote.to_udict()) self.set_current_quote_id(sId) return sId - def message_matches(self, msg, pattern=None): - """ Check whether the given message matches the given pattern + def remove_quote(self, quote_id): + """ Remove a given quote from the database + + Returns False if the quoteId is invalid or does not exist. + """ + + try: + id = int(quote_id) + except ValueError: + return False + else: + q = self.quote_from_id(id) + if not q: + return False + + self.quotedb.remove({'quoteId': q.quote_id}) + + return True - If there is no pattern, it is treated as a wildcard and all messages match. + def find_quotes(self, nick=None, channel=None, pattern=None, direction=pymongo.ASCENDING): + """ Finds and yields all quotes for a particular nick on a given channel """ - if pattern is None: - return True + if nick is None or nick == '*': + user = {'channel': channel} + elif channel is not None: + user = {'channel': channel, 'nick': nick} + else: + user = {'nick': nick} + + for quote in self.quotedb.find(user, sort=[('quoteId', direction)]): + if message_matches(quote['message'], pattern=pattern): + yield QuoteRecord.from_udict(quote) + + +class Quote(Plugin, QuoteDB): + """Attach channel specific quotes to a user + """ + quotedb = Plugin.use('mongodb', collection='quotedb') + + PLUGIN_DEPENDS = ['usertrack', 'auth'] - return re.search(pattern, msg) is not None + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.channel_logs = collections.defaultdict(functools.partial(collections.deque, maxlen=100)) def quote_set(self, nick, channel, pattern=None): """ Insert the last matching quote from a user on a particular channel into the quotes database. """ user = self.identify_user(nick, channel) - for udict in self.channel_logs[channel]: - if subdict(user, udict): - if self.message_matches(udict['message'], pattern=pattern): - self.insert_quote(udict) - return udict - + for q in self.channel_logs[channel]: + if nick == q.nick and channel == q.channel and message_matches(q.message, pattern=pattern): + self.insert_quote(q) + return q return None @Plugin.command('remember', help=("remember []: adds last quote that matches to the database")) @@ -138,25 +162,23 @@ def remember(self, e): m = re.fullmatch(r'(?P\S+)', data) if m: - print('fullmatch nick!') return self.remember_quote(e, user_nick, m.group('nick'), channel, None) m = re.fullmatch(r'(?P\S+)\s+(?P.+)', data) if m: - print('fullmatch pat') return self.remember_quote(e, user_nick, m.group('nick'), channel, m.group('pattern').strip()) - e.reply('Invalid nick or pattern') + e.reply('Error: invalid command') def remember_quote(self, e, user, nick, channel, pattern): - res = self.quote_set(nick, channel, pattern) - if res is None: + quote = self.quote_set(nick, channel, pattern) + if quote is None: if pattern is not None: e.reply(f'No data for {nick} found matching "{pattern}"') else: e.reply( f'No data for {nick}') else: - self.bot.reply(user, 'remembered "{}"'.format(self.format_quote(res, show_id=False))) + self.bot.reply(user, 'remembered "{}"'.format(quote.format(show_id=False))) @Plugin.command('quote', help=("quote [ []]: looks up quotes from " " (optionally only those matching )")) @@ -167,7 +189,7 @@ def quote(self, e): channel = e['channel'] if data.strip() == '': - return e.reply(self.find_a_quote('*', channel, None)) + return e.reply(self.find_a_quote(None, channel, None)) m = re.fullmatch(r'(?P\S+)', data) if m: @@ -184,25 +206,13 @@ def find_a_quote(self, nick, channel, pattern): """ res = list(self.find_quotes(nick, channel, pattern)) if not res: - if nick == '*': + if nick is None: return 'No data' else: return 'No data for {}'.format(nick) else: out = random.choice(res) - return self.format_quote(out, show_channel=False) - - def find_quotes(self, nick, channel, pattern=None): - """ Finds and yields all quotes for a particular nick on a given channel - """ - if nick == '*': - user = {'channel': channel} - else: - user = self.identify_user(nick, channel) - - for quote in self.quotedb.find(user, sort=[('quoteId', pymongo.ASCENDING)]): - if self.message_matches(quote['message'], pattern=pattern): - yield quote + return out.format(show_channel=False) @Plugin.command('quote.list', help=("quote.list []: looks up all quotes on the channel")) def quote_list(self, e): @@ -245,7 +255,7 @@ def quote_summary(self, channel, pattern=None, dpaste=True): Returns the last 5 matching quotes only, the remainder are added to a pastebin. """ - quotes = list(self.quotedb.find({'channel': channel}, sort=[('quoteId', pymongo.DESCENDING)])) + quotes = list(self.find_quotes(nick=None, channel=channel, pattern=pattern, direction=pymongo.DESCENDING)) if not quotes: if pattern: yield 'No quotes for channel {} that match "{}"'.format(channel, pattern) @@ -255,7 +265,7 @@ def quote_summary(self, channel, pattern=None, dpaste=True): return for q in quotes[:5]: - yield self.format_quote(q, show_channel=True) + yield q.format(show_channel=True) if dpaste and len(quotes) > 5: paste_link = self.paste_quotes(quotes) @@ -267,7 +277,7 @@ def quote_summary(self, channel, pattern=None, dpaste=True): def paste_quotes(self, quotes): """ Pastebins a the last 100 quotes and returns the url """ - paste_content = '\n'.join(self.format_quote(q, show_channel=True) for q in quotes[:100]) + paste_content = '\n'.join(q.format(show_channel=True) for q in quotes[:100]) if len(quotes) > 100: paste_content = 'Latest 100 quotes:\n' + paste_content @@ -295,33 +305,20 @@ def quotes_remove(self, e): for id in ids: if id == '-1': # special case -1, to be the last - _id = self.quotedb.find_one({'channel': channel}, sort=[('quoteId', pymongo.DESCENDING)]) - if _id: - id = _id['quoteId'] + try: + q = next(self.find_quotes(nick=None, channel=channel, pattern=None, direction=pymongo.DESCENDING)) + except StopIteration: + invalid_ids.append(id) + continue + + id = q.quote_id if not self.remove_quote(id): invalid_ids.append(id) if invalid_ids: str_invalid_ids = ', '.join(str(id) for id in invalid_ids) - return e.reply('Could not remove quotes with IDs: {ids} (error: quote does not exist)'.format(ids=str_invalid_ids)) - - def remove_quote(self, quoteId): - """ Remove a given quote from the database - - Returns False if the quoteId is invalid or does not exist. - """ - - try: - id = int(quoteId) - except ValueError: - return False - else: - q = self.quote_from_id(id) - if not q: - return False - - self.quotedb.remove(q) + return e.reply('Error: could not remove quote(s) with ID: {ids}'.format(ids=str_invalid_ids)) @Plugin.hook('core.message.privmsg') def log_privmsgs(self, e): @@ -335,7 +332,8 @@ def log_privmsgs(self, e): ident = self.identify_user(user, channel) ident['message'] = msg ident['nick'] = user # even for auth'd user, save their nick - self.channel_logs[channel].appendleft(ident) + quote = QuoteRecord(None, channel, user, msg) + self.channel_logs[channel].appendleft(quote) def identify_user(self, nick, channel): """Identify a user: by account if authed, if not, by nick. Produces a dict @@ -349,3 +347,13 @@ def identify_user(self, nick, channel): else: return {'nick': nick, 'channel': channel} + +def message_matches(msg, pattern=None): + """ Check whether the given message matches the given pattern + + If there is no pattern, it is treated as a wildcard and all messages match. + """ + if pattern is None: + return True + + return re.search(pattern, msg) is not None \ No newline at end of file diff --git a/tests/test_plugin_quote.py b/tests/test_plugin_quote.py index be4d8419..6342b712 100644 --- a/tests/test_plugin_quote.py +++ b/tests/test_plugin_quote.py @@ -7,6 +7,8 @@ from csbot.util import subdict from csbot.test import BotTestCase, run_client +from csbot.plugins.quote import QuoteRecord + def failsafe(f): """forces the test to fail if not using a mock @@ -18,6 +20,25 @@ def decorator(self, *args, **kwargs): return f(self, *args, **kwargs) return decorator +class TestQuoteRecord: + def test_quote_formatter(self): + quote = QuoteRecord(quote_id=0, channel='#First', nick='Nick', message='test') + assert quote.format() == '[0] test' + assert quote.format(show_id=False) == ' test' + assert quote.format(show_channel=True) == '[0] - #First - test' + assert quote.format(show_channel=True, show_id=False) == '#First - test' + + def test_quote_deserialise(self): + udict = {'quoteId': 0, 'channel': '#First', 'message': 'test', 'nick': 'Nick'} + qr = QuoteRecord(quote_id=0, channel='#First', nick='Nick', message='test') + assert QuoteRecord.from_udict(udict) == qr + + def test_quote_serialise(self): + udict = {'quoteId': 0, 'channel': '#First', 'message': 'test', 'nick': 'Nick'} + qr = QuoteRecord(quote_id=0, channel='#First', nick='Nick', message='test') + assert qr.to_udict() == udict + + class TestQuotePlugin(BotTestCase): CONFIG = """\ [@bot] @@ -45,16 +66,11 @@ def _recv_privmsg(self, name, channel, msg): yield from self.client.line_received(':{} PRIVMSG {} :{}'.format(name, channel, msg)) def assert_sent_quote(self, channel, quote_id, quoted_user, quoted_channel, quoted_text, show_channel=False): - quote = {'quoteId': quote_id, 'channel': quoted_channel, 'message': quoted_text, 'nick': quoted_user} - self.assert_sent('NOTICE {} :{}'.format(channel, self.quote.format_quote(quote))) - - @failsafe - def test_quote_formatter(self): - quote = {'quoteId': 0, 'channel': '#First', 'message': 'test', 'nick': 'Nick'} - assert self.quote.format_quote(quote) == '[0] test' - assert self.quote.format_quote(quote, show_id=False) == ' test' - assert self.quote.format_quote(quote, show_channel=True) == '[0] - #First - test' - assert self.quote.format_quote(quote, show_channel=True, show_id=False) == '#First - test' + quote = QuoteRecord(quote_id=quote_id, + channel=quoted_channel, + nick=quoted_user, + message=quoted_text) + self.assert_sent('NOTICE {} :{}'.format(channel, quote.format())) @failsafe def test_quote_empty(self): @@ -168,10 +184,10 @@ def test_client_quotes_list(self): yield from self._recv_privmsg('Other!~user@host', '#Second', '!quote.list') - quotes = [{'nick': 'Nick', 'channel': '#Second', 'message': d, 'quoteId': i} for i, d in enumerate(data)] + quotes = [QuoteRecord(quote_id=i, channel='#Second', nick='Nick', message=d) for i, d in enumerate(data)] quotes = reversed(quotes) msgs = ['NOTICE {channel} :{msg}'.format(channel='Other', - msg=self.quote.format_quote(q, show_channel=True)) for q in quotes] + msg=q.format(show_channel=True)) for q in quotes] self.assert_sent(msgs[:5]) # manually unroll the call args to map subdict over it @@ -209,6 +225,14 @@ def test_client_quote_remove_no_permission(self): self.assert_sent('NOTICE {} :{}'.format('#First', 'error: otheraccount not authorised for #First:quote')) + @failsafe + @run_client + def test_client_quote_remove_no_quotes(self): + yield from self.client.line_received(":Nick!~user@host ACCOUNT nickaccount") + yield from self._recv_privmsg('Nick!~user@host', '#First', '!quote.remove -1') + + self.assert_sent('NOTICE {} :{}'.format('#First', 'Error: could not remove quote(s) with ID: -1')) + @failsafe @run_client def test_client_quote_list_no_permission(self): From 7a9e31fd14ce9282a914f0db1ec438643c6bedca Mon Sep 17 00:00:00 2001 From: Ben Simner Date: Sat, 30 Jun 2018 14:24:01 +0100 Subject: [PATCH 11/13] Remove `identify_users` I'm not 100% sure what this was doing. I think we don't need it anymore. --- src/csbot/plugins/quote.py | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/src/csbot/plugins/quote.py b/src/csbot/plugins/quote.py index a54d6101..c8f78c55 100644 --- a/src/csbot/plugins/quote.py +++ b/src/csbot/plugins/quote.py @@ -144,8 +144,6 @@ def __init__(self, *args, **kwargs): def quote_set(self, nick, channel, pattern=None): """ Insert the last matching quote from a user on a particular channel into the quotes database. """ - user = self.identify_user(nick, channel) - for q in self.channel_logs[channel]: if nick == q.nick and channel == q.channel and message_matches(q.message, pattern=pattern): self.insert_quote(q) @@ -329,25 +327,9 @@ def log_privmsgs(self, e): channel = e['channel'] user = nick(e['user']) - ident = self.identify_user(user, channel) - ident['message'] = msg - ident['nick'] = user # even for auth'd user, save their nick quote = QuoteRecord(None, channel, user, msg) self.channel_logs[channel].appendleft(quote) - def identify_user(self, nick, channel): - """Identify a user: by account if authed, if not, by nick. Produces a dict - suitable for throwing at mongo.""" - - user = self.bot.plugins['usertrack'].get_user(nick) - - if user['account'] is not None: - return {'account': user['account'], - 'channel': channel} - else: - return {'nick': nick, - 'channel': channel} - def message_matches(msg, pattern=None): """ Check whether the given message matches the given pattern From 388fea86733538a80a3a65393bec7272c33c2d0f Mon Sep 17 00:00:00 2001 From: Alan Briolat Date: Sat, 19 Feb 2022 20:47:41 +0000 Subject: [PATCH 12/13] quote: modernise tests, upgrade to pymongo 4.x, get tests passing --- src/csbot/plugins/quote.py | 9 +- tests/test_plugin_quote.py | 296 ++++++++++++++++--------------------- 2 files changed, 136 insertions(+), 169 deletions(-) diff --git a/src/csbot/plugins/quote.py b/src/csbot/plugins/quote.py index c8f78c55..31980d6a 100644 --- a/src/csbot/plugins/quote.py +++ b/src/csbot/plugins/quote.py @@ -67,8 +67,9 @@ def set_current_quote_id(self, id): We keep track of the latest quote ID (they're sequential) in the database To update it we remove the old one and insert a new record. """ - self.quotedb.remove({'header': 'currentQuoteId'}) - self.quotedb.insert({'header': 'currentQuoteId', 'maxQuoteId': id}) + self.quotedb.replace_one({'header': 'currentQuoteId'}, + {'header': 'currentQuoteId', 'maxQuoteId': id}, + upsert=True) def get_current_quote_id(self): """ Gets the current maximum quote ID @@ -92,7 +93,7 @@ def insert_quote(self, quote): id = self.get_current_quote_id() sId = id + 1 quote.quote_id = sId - self.quotedb.insert(quote.to_udict()) + self.quotedb.insert_one(quote.to_udict()) self.set_current_quote_id(sId) return sId @@ -111,7 +112,7 @@ def remove_quote(self, quote_id): if not q: return False - self.quotedb.remove({'quoteId': q.quote_id}) + self.quotedb.delete_one({'quoteId': q.quote_id}) return True diff --git a/tests/test_plugin_quote.py b/tests/test_plugin_quote.py index 6342b712..cf9d9bb4 100644 --- a/tests/test_plugin_quote.py +++ b/tests/test_plugin_quote.py @@ -1,25 +1,13 @@ -import functools -import unittest -import unittest.mock +import asyncio +from unittest import mock import mongomock - -from csbot.util import subdict -from csbot.test import BotTestCase, run_client +import pytest from csbot.plugins.quote import QuoteRecord +from csbot.util import subdict -def failsafe(f): - """forces the test to fail if not using a mock - this prevents the tests from accidentally polluting a real database in the event of failure""" - @functools.wraps(f) - def decorator(self, *args, **kwargs): - assert isinstance(self.quote.quotedb, - mongomock.Collection), 'Not mocking MongoDB -- may be writing to actual database (!) (aborted test)' - return f(self, *args, **kwargs) - return decorator - class TestQuoteRecord: def test_quote_formatter(self): quote = QuoteRecord(quote_id=0, channel='#First', nick='Nick', message='test') @@ -39,156 +27,145 @@ def test_quote_serialise(self): assert qr.to_udict() == udict -class TestQuotePlugin(BotTestCase): - CONFIG = """\ - [@bot] - plugins = mongodb usertrack auth quote - - [auth] - nickaccount = #First:quote - otheraccount = #Second:quote - - [mongodb] - mode = mock - """ - - PLUGINS = ['quote'] - - def setUp(self): - super().setUp() - - if not isinstance(self.quote.paste_quotes, unittest.mock.Mock): - self.quote.paste_quotes = unittest.mock.MagicMock(wraps=self.quote.paste_quotes, return_value='') - - self.quote.paste_quotes.reset_mock() - - def _recv_privmsg(self, name, channel, msg): - yield from self.client.line_received(':{} PRIVMSG {} :{}'.format(name, channel, msg)) +class TestQuotePlugin: + pytestmark = [ + pytest.mark.bot(config=""" + ["@bot"] + plugins = ["mongodb", "usertrack", "auth", "quote"] + + [auth] + nickaccount = "#First:quote" + otheraccount = "#Second:quote" + + [mongodb] + mode = "mock" + """), + pytest.mark.usefixtures("run_client"), + ] + + @pytest.fixture(autouse=True) + def quote_plugin(self, bot_helper): + self.bot_helper = bot_helper + self.quote = self.bot_helper['quote'] + + # Force the test to fail if not using a mock database. This prevents the tests from accidentally + # polluting a real database in the evnet of failure. + assert isinstance(self.quote.quotedb, mongomock.Collection), \ + 'Not mocking MongoDB -- may be writing to actual database (!) (aborted test)' + + self.mock_paste_quotes = mock.MagicMock(wraps=self.quote.paste_quotes, return_value='N/A') + with mock.patch.object(self.quote, 'paste_quotes', self.mock_paste_quotes): + yield + + async def _recv_line(self, line): + return await asyncio.wait(self.bot_helper.receive(line)) + + async def _recv_privmsg(self, name, channel, msg): + return await self._recv_line(f':{name} PRIVMSG {channel} :{msg}') def assert_sent_quote(self, channel, quote_id, quoted_user, quoted_channel, quoted_text, show_channel=False): quote = QuoteRecord(quote_id=quote_id, channel=quoted_channel, nick=quoted_user, message=quoted_text) - self.assert_sent('NOTICE {} :{}'.format(channel, quote.format())) + self.bot_helper.assert_sent('NOTICE {} :{}'.format(channel, quote.format())) - @failsafe def test_quote_empty(self): assert list(self.quote.find_quotes('noQuotesForMe', '#anyChannel')) == [] - @failsafe - @run_client - def test_client_quote_add(self): - yield from self._recv_privmsg('Nick!~user@host', '#First', 'test data') - yield from self._recv_privmsg('Other!~user@host', '#First', '!remember Nick') - yield from self._recv_privmsg('Nick!~user@host', '#First', '!quote Nick') + async def test_client_quote_add(self): + await self._recv_privmsg('Nick!~user@host', '#First', 'test data') + await self._recv_privmsg('Other!~user@host', '#First', '!remember Nick') + await self._recv_privmsg('Nick!~user@host', '#First', '!quote Nick') self.assert_sent_quote('#First', 0, 'Nick', '#First', 'test data') - @failsafe - @run_client - def test_client_quote_remember_send_privmsg(self): - yield from self._recv_privmsg('Nick!~user@host', '#First', 'test data') - yield from self._recv_privmsg('Other!~user@host', '#First', '!remember Nick') - self.assert_sent('NOTICE Other :remembered " test data"') + async def test_client_quote_remember_send_privmsg(self): + await self._recv_privmsg('Nick!~user@host', '#First', 'test data') + await self._recv_privmsg('Other!~user@host', '#First', '!remember Nick') + self.bot_helper.assert_sent('NOTICE Other :remembered " test data"') - @failsafe - @run_client - def test_client_quote_add_pattern_find(self): - yield from self._recv_privmsg('Nick!~user@host', '#First', 'test data#1') - yield from self._recv_privmsg('Other!~user@host', '#First', '!remember Nick') + async def test_client_quote_add_pattern_find(self): + await self._recv_privmsg('Nick!~user@host', '#First', 'test data#1') + await self._recv_privmsg('Other!~user@host', '#First', '!remember Nick') - yield from self._recv_privmsg('Nick!~user@host', '#First', 'test data#2') - yield from self._recv_privmsg('Other!~user@host', '#First', '!remember Nick') + await self._recv_privmsg('Nick!~user@host', '#First', 'test data#2') + await self._recv_privmsg('Other!~user@host', '#First', '!remember Nick') - yield from self._recv_privmsg('Nick!~user@host', '#First', '!quote Nick data#2') + await self._recv_privmsg('Nick!~user@host', '#First', '!quote Nick data#2') self.assert_sent_quote('#First', 1, 'Nick', '#First', 'test data#2') - @failsafe - @run_client - def test_client_quotes_not_exist(self): - yield from self._recv_privmsg('Nick!~user@host', '#First', '!quote Nick') - self.assert_sent('NOTICE {} :{}'.format('#First', 'No data for Nick')) - - @failsafe - @run_client - def test_client_quote_add_multi(self): - yield from self._recv_privmsg('Nick!~user@host', '#First', 'test data') - yield from self._recv_privmsg('Nick!~user@host', '#First', 'other data') - yield from self._recv_privmsg('Other!~user@host', '#First', '!remember Nick test') - yield from self._recv_privmsg('Nick!~user@host', '#First', '!quote Nick') + async def test_client_quotes_not_exist(self): + await self._recv_privmsg('Nick!~user@host', '#First', '!quote Nick') + self.bot_helper.assert_sent('NOTICE {} :{}'.format('#First', 'No data for Nick')) + + async def test_client_quote_add_multi(self): + await self._recv_privmsg('Nick!~user@host', '#First', 'test data') + await self._recv_privmsg('Nick!~user@host', '#First', 'other data') + await self._recv_privmsg('Other!~user@host', '#First', '!remember Nick test') + await self._recv_privmsg('Nick!~user@host', '#First', '!quote Nick') self.assert_sent_quote('#First', 0, 'Nick', '#First', 'test data') - @failsafe - @run_client - def test_client_quote_channel_specific_logs(self): - yield from self._recv_privmsg('Nick!~user@host', '#First', 'test data') - yield from self._recv_privmsg('Nick!~user@host', '#First', 'other data') + async def test_client_quote_channel_specific_logs(self): + await self._recv_privmsg('Nick!~user@host', '#First', 'test data') + await self._recv_privmsg('Nick!~user@host', '#First', 'other data') - yield from self._recv_privmsg('Other!~user@host', '#Second', '!remember Nick') - self.assert_sent('NOTICE {} :{}'.format('#Second', 'No data for Nick')) + await self._recv_privmsg('Other!~user@host', '#Second', '!remember Nick') + self.bot_helper.assert_sent('NOTICE {} :{}'.format('#Second', 'No data for Nick')) - yield from self._recv_privmsg('Other!~user@host', '#Second', '!quote Nick') - self.assert_sent('NOTICE {} :{}'.format('#Second', 'No data for Nick')) + await self._recv_privmsg('Other!~user@host', '#Second', '!quote Nick') + self.bot_helper.assert_sent('NOTICE {} :{}'.format('#Second', 'No data for Nick')) - @failsafe - @run_client - def test_client_quote_channel_specific_quotes(self): - yield from self._recv_privmsg('Nick!~user@host', '#First', 'test data') - yield from self._recv_privmsg('Nick!~user@host', '#Second', 'other data') + async def test_client_quote_channel_specific_quotes(self): + await self._recv_privmsg('Nick!~user@host', '#First', 'test data') + await self._recv_privmsg('Nick!~user@host', '#Second', 'other data') - yield from self._recv_privmsg('Other!~user@host', '#Second', '!remember Nick') - yield from self._recv_privmsg('Other!~user@host', '#Second', '!quote Nick') + await self._recv_privmsg('Other!~user@host', '#Second', '!remember Nick') + await self._recv_privmsg('Other!~user@host', '#Second', '!quote Nick') self.assert_sent_quote('#Second', 0, 'Nick', '#Second', 'other data') - yield from self._recv_privmsg('Another!~user@host', '#First', '!remember Nick') - yield from self._recv_privmsg('Other!~user@host', '#First', '!quote Nick') + await self._recv_privmsg('Another!~user@host', '#First', '!remember Nick') + await self._recv_privmsg('Other!~user@host', '#First', '!quote Nick') self.assert_sent_quote('#First', 1, 'Nick', '#First', 'test data') - @failsafe - @run_client - def test_client_quote_channel_fill_logs(self): + async def test_client_quote_channel_fill_logs(self): for i in range(150): - yield from self._recv_privmsg('Nick!~user@host', '#First', 'test data#{}'.format(i)) - yield from self._recv_privmsg('Nick!~user@host', '#Second', 'other data#{}'.format(i)) + await self._recv_privmsg('Nick!~user@host', '#First', 'test data#{}'.format(i)) + await self._recv_privmsg('Nick!~user@host', '#Second', 'other data#{}'.format(i)) - yield from self._recv_privmsg('Other!~user@host', '#Second', '!remember Nick data#135') - yield from self._recv_privmsg('Other!~user@host', '#Second', '!quote Nick') + await self._recv_privmsg('Other!~user@host', '#Second', '!remember Nick data#135') + await self._recv_privmsg('Other!~user@host', '#Second', '!quote Nick') self.assert_sent_quote('#Second', 0, 'Nick', '#Second', 'other data#135') - @failsafe - @run_client - def test_client_quotes_format(self): + async def test_client_quotes_format(self): """make sure the format !quote.list yields is readable and goes to the right place """ - yield from self.client.line_received(":Other!~other@otherhost ACCOUNT otheraccount") + await self._recv_line(":Other!~other@otherhost ACCOUNT otheraccount") - yield from self._recv_privmsg('Nick!~user@host', '#Second', 'data test') - yield from self._recv_privmsg('Other!~user@host', '#Second', '!remember Nick') + await self._recv_privmsg('Nick!~user@host', '#Second', 'data test') + await self._recv_privmsg('Other!~user@host', '#Second', '!remember Nick') - yield from self._recv_privmsg('Other!~user@host', '#Second', '!quote.list') - self.assert_sent('NOTICE Other :[0] - #Second - data test') + await self._recv_privmsg('Other!~user@host', '#Second', '!quote.list') + self.bot_helper.assert_sent('NOTICE Other :[0] - #Second - data test') - @failsafe - @run_client - def test_client_quotes_list(self): + async def test_client_quotes_list(self): """ensure the list !quote.list sends is short and redirects to pastebin """ - yield from self.client.line_received(":Nick!~user@host ACCOUNT nickaccount") - yield from self.client.line_received(":Other!~other@otherhost ACCOUNT otheraccount") + await self._recv_line(":Nick!~user@host ACCOUNT nickaccount") + await self._recv_line(":Other!~other@otherhost ACCOUNT otheraccount") # stick some quotes in a thing data = ['test data#{}'.format(i) for i in range(10)] for msg in data: - yield from self._recv_privmsg('Nick!~user@host', '#Second', msg) - yield from self._recv_privmsg('Other!~user@host', '#Second', '!remember Nick') + await self._recv_privmsg('Nick!~user@host', '#Second', msg) + await self._recv_privmsg('Other!~user@host', '#Second', '!remember Nick') - yield from self._recv_privmsg('Other!~user@host', '#Second', '!quote.list') + await self._recv_privmsg('Other!~user@host', '#Second', '!quote.list') quotes = [QuoteRecord(quote_id=i, channel='#Second', nick='Nick', message=d) for i, d in enumerate(data)] quotes = reversed(quotes) msgs = ['NOTICE {channel} :{msg}'.format(channel='Other', msg=q.format(show_channel=True)) for q in quotes] - self.assert_sent(msgs[:5]) + self.bot_helper.assert_sent(msgs[:5]) # manually unroll the call args to map subdict over it # so we can ignore the cruft mongo inserts @@ -197,68 +174,57 @@ def test_client_quotes_list(self): for quote, document in zip(quotes, qarg): assert subdict(quote, document) - @failsafe - @run_client - def test_client_quote_remove(self): - yield from self.client.line_received(":Nick!~user@host ACCOUNT nickaccount") + async def test_client_quote_remove(self): + await self._recv_line(":Nick!~user@host ACCOUNT nickaccount") - yield from self._recv_privmsg('Nick!~user@host', '#First', 'test data#1') - yield from self._recv_privmsg('Other!~user@host', '#First', '!remember Nick') + await self._recv_privmsg('Nick!~user@host', '#First', 'test data#1') + await self._recv_privmsg('Other!~user@host', '#First', '!remember Nick') - yield from self._recv_privmsg('Nick!~user@host', '#First', 'test data#2') - yield from self._recv_privmsg('Other!~user@host', '#First', '!remember Nick') + await self._recv_privmsg('Nick!~user@host', '#First', 'test data#2') + await self._recv_privmsg('Other!~user@host', '#First', '!remember Nick') - yield from self._recv_privmsg('Nick!~user@host', '#First', '!quote.remove -1') - yield from self._recv_privmsg('Nick!~user@host', '#First', '!quote.remove 0') + await self._recv_privmsg('Nick!~user@host', '#First', '!quote.remove -1') + await self._recv_privmsg('Nick!~user@host', '#First', '!quote.remove 0') - yield from self._recv_privmsg('Nick!~user@host', '#First', '!quote Nick') - self.assert_sent('NOTICE {} :{}'.format('#First', 'No data for Nick')) + await self._recv_privmsg('Nick!~user@host', '#First', '!quote Nick') + self.bot_helper.assert_sent('NOTICE {} :{}'.format('#First', 'No data for Nick')) - @failsafe - @run_client - def test_client_quote_remove_no_permission(self): - yield from self.client.line_received(":Other!~other@otherhost ACCOUNT otheraccount") + async def test_client_quote_remove_no_permission(self): + await self._recv_line(":Other!~other@otherhost ACCOUNT otheraccount") - yield from self._recv_privmsg('Nick!~user@host', '#First', 'test data#1') - yield from self._recv_privmsg('Other!~user@host', '#First', '!remember Nick') - yield from self._recv_privmsg('Other!~user@host', '#First', '!quote.remove -1') + await self._recv_privmsg('Nick!~user@host', '#First', 'test data#1') + await self._recv_privmsg('Other!~user@host', '#First', '!remember Nick') + await self._recv_privmsg('Other!~user@host', '#First', '!quote.remove -1') - self.assert_sent('NOTICE {} :{}'.format('#First', 'error: otheraccount not authorised for #First:quote')) + self.bot_helper.assert_sent('NOTICE {} :{}'.format('#First', 'error: otheraccount not authorised for #First:quote')) - @failsafe - @run_client - def test_client_quote_remove_no_quotes(self): - yield from self.client.line_received(":Nick!~user@host ACCOUNT nickaccount") - yield from self._recv_privmsg('Nick!~user@host', '#First', '!quote.remove -1') + async def test_client_quote_remove_no_quotes(self): + await self._recv_line(":Nick!~user@host ACCOUNT nickaccount") + await self._recv_privmsg('Nick!~user@host', '#First', '!quote.remove -1') - self.assert_sent('NOTICE {} :{}'.format('#First', 'Error: could not remove quote(s) with ID: -1')) + self.bot_helper.assert_sent('NOTICE {} :{}'.format('#First', 'Error: could not remove quote(s) with ID: -1')) - @failsafe - @run_client - def test_client_quote_list_no_permission(self): - yield from self.client.line_received(":Other!~other@otherhost ACCOUNT otheraccount") + async def test_client_quote_list_no_permission(self): + await self._recv_line(":Other!~other@otherhost ACCOUNT otheraccount") - yield from self._recv_privmsg('Nick!~user@host', '#First', 'test data#1') - yield from self._recv_privmsg('Other!~user@host', '#First', '!remember Nick') - yield from self._recv_privmsg('Other!~user@host', '#First', '!quote.list') + await self._recv_privmsg('Nick!~user@host', '#First', 'test data#1') + await self._recv_privmsg('Other!~user@host', '#First', '!remember Nick') + await self._recv_privmsg('Other!~user@host', '#First', '!quote.list') - self.assert_sent('NOTICE {} :{}'.format('#First', 'error: otheraccount not authorised for #First:quote')) + self.bot_helper.assert_sent('NOTICE {} :{}'.format('#First', 'error: otheraccount not authorised for #First:quote')) - @run_client - def test_client_quote_channelwide(self): - yield from self._recv_privmsg('Nick!~user@host', '#First', 'test data!') - yield from self._recv_privmsg('Other!~other@host', '#First', '!remember Nick') - yield from self._recv_privmsg('Other!~other@host', '#First', '!quote') + async def test_client_quote_channelwide(self): + await self._recv_privmsg('Nick!~user@host', '#First', 'test data!') + await self._recv_privmsg('Other!~other@host', '#First', '!remember Nick') + await self._recv_privmsg('Other!~other@host', '#First', '!quote') self.assert_sent_quote('#First', 0, 'Nick', '#First', 'test data!') - @failsafe - @run_client - def test_client_quote_channelwide_with_pattern(self): - yield from self._recv_privmsg('Nick!~user@host', '#First', 'test data!') - yield from self._recv_privmsg('Other!~other@host', '#First', '!remember Nick') + async def test_client_quote_channelwide_with_pattern(self): + await self._recv_privmsg('Nick!~user@host', '#First', 'test data!') + await self._recv_privmsg('Other!~other@host', '#First', '!remember Nick') - yield from self._recv_privmsg('Other!~other@host', '#First', 'other data') - yield from self._recv_privmsg('Nick!~user@host', '#First', '!remember Other') + await self._recv_privmsg('Other!~other@host', '#First', 'other data') + await self._recv_privmsg('Nick!~user@host', '#First', '!remember Other') - yield from self._recv_privmsg('Other!~other@host', '#First', '!quote * other') + await self._recv_privmsg('Other!~other@host', '#First', '!quote * other') self.assert_sent_quote('#First', 1, 'Other', '#First', 'other data') From 02af8e99d5966bc3609a8199cd8d22412dc87f87 Mon Sep 17 00:00:00 2001 From: Alan Briolat Date: Sat, 19 Feb 2022 20:55:34 +0000 Subject: [PATCH 13/13] quote: fix lint errors --- src/csbot/plugins/quote.py | 15 ++++++++++----- tests/test_plugin_quote.py | 24 ++++++++++++------------ 2 files changed, 22 insertions(+), 17 deletions(-) diff --git a/src/csbot/plugins/quote.py b/src/csbot/plugins/quote.py index 31980d6a..8e7e9cf7 100644 --- a/src/csbot/plugins/quote.py +++ b/src/csbot/plugins/quote.py @@ -8,7 +8,7 @@ import requests from csbot.plugin import Plugin -from csbot.util import nick, subdict +from csbot.util import nick @attr.s @@ -51,6 +51,7 @@ def from_udict(cls, udict): message=udict['message'], ) + class QuoteDB: def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -151,7 +152,8 @@ def quote_set(self, nick, channel, pattern=None): return q return None - @Plugin.command('remember', help=("remember []: adds last quote that matches to the database")) + @Plugin.command('remember', + help="remember []: adds last quote that matches to the database") def remember(self, e): """ Remembers the last matching quote from a user """ @@ -175,7 +177,7 @@ def remember_quote(self, e, user, nick, channel, pattern): if pattern is not None: e.reply(f'No data for {nick} found matching "{pattern}"') else: - e.reply( f'No data for {nick}') + e.reply(f'No data for {nick}') else: self.bot.reply(user, 'remembered "{}"'.format(quote.format(show_id=False))) @@ -236,7 +238,9 @@ def quote_list(self, e): if just_channel: return self.reply_with_summary(nick_, just_channel.group('channel'), None) elif channel_and_pat: - return self.reply_with_summary(nick_, channel_and_pat.group('channel'), channel_and_pat.group('pattern')) + return self.reply_with_summary(nick_, + channel_and_pat.group('channel'), + channel_and_pat.group('pattern')) return e.reply('Invalid command. Syntax in privmsg is !quote.list []') else: @@ -331,6 +335,7 @@ def log_privmsgs(self, e): quote = QuoteRecord(None, channel, user, msg) self.channel_logs[channel].appendleft(quote) + def message_matches(msg, pattern=None): """ Check whether the given message matches the given pattern @@ -339,4 +344,4 @@ def message_matches(msg, pattern=None): if pattern is None: return True - return re.search(pattern, msg) is not None \ No newline at end of file + return re.search(pattern, msg) is not None diff --git a/tests/test_plugin_quote.py b/tests/test_plugin_quote.py index cf9d9bb4..da4a4ff9 100644 --- a/tests/test_plugin_quote.py +++ b/tests/test_plugin_quote.py @@ -32,11 +32,11 @@ class TestQuotePlugin: pytest.mark.bot(config=""" ["@bot"] plugins = ["mongodb", "usertrack", "auth", "quote"] - + [auth] nickaccount = "#First:quote" otheraccount = "#Second:quote" - + [mongodb] mode = "mock" """), @@ -96,7 +96,7 @@ async def test_client_quote_add_pattern_find(self): async def test_client_quotes_not_exist(self): await self._recv_privmsg('Nick!~user@host', '#First', '!quote Nick') - self.bot_helper.assert_sent('NOTICE {} :{}'.format('#First', 'No data for Nick')) + self.bot_helper.assert_sent('NOTICE #First :No data for Nick') async def test_client_quote_add_multi(self): await self._recv_privmsg('Nick!~user@host', '#First', 'test data') @@ -110,10 +110,10 @@ async def test_client_quote_channel_specific_logs(self): await self._recv_privmsg('Nick!~user@host', '#First', 'other data') await self._recv_privmsg('Other!~user@host', '#Second', '!remember Nick') - self.bot_helper.assert_sent('NOTICE {} :{}'.format('#Second', 'No data for Nick')) + self.bot_helper.assert_sent('NOTICE #Second :No data for Nick') await self._recv_privmsg('Other!~user@host', '#Second', '!quote Nick') - self.bot_helper.assert_sent('NOTICE {} :{}'.format('#Second', 'No data for Nick')) + self.bot_helper.assert_sent('NOTICE #Second :No data for Nick') async def test_client_quote_channel_specific_quotes(self): await self._recv_privmsg('Nick!~user@host', '#First', 'test data') @@ -129,8 +129,8 @@ async def test_client_quote_channel_specific_quotes(self): async def test_client_quote_channel_fill_logs(self): for i in range(150): - await self._recv_privmsg('Nick!~user@host', '#First', 'test data#{}'.format(i)) - await self._recv_privmsg('Nick!~user@host', '#Second', 'other data#{}'.format(i)) + await self._recv_privmsg('Nick!~user@host', '#First', f'test data#{i}') + await self._recv_privmsg('Nick!~user@host', '#Second', f'other data#{i}') await self._recv_privmsg('Other!~user@host', '#Second', '!remember Nick data#135') await self._recv_privmsg('Other!~user@host', '#Second', '!quote Nick') @@ -154,7 +154,7 @@ async def test_client_quotes_list(self): await self._recv_line(":Other!~other@otherhost ACCOUNT otheraccount") # stick some quotes in a thing - data = ['test data#{}'.format(i) for i in range(10)] + data = [f'test data#{i}' for i in range(10)] for msg in data: await self._recv_privmsg('Nick!~user@host', '#Second', msg) await self._recv_privmsg('Other!~user@host', '#Second', '!remember Nick') @@ -187,7 +187,7 @@ async def test_client_quote_remove(self): await self._recv_privmsg('Nick!~user@host', '#First', '!quote.remove 0') await self._recv_privmsg('Nick!~user@host', '#First', '!quote Nick') - self.bot_helper.assert_sent('NOTICE {} :{}'.format('#First', 'No data for Nick')) + self.bot_helper.assert_sent('NOTICE #First :No data for Nick') async def test_client_quote_remove_no_permission(self): await self._recv_line(":Other!~other@otherhost ACCOUNT otheraccount") @@ -196,13 +196,13 @@ async def test_client_quote_remove_no_permission(self): await self._recv_privmsg('Other!~user@host', '#First', '!remember Nick') await self._recv_privmsg('Other!~user@host', '#First', '!quote.remove -1') - self.bot_helper.assert_sent('NOTICE {} :{}'.format('#First', 'error: otheraccount not authorised for #First:quote')) + self.bot_helper.assert_sent('NOTICE #First :error: otheraccount not authorised for #First:quote') async def test_client_quote_remove_no_quotes(self): await self._recv_line(":Nick!~user@host ACCOUNT nickaccount") await self._recv_privmsg('Nick!~user@host', '#First', '!quote.remove -1') - self.bot_helper.assert_sent('NOTICE {} :{}'.format('#First', 'Error: could not remove quote(s) with ID: -1')) + self.bot_helper.assert_sent('NOTICE #First :Error: could not remove quote(s) with ID: -1') async def test_client_quote_list_no_permission(self): await self._recv_line(":Other!~other@otherhost ACCOUNT otheraccount") @@ -211,7 +211,7 @@ async def test_client_quote_list_no_permission(self): await self._recv_privmsg('Other!~user@host', '#First', '!remember Nick') await self._recv_privmsg('Other!~user@host', '#First', '!quote.list') - self.bot_helper.assert_sent('NOTICE {} :{}'.format('#First', 'error: otheraccount not authorised for #First:quote')) + self.bot_helper.assert_sent('NOTICE #First :error: otheraccount not authorised for #First:quote') async def test_client_quote_channelwide(self): await self._recv_privmsg('Nick!~user@host', '#First', 'test data!')