diff --git a/src/csbot/plugins/quote.py b/src/csbot/plugins/quote.py new file mode 100644 index 0000000..8e7e9cf --- /dev/null +++ b/src/csbot/plugins/quote.py @@ -0,0 +1,347 @@ +import re +import random +import functools +import collections + +import attr +import pymongo +import requests + +from csbot.plugin import Plugin +from csbot.util import nick + + +@attr.s +class QuoteRecord: + quote_id = attr.ib() + channel = attr.ib() + nick = attr.ib() + message = attr.ib() + + def format(self, show_channel=False, show_id=True): + """ Formats a quote into a prettified string. + + >>> self.format() + "[3] some silly quote..." + >>> self.format(show_channel=True, show_id=False) + "#test - silly quote" + """ + if show_channel and show_id: + fmt = '[{quoteId}] - {channel} - <{nick}> {message}' + elif show_channel and not show_id: + fmt = '{channel} - <{nick}> {message}' + elif not show_channel and show_id: + fmt = '[{quoteId}] <{nick}> {message}' + else: + fmt = '<{nick}> {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 + + 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.replace_one({'header': 'currentQuoteId'}, + {'header': 'currentQuoteId', 'maxQuoteId': id}, + upsert=True) + + 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, quote): + """ 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() + sId = id + 1 + quote.quote_id = sId + self.quotedb.insert_one(quote.to_udict()) + self.set_current_quote_id(sId) + return sId + + 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.delete_one({'quoteId': q.quote_id}) + + return True + + 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 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'] + + 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. + """ + 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") + def remember(self, e): + """ Remembers the last matching quote from a user + """ + data = e['data'].strip() + channel = e['channel'] + user_nick = nick(e['user']) + + m = re.fullmatch(r'(?P\S+)', data) + if m: + return self.remember_quote(e, user_nick, m.group('nick'), channel, None) + + m = re.fullmatch(r'(?P\S+)\s+(?P.+)', data) + if m: + return self.remember_quote(e, user_nick, m.group('nick'), channel, m.group('pattern').strip()) + + e.reply('Error: invalid command') + + def remember_quote(self, e, user, nick, channel, pattern): + 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(quote.format(show_id=False))) + + @Plugin.command('quote', help=("quote [ []]: looks up quotes from " + " (optionally only those matching )")) + def quote(self, e): + """ Lookup quotes for the given channel/nick and outputs one + """ + data = e['data'] + channel = e['channel'] + + if data.strip() == '': + return e.reply(self.find_a_quote(None, channel, None)) + + m = re.fullmatch(r'(?P\S+)', data) + if m: + return e.reply(self.find_a_quote(m.group('nick'), channel, None)) + + m = re.fullmatch(r'(?P\S+)\s+(?P.+)', data) + if m: + return e.reply(self.find_a_quote(m.group('nick'), channel, m.group('pattern'))) + + def find_a_quote(self, nick, channel, pattern): + """ Finds a random matching quote from a user on a specific channel + + Returns the formatted quote string + """ + res = list(self.find_quotes(nick, channel, pattern)) + if not res: + if nick is None: + return 'No data' + else: + return 'No data for {}'.format(nick) + else: + out = random.choice(res) + 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): + """ 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']) + + if not self.bot.plugins['auth'].check_or_error(e, 'quote', channel): + return + + if channel == self.bot.nick: + # first argument must be a channel + data = e['data'].split(maxsplit=1) + + # TODO: use assignment expressions here when they come out + # https://www.python.org/dev/peps/pep-0572/ + just_channel = re.fullmatch(r'(?P\S+)', data) + channel_and_pat = re.fullmatch(r'(?P\S+)\s+(?P.+)', data) + if just_channel: + return self.reply_with_summary(nick_, just_channel.group('channel'), None) + elif channel_and_pat: + return self.reply_with_summary(nick_, + channel_and_pat.group('channel'), + channel_and_pat.group('pattern')) + + return e.reply('Invalid command. Syntax in privmsg is !quote.list []') + else: + pattern = e['data'] + return self.reply_with_summary(nick_, channel, pattern) + + def reply_with_summary(self, to, channel, pattern): + """ Helper to list all quotes for a summary paste. + """ + for line in self.quote_summary(channel, pattern=pattern): + self.bot.reply(to, line) + + def quote_summary(self, channel, pattern=None, dpaste=True): + """ Search through all quotes for a channel and optionally paste a list of them + + Returns the last 5 matching quotes only, the remainder are added to a pastebin. + """ + quotes = list(self.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) + else: + yield 'No quotes for channel {}'.format(channel) + + return + + for q in quotes[:5]: + yield q.format(show_channel=True) + + 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(q.format(show_channel=True) for q in quotes[:100]) + if len(quotes) > 100: + paste_content = 'Latest 100 quotes:\n' + paste_content + + req = requests.post('http://dpaste.com/api/v2/', {'content': paste_content}) + if req: + return req.content.decode('utf-8').strip() + + return req # return the failed request to handle error later + + @Plugin.command('quote.remove', help=("quote.remove [, ]*: removes quotes from the database")) + def quotes_remove(self, e): + """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('No quoteID supplied') + + ids = [qId.strip() for qId in data] + invalid_ids = [] + for id in ids: + if id == '-1': + # special case -1, to be the last + 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('Error: could not remove quote(s) with ID: {ids}'.format(ids=str_invalid_ids)) + + @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']) + 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 + + 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 diff --git a/src/csbot/util.py b/src/csbot/util.py index 4c01167..4003bfe 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 0000000..da4a4ff --- /dev/null +++ b/tests/test_plugin_quote.py @@ -0,0 +1,230 @@ +import asyncio +from unittest import mock + +import mongomock +import pytest + +from csbot.plugins.quote import QuoteRecord +from csbot.util import subdict + + +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: + 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.bot_helper.assert_sent('NOTICE {} :{}'.format(channel, quote.format())) + + def test_quote_empty(self): + assert list(self.quote.find_quotes('noQuotesForMe', '#anyChannel')) == [] + + 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') + + async def test_client_quote_remember_send_privmsg(self): + await self._recv_privmsg('Nick!~user@host', '#First', 'test data') + await self._recv_privmsg('Other!~user@host', '#First', '!remember Nick') + self.bot_helper.assert_sent('NOTICE Other :remembered " test data"') + + 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') + + await self._recv_privmsg('Nick!~user@host', '#First', 'test data#2') + await self._recv_privmsg('Other!~user@host', '#First', '!remember Nick') + + await self._recv_privmsg('Nick!~user@host', '#First', '!quote Nick data#2') + self.assert_sent_quote('#First', 1, 'Nick', '#First', 'test data#2') + + async def test_client_quotes_not_exist(self): + await self._recv_privmsg('Nick!~user@host', '#First', '!quote 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') + 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') + + 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') + + await self._recv_privmsg('Other!~user@host', '#Second', '!remember 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 #Second :No data for Nick') + + 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') + + 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') + + 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') + + async def test_client_quote_channel_fill_logs(self): + for i in range(150): + 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') + self.assert_sent_quote('#Second', 0, 'Nick', '#Second', 'other data#135') + + async def test_client_quotes_format(self): + """make sure the format !quote.list yields is readable and goes to the right place + """ + await self._recv_line(":Other!~other@otherhost ACCOUNT otheraccount") + + await self._recv_privmsg('Nick!~user@host', '#Second', 'data test') + await self._recv_privmsg('Other!~user@host', '#Second', '!remember Nick') + + await self._recv_privmsg('Other!~user@host', '#Second', '!quote.list') + self.bot_helper.assert_sent('NOTICE Other :[0] - #Second - data test') + + async def test_client_quotes_list(self): + """ensure the list !quote.list sends is short and redirects to pastebin + """ + 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 = [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') + + 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.bot_helper.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) + + async def test_client_quote_remove(self): + await self._recv_line(":Nick!~user@host ACCOUNT nickaccount") + + 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('Nick!~user@host', '#First', 'test data#2') + await self._recv_privmsg('Other!~user@host', '#First', '!remember Nick') + + await self._recv_privmsg('Nick!~user@host', '#First', '!quote.remove -1') + 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 #First :No data for Nick') + + async def test_client_quote_remove_no_permission(self): + await self._recv_line(":Other!~other@otherhost ACCOUNT otheraccount") + + 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.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 #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") + + 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.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!') + 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!') + + 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') + + await self._recv_privmsg('Other!~other@host', '#First', 'other data') + await self._recv_privmsg('Nick!~user@host', '#First', '!remember Other') + + await self._recv_privmsg('Other!~other@host', '#First', '!quote * other') + self.assert_sent_quote('#First', 1, 'Other', '#First', 'other data')