From 3884ab605cb76f37ff6025d6a8e0763998e73d77 Mon Sep 17 00:00:00 2001
From: Ben Simner <benjsimner@gmail.com>
Date: Tue, 28 Mar 2017 16:26:41 +0100
Subject: [PATCH 01/13] Add a quote plugin with tests

Adds extra commands:
  - !quote <nick> [<pattern>]
  - !quote.list [<pattern>]                   # on a channel
  - !qote.list <channel> [<pattern>]  # in a privmsg
  - !quote.remove <id> [, <id>]*
---
 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 <nick> [<pattern>]: adds last quote that matches <pattern> 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 <nick> [<pattern>]: looks up quotes from <nick>"
+                                    " (optionally only those matching <pattern>)"))
+    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 [<pattern>]: 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 <channel> 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 <id> [, <id>]*: 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', '<Nick> 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', '<Nick> 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', '<Nick> 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', '<Nick> 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', '<Nick> 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', '<Nick> 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 - <Nick> 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 <benjsimner@gmail.com>
Date: Tue, 28 Mar 2017 18:04:43 +0100
Subject: [PATCH 02/13] Clean up format to include quoteId

Also adds `!quotes * <pattern>`, 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 <nick> [<pattern>]: adds last quote that matches <pattern> 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 <nick> [<pattern>]: looks up quotes from <nick>"
+    @Plugin.command('quotes', help=("quote [<nick> [<pattern>]]: looks up quotes from <nick>"
                                     " (optionally only those matching <pattern>)"))
     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 [<pattern>]: 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', '<Nick> 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', '<Nick> 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', '<Nick> 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', '<Nick> 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', '<Nick> 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', '<Nick> 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 - <Nick> data test')
+        self.assert_sent('NOTICE Other :(0) - #First - <Nick> 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 <benjsimner@gmail.com>
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 <nick> [<pattern>]: adds last quote that matches <pattern> to the database"))
-    def quote(self, e):
-        """Lookup the nick given
+    @Plugin.command('remember', help=("remember <nick> [<pattern>]: adds last quote that matches <pattern> 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 [<nick> [<pattern>]]: looks up quotes from <nick>"
+    @Plugin.command('quote', help=("quote [<nick> [<pattern>]]: looks up quotes from <nick>"
                                     " (optionally only those matching <pattern>)"))
-    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 [<pattern>]: looks up all quotes on the channel"))
+    @Plugin.command('quote.list', help=("quote.list [<pattern>]: 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 <id> [, <id>]*: removes quotes from the database"))
+    @Plugin.command('quote.remove', help=("quote.remove <id> [, <id>]*: 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 - <Nick> data test')
+        yield from self._recv_privmsg('Other!~user@host', '#Second', '!quote.list')
+        self.assert_sent('NOTICE Other :(0) - #Second - <Nick> 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 <benjsimner@gmail.com>
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 - <Nick> data test')
+        self.assert_sent('NOTICE Other :[0] - #Second - <Nick> data test')
 
     @failsafe
     @run_client

From e437f7cdb876f2db3149ddae5648323801994d97 Mon Sep 17 00:00:00 2001
From: Ben Simner <benjsimner@gmail.com>
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 [<nick> [<pattern>]]: looks up quotes from <nick>"
                                     " (optionally only those matching <pattern>)"))
@@ -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 <channel> argument in PRIVMSGs, see !help quotes.list')
+                return e.reply('No channel supplied. Syntax for privmsg version is !quote.list <channel> [<pattern>]')
 
             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 <benjsimner@gmail.com>
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 <benjsimner@gmail.com>
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 [<nick> [<pattern>]]: looks up quotes from <nick>"
                                     " (optionally only those matching <pattern>)"))
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] <Nick> test'
+        assert self.quote.format_quote(quote, show_id=False) == '<Nick> test'
+        assert self.quote.format_quote(quote, show_channel=True) == '[0] - #First - <Nick> test'
+        assert self.quote.format_quote(quote, show_channel=True, show_id=False) == '#First - <Nick> 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 "<Nick> 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 <benjsimner@gmail.com>
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] <Alan> some silly quote..."
+
+        >>> self.format_quote({'quoteId': 3}, show_channel=True, show_id=False)
+        "[1  ] - #test - <Alan> 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 <nick> [<pattern>]: adds last quote that matches <pattern> 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<nick>\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<nick>\S+)\s+(?P<pattern>.+)', 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 [<nick> [<pattern>]]: looks up quotes from <nick>"
-                                    " (optionally only those matching <pattern>)"))
+                                   " (optionally only those matching <pattern>)"))
     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<nick>\S+)', data)
+        if m:
+            return e.reply(self.find_a_quote(m.group('nick'), channel, None))
+
+        m = re.fullmatch(r'(?P<nick>\S+)\s+(?P<pattern>.+)', 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 [<pattern>]: 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 <channel> [<pattern>]')
 
-            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<channel>\S+)', data)
+            channel_and_pat = re.fullmatch(r'(?P<channel>\S+)\s+(?P<pattern>.+)', 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 <channel> [<pattern>]')
         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 <id> [, <id>]*: 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 <benjsimner@gmail.com>
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] <Alan> some silly quote..."
-
         >>> self.format_quote({'quoteId': 3}, show_channel=True, show_id=False)
         "[1  ] - #test - <Alan> silly quote"
         """

From 621e250277abba12ff14350b19f763b4f3df126d Mon Sep 17 00:00:00 2001
From: Ben Simner <benjsimner@gmail.com>
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] <Alan> some silly quote..."
-        >>> self.format_quote({'quoteId': 3}, show_channel=True, show_id=False)
-        "[1  ] - #test - <Alan> silly quote"
+        >>> self.format(show_channel=True, show_id=False)
+        "#test - <Alan> 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 <nick> [<pattern>]: adds last quote that matches <pattern> to the database"))
@@ -138,25 +162,23 @@ def remember(self, e):
 
         m = re.fullmatch(r'(?P<nick>\S+)', data)
         if m:
-            print('fullmatch nick!')
             return self.remember_quote(e, user_nick, m.group('nick'), channel, None)
 
         m = re.fullmatch(r'(?P<nick>\S+)\s+(?P<pattern>.+)', 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 [<nick> [<pattern>]]: looks up quotes from <nick>"
                                    " (optionally only those matching <pattern>)"))
@@ -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<nick>\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 [<pattern>]: 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] <Nick> test'
+        assert quote.format(show_id=False) == '<Nick> test'
+        assert quote.format(show_channel=True) == '[0] - #First - <Nick> test'
+        assert quote.format(show_channel=True, show_id=False) == '#First - <Nick> 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] <Nick> test'
-        assert self.quote.format_quote(quote, show_id=False) == '<Nick> test'
-        assert self.quote.format_quote(quote, show_channel=True) == '[0] - #First - <Nick> test'
-        assert self.quote.format_quote(quote, show_channel=True, show_id=False) == '#First - <Nick> 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 <benjsimner@gmail.com>
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 <alan.briolat@gmail.com>
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 "<Nick> 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 "<Nick> 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 - <Nick> data test')
+        await self._recv_privmsg('Other!~user@host', '#Second', '!quote.list')
+        self.bot_helper.assert_sent('NOTICE Other :[0] - #Second - <Nick> 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 <alan.briolat@gmail.com>
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 <nick> [<pattern>]: adds last quote that matches <pattern> to the database"))
+    @Plugin.command('remember',
+                    help="remember <nick> [<pattern>]: adds last quote that matches <pattern> 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 <channel> [<pattern>]')
         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!')