diff --git a/csbot/plugins/quote.py b/csbot/plugins/quote.py index b3d52424..a54d6101 100644 --- a/csbot/plugins/quote.py +++ b/csbot/plugins/quote.py @@ -3,62 +3,29 @@ import functools import collections +import attr import pymongo import requests from csbot.plugin import Plugin from csbot.util import nick, subdict -class Quote(Plugin): - """Attach channel specific quotes to a user - """ - - PLUGIN_DEPENDS = ['usertrack', 'auth'] - - quotedb = Plugin.use('mongodb', collection='quotedb') - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.channel_logs = collections.defaultdict(functools.partial(collections.deque, maxlen=100)) - - def quote_from_id(self, quoteId): - """gets a quote with some `quoteId` from the database - returns None if no such quote exists - """ - return self.quotedb.find_one({'quoteId': quoteId}) - def format_quote_id(self, quote_id, pad=False): - """Formats the quote_id as a string. - - Can ask for a long-form version, which pads and aligns, or a short version: - - >>> self.format_quote_id(3) - '3' - >>> self.format_quote_id(23, pad=True) - '23 ' - """ +@attr.s +class QuoteRecord: + quote_id = attr.ib() + channel = attr.ib() + nick = attr.ib() + message = attr.ib() - if not pad: - return str(quote_id) - else: - current = self.get_current_quote_id() - - if current == -1: # quote_id is the first quote - return str(quote_id) - - length = len(str(current)) - return '{:<{length}}'.format(quote_id, length=length) - - def format_quote(self, q, show_channel=False, show_id=True): + def format(self, show_channel=False, show_id=True): """ Formats a quote into a prettified string. - >>> self.format_quote({'quoteId': 3}) + >>> self.format() "[3] some silly quote..." - >>> self.format_quote({'quoteId': 3}, show_channel=True, show_id=False) - "[1 ] - #test - silly quote" + >>> self.format(show_channel=True, show_id=False) + "#test - silly quote" """ - quote_id_fmt = self.format_quote_id(q['quoteId'], pad=show_channel) - if show_channel and show_id: fmt = '[{quoteId}] - {channel} - <{nick}> {message}' elif show_channel and not show_id: @@ -68,7 +35,31 @@ def format_quote(self, q, show_channel=False, show_id=True): else: fmt = '<{nick}> {message}' - return fmt.format(quoteId=quote_id_fmt, channel=q['channel'], nick=q['nick'], message=q['message']) + return fmt.format(quoteId=self.quote_id, channel=self.channel, nick=self.nick, message=self.message) + + def __bool__(self): + return True + + def to_udict(self): + return {'quoteId': self.quote_id, 'nick': self.nick, 'channel': self.channel, 'message': self.message} + + @classmethod + def from_udict(cls, udict): + return cls(quote_id=udict['quoteId'], + channel=udict['channel'], + nick=udict['nick'], + message=udict['message'], + ) + +class QuoteDB: + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def quote_from_id(self, quote_id): + """gets a quote with some `quoteId` from the database + returns None if no such quote exists + """ + return QuoteRecord.from_udict(self.quotedb.find_one({'quoteId': quote_id})) def set_current_quote_id(self, id): """ Sets the last quote id @@ -90,7 +81,7 @@ def get_current_quote_id(self): return current_id - def insert_quote(self, udict): + def insert_quote(self, quote): """ Remember a quote by storing it in the database Inserts a {'user': user, 'channel': channel, 'message': msg} @@ -100,32 +91,65 @@ def insert_quote(self, udict): id = self.get_current_quote_id() sId = id + 1 - udict['quoteId'] = sId - self.quotedb.insert(udict) + quote.quote_id = sId + self.quotedb.insert(quote.to_udict()) self.set_current_quote_id(sId) return sId - def message_matches(self, msg, pattern=None): - """ Check whether the given message matches the given pattern + def remove_quote(self, quote_id): + """ Remove a given quote from the database + + Returns False if the quoteId is invalid or does not exist. + """ + + try: + id = int(quote_id) + except ValueError: + return False + else: + q = self.quote_from_id(id) + if not q: + return False + + self.quotedb.remove({'quoteId': q.quote_id}) + + return True - If there is no pattern, it is treated as a wildcard and all messages match. + def find_quotes(self, nick=None, channel=None, pattern=None, direction=pymongo.ASCENDING): + """ Finds and yields all quotes for a particular nick on a given channel """ - if pattern is None: - return True + if nick is None or nick == '*': + user = {'channel': channel} + elif channel is not None: + user = {'channel': channel, 'nick': nick} + else: + user = {'nick': nick} + + for quote in self.quotedb.find(user, sort=[('quoteId', direction)]): + if message_matches(quote['message'], pattern=pattern): + yield QuoteRecord.from_udict(quote) + + +class Quote(Plugin, QuoteDB): + """Attach channel specific quotes to a user + """ + quotedb = Plugin.use('mongodb', collection='quotedb') + + PLUGIN_DEPENDS = ['usertrack', 'auth'] - return re.search(pattern, msg) is not None + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.channel_logs = collections.defaultdict(functools.partial(collections.deque, maxlen=100)) def quote_set(self, nick, channel, pattern=None): """ Insert the last matching quote from a user on a particular channel into the quotes database. """ user = self.identify_user(nick, channel) - for udict in self.channel_logs[channel]: - if subdict(user, udict): - if self.message_matches(udict['message'], pattern=pattern): - self.insert_quote(udict) - return udict - + for q in self.channel_logs[channel]: + if nick == q.nick and channel == q.channel and message_matches(q.message, pattern=pattern): + self.insert_quote(q) + return q return None @Plugin.command('remember', help=("remember []: adds last quote that matches to the database")) @@ -138,25 +162,23 @@ def remember(self, e): m = re.fullmatch(r'(?P\S+)', data) if m: - print('fullmatch nick!') return self.remember_quote(e, user_nick, m.group('nick'), channel, None) m = re.fullmatch(r'(?P\S+)\s+(?P.+)', data) if m: - print('fullmatch pat') return self.remember_quote(e, user_nick, m.group('nick'), channel, m.group('pattern').strip()) - e.reply('Invalid nick or pattern') + e.reply('Error: invalid command') def remember_quote(self, e, user, nick, channel, pattern): - res = self.quote_set(nick, channel, pattern) - if res is None: + quote = self.quote_set(nick, channel, pattern) + if quote is None: if pattern is not None: e.reply(f'No data for {nick} found matching "{pattern}"') else: e.reply( f'No data for {nick}') else: - self.bot.reply(user, 'remembered "{}"'.format(self.format_quote(res, show_id=False))) + self.bot.reply(user, 'remembered "{}"'.format(quote.format(show_id=False))) @Plugin.command('quote', help=("quote [ []]: looks up quotes from " " (optionally only those matching )")) @@ -167,7 +189,7 @@ def quote(self, e): channel = e['channel'] if data.strip() == '': - return e.reply(self.find_a_quote('*', channel, None)) + return e.reply(self.find_a_quote(None, channel, None)) m = re.fullmatch(r'(?P\S+)', data) if m: @@ -184,25 +206,13 @@ def find_a_quote(self, nick, channel, pattern): """ res = list(self.find_quotes(nick, channel, pattern)) if not res: - if nick == '*': + if nick is None: return 'No data' else: return 'No data for {}'.format(nick) else: out = random.choice(res) - return self.format_quote(out, show_channel=False) - - def find_quotes(self, nick, channel, pattern=None): - """ Finds and yields all quotes for a particular nick on a given channel - """ - if nick == '*': - user = {'channel': channel} - else: - user = self.identify_user(nick, channel) - - for quote in self.quotedb.find(user, sort=[('quoteId', pymongo.ASCENDING)]): - if self.message_matches(quote['message'], pattern=pattern): - yield quote + return out.format(show_channel=False) @Plugin.command('quote.list', help=("quote.list []: looks up all quotes on the channel")) def quote_list(self, e): @@ -245,7 +255,7 @@ def quote_summary(self, channel, pattern=None, dpaste=True): Returns the last 5 matching quotes only, the remainder are added to a pastebin. """ - quotes = list(self.quotedb.find({'channel': channel}, sort=[('quoteId', pymongo.DESCENDING)])) + quotes = list(self.find_quotes(nick=None, channel=channel, pattern=pattern, direction=pymongo.DESCENDING)) if not quotes: if pattern: yield 'No quotes for channel {} that match "{}"'.format(channel, pattern) @@ -255,7 +265,7 @@ def quote_summary(self, channel, pattern=None, dpaste=True): return for q in quotes[:5]: - yield self.format_quote(q, show_channel=True) + yield q.format(show_channel=True) if dpaste and len(quotes) > 5: paste_link = self.paste_quotes(quotes) @@ -267,7 +277,7 @@ def quote_summary(self, channel, pattern=None, dpaste=True): def paste_quotes(self, quotes): """ Pastebins a the last 100 quotes and returns the url """ - paste_content = '\n'.join(self.format_quote(q, show_channel=True) for q in quotes[:100]) + paste_content = '\n'.join(q.format(show_channel=True) for q in quotes[:100]) if len(quotes) > 100: paste_content = 'Latest 100 quotes:\n' + paste_content @@ -295,33 +305,20 @@ def quotes_remove(self, e): for id in ids: if id == '-1': # special case -1, to be the last - _id = self.quotedb.find_one({'channel': channel}, sort=[('quoteId', pymongo.DESCENDING)]) - if _id: - id = _id['quoteId'] + try: + q = next(self.find_quotes(nick=None, channel=channel, pattern=None, direction=pymongo.DESCENDING)) + except StopIteration: + invalid_ids.append(id) + continue + + id = q.quote_id if not self.remove_quote(id): invalid_ids.append(id) if invalid_ids: str_invalid_ids = ', '.join(str(id) for id in invalid_ids) - return e.reply('Could not remove quotes with IDs: {ids} (error: quote does not exist)'.format(ids=str_invalid_ids)) - - def remove_quote(self, quoteId): - """ Remove a given quote from the database - - Returns False if the quoteId is invalid or does not exist. - """ - - try: - id = int(quoteId) - except ValueError: - return False - else: - q = self.quote_from_id(id) - if not q: - return False - - self.quotedb.remove(q) + return e.reply('Error: could not remove quote(s) with ID: {ids}'.format(ids=str_invalid_ids)) @Plugin.hook('core.message.privmsg') def log_privmsgs(self, e): @@ -335,7 +332,8 @@ def log_privmsgs(self, e): ident = self.identify_user(user, channel) ident['message'] = msg ident['nick'] = user # even for auth'd user, save their nick - self.channel_logs[channel].appendleft(ident) + quote = QuoteRecord(None, channel, user, msg) + self.channel_logs[channel].appendleft(quote) def identify_user(self, nick, channel): """Identify a user: by account if authed, if not, by nick. Produces a dict @@ -349,3 +347,13 @@ def identify_user(self, nick, channel): else: return {'nick': nick, 'channel': channel} + +def message_matches(msg, pattern=None): + """ Check whether the given message matches the given pattern + + If there is no pattern, it is treated as a wildcard and all messages match. + """ + if pattern is None: + return True + + return re.search(pattern, msg) is not None \ No newline at end of file diff --git a/csbot/test/test_plugin_quote.py b/csbot/test/test_plugin_quote.py index be4d8419..6342b712 100644 --- a/csbot/test/test_plugin_quote.py +++ b/csbot/test/test_plugin_quote.py @@ -7,6 +7,8 @@ from csbot.util import subdict from csbot.test import BotTestCase, run_client +from csbot.plugins.quote import QuoteRecord + def failsafe(f): """forces the test to fail if not using a mock @@ -18,6 +20,25 @@ def decorator(self, *args, **kwargs): return f(self, *args, **kwargs) return decorator +class TestQuoteRecord: + def test_quote_formatter(self): + quote = QuoteRecord(quote_id=0, channel='#First', nick='Nick', message='test') + assert quote.format() == '[0] test' + assert quote.format(show_id=False) == ' test' + assert quote.format(show_channel=True) == '[0] - #First - test' + assert quote.format(show_channel=True, show_id=False) == '#First - test' + + def test_quote_deserialise(self): + udict = {'quoteId': 0, 'channel': '#First', 'message': 'test', 'nick': 'Nick'} + qr = QuoteRecord(quote_id=0, channel='#First', nick='Nick', message='test') + assert QuoteRecord.from_udict(udict) == qr + + def test_quote_serialise(self): + udict = {'quoteId': 0, 'channel': '#First', 'message': 'test', 'nick': 'Nick'} + qr = QuoteRecord(quote_id=0, channel='#First', nick='Nick', message='test') + assert qr.to_udict() == udict + + class TestQuotePlugin(BotTestCase): CONFIG = """\ [@bot] @@ -45,16 +66,11 @@ def _recv_privmsg(self, name, channel, msg): yield from self.client.line_received(':{} PRIVMSG {} :{}'.format(name, channel, msg)) def assert_sent_quote(self, channel, quote_id, quoted_user, quoted_channel, quoted_text, show_channel=False): - quote = {'quoteId': quote_id, 'channel': quoted_channel, 'message': quoted_text, 'nick': quoted_user} - self.assert_sent('NOTICE {} :{}'.format(channel, self.quote.format_quote(quote))) - - @failsafe - def test_quote_formatter(self): - quote = {'quoteId': 0, 'channel': '#First', 'message': 'test', 'nick': 'Nick'} - assert self.quote.format_quote(quote) == '[0] test' - assert self.quote.format_quote(quote, show_id=False) == ' test' - assert self.quote.format_quote(quote, show_channel=True) == '[0] - #First - test' - assert self.quote.format_quote(quote, show_channel=True, show_id=False) == '#First - test' + quote = QuoteRecord(quote_id=quote_id, + channel=quoted_channel, + nick=quoted_user, + message=quoted_text) + self.assert_sent('NOTICE {} :{}'.format(channel, quote.format())) @failsafe def test_quote_empty(self): @@ -168,10 +184,10 @@ def test_client_quotes_list(self): yield from self._recv_privmsg('Other!~user@host', '#Second', '!quote.list') - quotes = [{'nick': 'Nick', 'channel': '#Second', 'message': d, 'quoteId': i} for i, d in enumerate(data)] + quotes = [QuoteRecord(quote_id=i, channel='#Second', nick='Nick', message=d) for i, d in enumerate(data)] quotes = reversed(quotes) msgs = ['NOTICE {channel} :{msg}'.format(channel='Other', - msg=self.quote.format_quote(q, show_channel=True)) for q in quotes] + msg=q.format(show_channel=True)) for q in quotes] self.assert_sent(msgs[:5]) # manually unroll the call args to map subdict over it @@ -209,6 +225,14 @@ def test_client_quote_remove_no_permission(self): self.assert_sent('NOTICE {} :{}'.format('#First', 'error: otheraccount not authorised for #First:quote')) + @failsafe + @run_client + def test_client_quote_remove_no_quotes(self): + yield from self.client.line_received(":Nick!~user@host ACCOUNT nickaccount") + yield from self._recv_privmsg('Nick!~user@host', '#First', '!quote.remove -1') + + self.assert_sent('NOTICE {} :{}'.format('#First', 'Error: could not remove quote(s) with ID: -1')) + @failsafe @run_client def test_client_quote_list_no_permission(self): diff --git a/requirements.txt b/requirements.txt index c6fc9958..edfed7c4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,6 +8,7 @@ google-api-python-client>=1.4.1,<2.0.0 imgurpython>=1.1.6,<2.0.0 isodate>=0.5.1 rollbar +attrs # Requirements for unit testing responses