def test_get_sendable_message_optional(): text, excess = tools.get_sendable_message('aaaa', 3) assert text == 'aaa' assert excess == 'a' text, excess = tools.get_sendable_message('aaa bbb', 3) assert text == 'aaa' assert excess == 'bbb' text, excess = tools.get_sendable_message('aa bb cc', 3) assert text == 'aa' assert excess == 'bb cc'
def test_get_sendable_message_optional(): text, excess = tools.get_sendable_message('aaaa', 3) assert text == 'aaa' assert excess == 'a' text, excess = tools.get_sendable_message('aaa bbb', 3) assert text == 'aaa' assert excess == 'bbb' text, excess = tools.get_sendable_message('aa bb cc', 3) assert text == 'aa' assert excess == 'bb cc'
def test_get_sendable_message_bigger_multibyte_whitespace(): """Tests that the logic doesn't break for multi-word strings with emoji. Testing multibyte characters without whitespace is fine, but there's an alternate code path to exercise. """ text = ( 'Egg 🍳 and bacon; 🐷 egg, 🍳 sausage 🌭 and bacon; 🥓 egg 🐣 and spam; ' 'egg, 🍳 bacon 🥓 and spam, egg, 🍳 bacon, 🥓 sausage 🌭 and spam; spam, ' 'bacon, 🐖 sausage 🌭 and spam; spam, egg, 🍳 spam, spam, bacon 🐖 and ' 'spam; spam, spam, spam, egg 🥚🍳 and spam; spam, spam, spam, spam, spam, ' 'spam, baked beans, 🍛 spam, spam, spam and spam; lobster 🦞 thermidor aux ' 'crevettes with a mornay sauce garnished with truffle paté, 👨😏 brandy' 'and a fried 🍤 egg 🥚🍳 on 🔛 top 🎩 and spam') first, second = tools.get_sendable_message(text) expected_first = ( 'Egg 🍳 and bacon; 🐷 egg, 🍳 sausage 🌭 and bacon; 🥓 egg 🐣 and spam; ' 'egg, 🍳 bacon 🥓 and spam, egg, 🍳 bacon, 🥓 sausage 🌭 and spam; spam, ' 'bacon, 🐖 sausage 🌭 and spam; spam, egg, 🍳 spam, spam, bacon 🐖 and ' 'spam; spam, spam, spam, egg 🥚🍳 and spam; spam, spam, spam, spam, spam, ' 'spam, baked beans, 🍛 spam, spam, spam and spam; lobster 🦞 thermidor aux' ) expected_second = ( 'crevettes with a mornay sauce garnished with truffle paté, 👨😏 brandy' 'and a fried 🍤 egg 🥚🍳 on 🔛 top 🎩 and spam') assert first == expected_first assert second == expected_second
def test_get_sendable_message_excess_space_limit(): # aaa...aaa bbb...bbb initial = ' '.join(['a' * 400, 'b' * 200]) text, excess = tools.get_sendable_message(initial) assert text == 'a' * 400 assert excess == 'b' * 200
def test_get_sendable_message_excess_bigger(): # aaa...aaa bbb...bbb initial = ' '.join(['a' * 401, 'b' * 1000]) text, excess = tools.get_sendable_message(initial) assert text == 'a' * 400 assert excess == 'a ' + 'b' * 1000
def test_get_sendable_message_excess_bigger(): # aaa...aaa bbb...bbb initial = ' '.join(['a' * 401, 'b' * 1000]) text, excess = tools.get_sendable_message(initial) assert text == 'a' * 400 assert excess == 'a ' + 'b' * 1000
def test_get_sendable_message_excess_space_limit(): # aaa...aaa bbb...bbb initial = ' '.join(['a' * 400, 'b' * 200]) text, excess = tools.get_sendable_message(initial) assert text == 'a' * 400 assert excess == 'b' * 200
def test_get_sendable_message_four_bytes(): text, excess = tools.get_sendable_message('𡃤𡃤𡃤𡃤', 8) assert text == '𡃤𡃤' assert excess == '𡃤𡃤' text, excess = tools.get_sendable_message('𡃤𡃤𡃤𡃤', 9) assert text == '𡃤𡃤' assert excess == '𡃤𡃤' text, excess = tools.get_sendable_message('𡃤𡃤𡃤𡃤', 10) assert text == '𡃤𡃤' assert excess == '𡃤𡃤' text, excess = tools.get_sendable_message('𡃤𡃤𡃤𡃤', 11) assert text == '𡃤𡃤' assert excess == '𡃤𡃤' text, excess = tools.get_sendable_message('𡃤 𡃤𡃤𡃤', 8) assert text == '𡃤' assert excess == '𡃤𡃤𡃤' text, excess = tools.get_sendable_message('𡃤𡃤 𡃤𡃤', 8) assert text == '𡃤𡃤' assert excess == '𡃤𡃤' text, excess = tools.get_sendable_message('𡃤𡃤𡃤 𡃤', 8) assert text == '𡃤𡃤' assert excess == '𡃤 𡃤'
def test_get_sendable_message_two_bytes(): text, excess = tools.get_sendable_message('αααα', 4) assert text == 'αα' assert excess == 'αα' text, excess = tools.get_sendable_message('αααα', 5) assert text == 'αα' assert excess == 'αα' text, excess = tools.get_sendable_message('α ααα', 4) assert text == 'α' assert excess == 'ααα' text, excess = tools.get_sendable_message('αα αα', 4) assert text == 'αα' assert excess == 'αα' text, excess = tools.get_sendable_message('ααα α', 4) assert text == 'αα' assert excess == 'α α'
def test_get_sendable_message_two_bytes(): text, excess = tools.get_sendable_message('αααα', 4) assert text == 'αα' assert excess == 'αα' text, excess = tools.get_sendable_message('αααα', 5) assert text == 'αα' assert excess == 'αα' text, excess = tools.get_sendable_message('α ααα', 4) assert text == 'α' assert excess == 'ααα' text, excess = tools.get_sendable_message('αα αα', 4) assert text == 'αα' assert excess == 'αα' text, excess = tools.get_sendable_message('ααα α', 4) assert text == 'αα' assert excess == 'α α'
def gettld(bot, trigger): """Show information about the given Top Level Domain.""" tld = trigger.group(2) if not tld: bot.reply("You must provide a top-level domain to search.") return # Stop if no tld argument is provided tld = tld.strip('.').lower() if not bot.memory['tld_list_cache']: _update_tld_data(bot, 'list') tld_list = bot.memory['tld_list_cache'] if not any([ name in tld_list for name in [tld, idna.ToASCII(tld).decode('utf-8')] ]): bot.reply( "The top-level domain '{}' is not in IANA's list of valid TLDs." .format(tld)) return if not bot.memory['tld_data_cache']: _update_tld_data(bot, 'data') tld_data = bot.memory['tld_data_cache'] record = tld_data.get(tld, None) if not record: bot.say( "The top-level domain '{}' exists, " "but no details about it could be found." .format(tld) ) return # Get the current order of available data fields fields = list(record.keys()) # This trick moves matching keys to the end of the list fields.sort(key=lambda s: s.startswith('Notes') or s.startswith('Comments')) items = [] for field in fields: value = record[field] if value: items.append('{}: {}'.format(field, value)) message = ' | '.join(items) usable, excess = tools.get_sendable_message(message) if excess: message = usable + ' […]' bot.say(message)
def display(bot, term, data, num=1): try: definition = data['list'][num - 1]['definition'] except IndexError: bot.reply("Requested definition does not exist. Try a lower number.") return # This guesswork nonsense can be replaced with `bot.say()`'s `trailing` # parameter when dropping support for Sopel <7.1 msg, excess = get_sendable_message( '[urban] {term} - {definition}'.format(term=term, definition=definition), 400, ) if excess: msg += ' […]' bot.say(msg)
def test_get_sendable_message_three_bytes(): text, excess = tools.get_sendable_message('अअअअ', 6) assert text == 'अअ' assert excess == 'अअ' text, excess = tools.get_sendable_message('अअअअ', 7) assert text == 'अअ' assert excess == 'अअ' text, excess = tools.get_sendable_message('अअअअ', 8) assert text == 'अअ' assert excess == 'अअ' text, excess = tools.get_sendable_message('अ अअअ', 6) assert text == 'अ' assert excess == 'अअअ' text, excess = tools.get_sendable_message('अअ अअ', 6) assert text == 'अअ' assert excess == 'अअ' text, excess = tools.get_sendable_message('अअअ अ', 6) assert text == 'अअ' assert excess == 'अ अ'
def say( self, text: str, recipient: str, max_messages: int = 1, truncation: str = '', trailing: str = '', ) -> None: """Send a ``PRIVMSG`` to a user or channel. :param text: the text to send :param recipient: the message recipient :param max_messages: split ``text`` into at most this many messages if it is too long to fit in one (optional) :param truncation: string to append if ``text`` is too long to fit in a single message, or into the last message if ``max_messages`` is greater than 1 (optional) :param trailing: string to append after ``text`` and (if used) ``truncation`` (optional) By default, this will attempt to send the entire ``text`` in one message. If the text is too long for the server, it may be truncated. If ``max_messages`` is given, the ``text`` will be split into at most that many messages. The split is made at the last space character before the "safe length" (which is calculated based on the bot's nickname and hostmask), or exactly at the "safe length" if no such space character exists. If the ``text`` is too long to fit into the specified number of messages using the above splitting, the final message will contain the entire remainder, which may be truncated by the server. You can specify ``truncation`` to tell Sopel how it should indicate that the remaining ``text`` was cut off. Note that the ``truncation`` parameter must include leading whitespace if you desire any between it and the truncated text. The ``trailing`` parameter is *always* appended to ``text``, after the point where ``truncation`` would be inserted if necessary. It's useful for making sure e.g. a link is always included, even if the summary your plugin fetches is too long to fit. Here are some examples of how the ``truncation`` and ``trailing`` parameters work, using an artificially low maximum line length:: # bot.say() outputs <text> + <truncation?> + <trailing> # always if needed always bot.say( '"This is a short quote.', truncation=' […]', trailing='"') # Sopel says: "This is a short quote." bot.say( '"This quote is very long and will not fit on a line.', truncation=' […]', trailing='"') # Sopel says: "This quote is very long […]" bot.say( # note the " included at the end this time '"This quote is very long and will not fit on a line."', truncation=' […]') # Sopel says: "This quote is very long […] # The ending " goes missing .. versionadded:: 7.1 The ``truncation`` and ``trailing`` parameters. """ if self.backend is None: raise RuntimeError(ERR_BACKEND_NOT_INITIALIZED) excess = '' safe_length = self.safe_text_length(recipient) if not isinstance(text, str): # Make sure we are dealing with a Unicode string text = text.decode('utf-8') if max_messages > 1 or truncation or trailing: if max_messages == 1 and trailing: safe_length -= len(trailing.encode('utf-8')) text, excess = tools.get_sendable_message(text, safe_length) if max_messages == 1: if excess and truncation: # only append `truncation` if this is the last message AND it's still too long safe_length -= len(truncation.encode('utf-8')) text, excess = tools.get_sendable_message(text, safe_length) text += truncation # ALWAYS append `trailing`; # its length is included when determining if truncation happened above text += trailing flood_max_wait = self.settings.core.flood_max_wait flood_burst_lines = self.settings.core.flood_burst_lines flood_refill_rate = self.settings.core.flood_refill_rate flood_empty_wait = self.settings.core.flood_empty_wait flood_text_length = self.settings.core.flood_text_length flood_penalty_ratio = self.settings.core.flood_penalty_ratio with self.sending: recipient_id = self.make_identifier(recipient) recipient_stack = self.stack.setdefault( recipient_id, { 'messages': [], 'flood_left': flood_burst_lines, }) if recipient_stack['messages']: elapsed = time.time() - recipient_stack['messages'][-1][0] else: # Default to a high enough value that we won't care. # Five minutes should be enough not to matter anywhere below. elapsed = 300 # If flood bucket is empty, refill the appropriate number of lines # based on how long it's been since our last message to recipient if not recipient_stack['flood_left']: recipient_stack['flood_left'] = min( flood_burst_lines, int(elapsed) * flood_refill_rate) # If it's too soon to send another message, wait if not recipient_stack['flood_left']: penalty = 0 if flood_penalty_ratio > 0: penalty_ratio = flood_text_length * flood_penalty_ratio text_length_overflow = float( max(0, len(text) - flood_text_length)) penalty = text_length_overflow / penalty_ratio # Maximum wait time is 2 sec by default initial_wait_time = flood_empty_wait + penalty wait = min(initial_wait_time, flood_max_wait) if elapsed < wait: sleep_time = wait - elapsed LOGGER.debug( 'Flood protection wait time: %.3fs; ' 'elapsed time: %.3fs; ' 'initial wait time (limited to %.3fs): %.3fs ' '(including %.3fs of penalty).', sleep_time, elapsed, flood_max_wait, initial_wait_time, penalty, ) time.sleep(sleep_time) # Loop detection messages = [m[1] for m in recipient_stack['messages'][-8:]] # If what we're about to send repeated at least 5 times in the last # two minutes, replace it with '...' if messages.count(text) >= 5 and elapsed < 120: text = '...' if messages.count('...') >= 3: # If we've already said '...' 3 times, discard message return self.backend.send_privmsg(recipient, text) recipient_stack['flood_left'] = max( 0, recipient_stack['flood_left'] - 1) recipient_stack['messages'].append((time.time(), safe(text))) recipient_stack['messages'] = recipient_stack['messages'][-10:] # Now that we've sent the first part, we need to send the rest if # requested. Doing so recursively seems simpler than iteratively. if max_messages > 1 and excess: self.say(excess, recipient, max_messages - 1, truncation, trailing)
def output_user(bot, trigger, sn): client = get_client(bot) response, content = client.request( 'https://api.twitter.com/1.1/users/show.json?screen_name={}'.format( sn)) if response['status'] != '200': logger.error('%s error reaching the twitter API for screen name %s', response['status'], sn) user = json.loads(content.decode('utf-8')) if user.get('errors', []): msg = "Twitter returned an error" try: error = user['errors'][0] except IndexError: error = {} try: msg = msg + ': ' + error['message'] if msg[-1] != '.': msg = msg + '.' # some texts end with a period, but not all... thanks, Twitter except KeyError: msg = msg + '. :( Maybe that user doesn\'t exist?' bot.say(msg) logger.debug( 'Screen name {sn} returned error code {code}: "{message}"'.format( sn=sn, code=error.get('code', '-1'), message=error.get('message', '(unknown description)'))) return if user.get('url', None): url = user['entities']['url']['urls'][0][ 'expanded_url'] # Twitter c'mon, this is absurd else: url = '' joined = datetime.strptime(user['created_at'], '%a %b %d %H:%M:%S %z %Y') tz = tools.time.get_timezone(bot.db, bot.config, None, trigger.nick, trigger.sender) joined = tools.time.format_time(bot.db, bot.config, tz, trigger.nick, trigger.sender, joined) bio = user.get('description', '') if bio: for link in user['entities']['description'][ 'urls']: # bloody t.co everywhere bio = bio.replace(link['url'], link['expanded_url']) bio = tools.web.decode(bio) message = ( '[Twitter] {user[name]} (@{user[screen_name]}){verified}{protected}{location}{url}' ' | {user[friends_count]:,} friends, {user[followers_count]:,} followers' ' | {user[statuses_count]:,} tweets, {user[favourites_count]:,} ♥s' ' | Joined: {joined}{bio}').format( user=user, verified=(' ✔️' if user['verified'] else ''), protected=(' 🔒' if user['protected'] else ''), location=(' | ' + user['location'] if user.get('location', None) else ''), url=(' | ' + url if url else ''), joined=format_time(bot, trigger, user['created_at']), bio=(' | ' + bio if bio else '')) # It's unlikely to happen, but theoretically we *might* need to truncate the message if enough # of the field values are ridiculously long. Best to be safe. message, excess = tools.get_sendable_message(message) if excess: message += ' […]' bot.say(message)
def test_get_sendable_message_excess(): initial = 'a' * 401 text, excess = tools.get_sendable_message(initial) assert text == 'a' * 400 assert excess == 'a'
def test_get_sendable_message_limit(): initial = 'a' * 400 text, excess = tools.get_sendable_message(initial) assert text == initial assert excess == ''
def say(self, text, recipient, max_messages=1): """Send ``text`` as a PRIVMSG to ``recipient``. In the context of a triggered callable, the ``recipient`` defaults to the channel (or nickname, if a private message) from which the message was received. By default, this will attempt to send the entire ``text`` in one message. If the text is too long for the server, it may be truncated. If ``max_messages`` is given, the ``text`` will be split into at most that many messages, each no more than 400 bytes. The split is made at the last space character before the 400th byte, or at the 400th byte if no such space exists. If the ``text`` is too long to fit into the specified number of messages using the above splitting, the final message will contain the entire remainder, which may be truncated by the server. """ excess = '' if not isinstance(text, unicode): # Make sure we are dealing with unicode string text = text.decode('utf-8') if max_messages > 1: # Manage multi-line only when needed text, excess = tools.get_sendable_message(text) try: self.sending.acquire() # No messages within the last 3 seconds? Go ahead! # Otherwise, wait so it's been at least 0.8 seconds + penalty recipient_id = Identifier(recipient) if recipient_id not in self.stack: self.stack[recipient_id] = [] elif self.stack[recipient_id]: elapsed = time.time() - self.stack[recipient_id][-1][0] if elapsed < 3: penalty = float(max(0, len(text) - 40)) / 70 wait = 0.8 + penalty if elapsed < wait: time.sleep(wait - elapsed) # Loop detection messages = [m[1] for m in self.stack[recipient_id][-8:]] # If what we about to send repeated at least 5 times in the # last 2 minutes, replace with '...' if messages.count(text) >= 5 and elapsed < 120: text = '...' if messages.count('...') >= 3: # If we said '...' 3 times, discard message return self.write(('PRIVMSG', recipient), text) self.stack[recipient_id].append((time.time(), self.safe(text))) self.stack[recipient_id] = self.stack[recipient_id][-10:] finally: self.sending.release() # Now that we've sent the first part, we need to send the rest. Doing # this recursively seems easier to me than iteratively if excess: self.msg(recipient, excess, max_messages - 1)
def test_get_sendable_message_default(): initial = 'aaaa' text, excess = tools.get_sendable_message(initial) assert text == initial assert excess == ''
def test_get_sendable_message_excess(): initial = 'a' * 401 text, excess = tools.get_sendable_message(initial) assert text == 'a' * 400 assert excess == 'a'
def test_get_sendable_message_limit(): initial = 'a' * 400 text, excess = tools.get_sendable_message(initial) assert text == initial assert excess == ''
def say(self, text, recipient, max_messages=1): """Send ``text`` as a PRIVMSG to ``recipient``. In the context of a triggered callable, the ``recipient`` defaults to the channel (or nickname, if a private message) from which the message was received. By default, this will attempt to send the entire ``text`` in one message. If the text is too long for the server, it may be truncated. If ``max_messages`` is given, the ``text`` will be split into at most that many messages, each no more than 400 bytes. The split is made at the last space character before the 400th byte, or at the 400th byte if no such space exists. If the ``text`` is too long to fit into the specified number of messages using the above splitting, the final message will contain the entire remainder, which may be truncated by the server. """ excess = '' if not isinstance(text, unicode): # Make sure we are dealing with unicode string text = text.decode('utf-8') if max_messages > 1: # Manage multi-line only when needed text, excess = tools.get_sendable_message(text) try: self.sending.acquire() # No messages within the last 3 seconds? Go ahead! # Otherwise, wait so it's been at least 0.8 seconds + penalty recipient_id = Identifier(recipient) if recipient_id not in self.stack: self.stack[recipient_id] = [] elif self.stack[recipient_id]: elapsed = time.time() - self.stack[recipient_id][-1][0] if elapsed < 3: penalty = float(max(0, len(text) - 40)) / 70 wait = min(0.8 + penalty, 2) # Never wait more than 2 seconds if elapsed < wait and self.config.core.message_throttle: time.sleep(wait - elapsed) # Loop detection messages = [m[1] for m in self.stack[recipient_id][-8:]] # If what we about to send repeated at least 5 times in the # last 2 minutes, replace with '...' if messages.count( text ) >= 5 and elapsed < 120 and self.config.core.message_loop_detection: text = '...' if messages.count('...') >= 3: # If we said '...' 3 times, discard message return self.write(('PRIVMSG', recipient), text) self.stack[recipient_id].append((time.time(), self.safe(text))) self.stack[recipient_id] = self.stack[recipient_id][-10:] finally: self.sending.release() # Now that we've sent the first part, we need to send the rest. Doing # this recursively seems easier to me than iteratively if excess: self.say(excess, max_messages - 1, recipient)
def test_get_sendable_message_default(): initial = 'aaaa' text, excess = tools.get_sendable_message(initial) assert text == initial assert excess == ''
def say(self, text, recipient, max_messages=1, trailing=''): """Send a PRIVMSG to a user or channel. :param str text: the text to send :param str recipient: the message recipient :param int max_messages: split ``text`` into at most this many messages if it is too long to fit in one (optional) :param str trailing: text to append if ``text`` is too long to fit in a single message, or into the last message if ``max_messages`` is greater than 1 (optional) By default, this will attempt to send the entire ``text`` in one message. If the text is too long for the server, it may be truncated. If ``max_messages`` is given, the ``text`` will be split into at most that many messages. The split is made at the last space character before the "safe length" (which is calculated based on the bot's nickname and hostmask), or exactly at the "safe length" if no such space character exists. If the ``text`` is too long to fit into the specified number of messages using the above splitting, the final message will contain the entire remainder, which may be truncated by the server. The ``trailing`` parameter allows gracefully terminating a ``text`` that is too long to fit in the specified number of messages. The final message (or only message, if ``max_messages`` is left at the default value of 1) will be truncated slightly to fit the ``trailing`` string. Note that the ``trailing`` parameter must include leading whitespace if you desire any between it and the truncated text. """ excess = '' if not isinstance(text, unicode): # Make sure we are dealing with a Unicode string text = text.decode('utf-8') if max_messages > 1 or trailing: # Handle message splitting/truncation only if needed try: hostmask_length = len(self.hostmask) except KeyError: # calculate maximum possible length, given current nick/username hostmask_length = ( len(self.nick) # own nick length + 1 # (! separator) + 1 # (for the optional ~ in user) + min( # own ident length, capped to ISUPPORT or RFC maximum len(self.user), getattr(self.isupport, 'USERLEN', 9)) + 1 # (@ separator) + 63 # <hostname> has a maximum length of 63 characters. ) safe_length = ( 512 # maximum IRC line length in bytes, per RFC - 1 # leading colon - hostmask_length # calculated/maximum length of own hostmask prefix - 1 # space between prefix & command - 7 # PRIVMSG command - 1 # space before recipient - len(recipient.encode( 'utf-8')) # target channel/nick (can contain Unicode) - 2 # space after recipient, colon before text - 2 # trailing CRLF ) text, excess = tools.get_sendable_message(text, safe_length) if max_messages == 1 and excess and trailing: # only append `trailing` if this is the last message AND it's still too long safe_length -= len(trailing.encode('utf-8')) text, excess = tools.get_sendable_message(text, safe_length) text += trailing flood_max_wait = self.settings.core.flood_max_wait flood_burst_lines = self.settings.core.flood_burst_lines flood_refill_rate = self.settings.core.flood_refill_rate flood_empty_wait = self.settings.core.flood_empty_wait flood_text_length = self.settings.core.flood_text_length flood_penalty_ratio = self.settings.core.flood_penalty_ratio with self.sending: recipient_id = tools.Identifier(recipient) recipient_stack = self.stack.setdefault( recipient_id, { 'messages': [], 'flood_left': flood_burst_lines, }) if recipient_stack['messages']: elapsed = time.time() - recipient_stack['messages'][-1][0] else: # Default to a high enough value that we won't care. # Five minutes should be enough not to matter anywhere below. elapsed = 300 # If flood bucket is empty, refill the appropriate number of lines # based on how long it's been since our last message to recipient if not recipient_stack['flood_left']: recipient_stack['flood_left'] = min( flood_burst_lines, int(elapsed) * flood_refill_rate) # If it's too soon to send another message, wait if not recipient_stack['flood_left']: penalty = 0 if flood_penalty_ratio > 0: penalty_ratio = flood_text_length * flood_penalty_ratio text_length_overflow = float( max(0, len(text) - flood_text_length)) penalty = text_length_overflow / penalty_ratio # Maximum wait time is 2 sec by default initial_wait_time = flood_empty_wait + penalty wait = min(initial_wait_time, flood_max_wait) if elapsed < wait: sleep_time = wait - elapsed LOGGER.debug( 'Flood protection wait time: %.3fs; ' 'elapsed time: %.3fs; ' 'initial wait time (limited to %.3fs): %.3fs ' '(including %.3fs of penalty).', sleep_time, elapsed, flood_max_wait, initial_wait_time, penalty, ) time.sleep(sleep_time) # Loop detection messages = [m[1] for m in recipient_stack['messages'][-8:]] # If what we're about to send repeated at least 5 times in the last # two minutes, replace it with '...' if messages.count(text) >= 5 and elapsed < 120: text = '...' if messages.count('...') >= 3: # If we've already said '...' 3 times, discard message return self.backend.send_privmsg(recipient, text) recipient_stack['flood_left'] = max( 0, recipient_stack['flood_left'] - 1) recipient_stack['messages'].append((time.time(), safe(text))) recipient_stack['messages'] = recipient_stack['messages'][-10:] # Now that we've sent the first part, we need to send the rest if # requested. Doing so recursively seems simpler than iteratively. if max_messages > 1 and excess: self.say(excess, recipient, max_messages - 1, trailing)
def say(self, text, recipient, max_messages=1): """Send a PRIVMSG to a user or channel. :param str text: the text to send :param str recipient: the message recipient :param int max_messages: the maximum number of messages to break the text into In the context of a triggered callable, the ``recipient`` defaults to the channel (or nickname, if a private message) from which the message was received. By default, this will attempt to send the entire ``text`` in one message. If the text is too long for the server, it may be truncated. If ``max_messages`` is given, the ``text`` will be split into at most that many messages, each no more than 400 bytes. The split is made at the last space character before the 400th byte, or at the 400th byte if no such space exists. If the ``text`` is too long to fit into the specified number of messages using the above splitting, the final message will contain the entire remainder, which may be truncated by the server. """ excess = '' if not isinstance(text, unicode): # Make sure we are dealing with unicode string text = text.decode('utf-8') if max_messages > 1: # Manage multi-line only when needed text, excess = tools.get_sendable_message(text) try: self.sending.acquire() recipient_id = Identifier(recipient) recipient_stack = self.stack.setdefault( recipient_id, { 'messages': [], 'flood_left': self.config.core.flood_burst_lines, }) if recipient_stack['messages']: elapsed = time.time() - recipient_stack['messages'][-1][0] else: # Default to a high enough value that we won't care. # Five minutes should be enough not to matter anywhere below. elapsed = 300 # If flood bucket is empty, refill the appropriate number of lines # based on how long it's been since our last message to recipient if not recipient_stack['flood_left']: recipient_stack['flood_left'] = min( self.config.core.flood_burst_lines, int(elapsed) * self.config.core.flood_refill_rate) # If it's too soon to send another message, wait if not recipient_stack['flood_left']: penalty = float(max(0, len(text) - 50)) / 70 wait = min(self.config.core.flood_empty_wait + penalty, 2) # Maximum wait time is 2 sec if elapsed < wait: time.sleep(wait - elapsed) # Loop detection messages = [m[1] for m in recipient_stack['messages'][-8:]] # If what we're about to send repeated at least 5 times in the last # two minutes, replace it with '...' if messages.count(text) >= 5 and elapsed < 120: text = '...' if messages.count('...') >= 3: # If we've already said '...' 3 times, discard message return self.write(('PRIVMSG', recipient), text) recipient_stack['flood_left'] = max( 0, recipient_stack['flood_left'] - 1) recipient_stack['messages'].append((time.time(), self.safe(text))) recipient_stack['messages'] = recipient_stack['messages'][-10:] finally: self.sending.release() # Now that we've sent the first part, we need to send the rest. Doing # this recursively seems easier to me than iteratively if excess: self.say(excess, max_messages - 1, recipient)
def say(self, text, recipient, max_messages=1): """Send a PRIVMSG to a user or channel. :param str text: the text to send :param str recipient: the message recipient :param int max_messages: split ``text`` into at most this many messages if it is too long to fit in one (optional) By default, this will attempt to send the entire ``text`` in one message. If the text is too long for the server, it may be truncated. If ``max_messages`` is given, the ``text`` will be split into at most that many messages, each no more than 400 bytes. The split is made at the last space character before the 400th byte, or at the 400th byte if no such space exists. If the ``text`` is too long to fit into the specified number of messages using the above splitting, the final message will contain the entire remainder, which may be truncated by the server. """ excess = '' if not isinstance(text, unicode): # Make sure we are dealing with a Unicode string text = text.decode('utf-8') if max_messages > 1: # Manage multi-line only when needed text, excess = tools.get_sendable_message(text) flood_max_wait = self.settings.core.flood_max_wait flood_burst_lines = self.settings.core.flood_burst_lines flood_refill_rate = self.settings.core.flood_refill_rate flood_empty_wait = self.settings.core.flood_empty_wait flood_text_length = self.settings.core.flood_text_length flood_penalty_ratio = self.settings.core.flood_penalty_ratio with self.sending: recipient_id = tools.Identifier(recipient) recipient_stack = self.stack.setdefault( recipient_id, { 'messages': [], 'flood_left': flood_burst_lines, }) if recipient_stack['messages']: elapsed = time.time() - recipient_stack['messages'][-1][0] else: # Default to a high enough value that we won't care. # Five minutes should be enough not to matter anywhere below. elapsed = 300 # If flood bucket is empty, refill the appropriate number of lines # based on how long it's been since our last message to recipient if not recipient_stack['flood_left']: recipient_stack['flood_left'] = min( flood_burst_lines, int(elapsed) * flood_refill_rate) # If it's too soon to send another message, wait if not recipient_stack['flood_left']: penalty = 0 if flood_penalty_ratio > 0: penalty_ratio = flood_text_length * flood_penalty_ratio text_length_overflow = float( max(0, len(text) - flood_text_length)) penalty = text_length_overflow / penalty_ratio # Maximum wait time is 2 sec by default initial_wait_time = flood_empty_wait + penalty wait = min(initial_wait_time, flood_max_wait) if elapsed < wait: sleep_time = wait - elapsed LOGGER.debug( 'Flood protection wait time: %.3fs; ' 'elapsed time: %.3fs; ' 'initial wait time (limited to %.3fs): %.3fs ' '(including %.3fs of penalty).', sleep_time, elapsed, flood_max_wait, initial_wait_time, penalty, ) time.sleep(sleep_time) # Loop detection messages = [m[1] for m in recipient_stack['messages'][-8:]] # If what we're about to send repeated at least 5 times in the last # two minutes, replace it with '...' if messages.count(text) >= 5 and elapsed < 120: text = '...' if messages.count('...') >= 3: # If we've already said '...' 3 times, discard message return self.backend.send_privmsg(recipient, text) recipient_stack['flood_left'] = max( 0, recipient_stack['flood_left'] - 1) recipient_stack['messages'].append((time.time(), safe(text))) recipient_stack['messages'] = recipient_stack['messages'][-10:] # Now that we've sent the first part, we need to send the rest. Doing # this recursively seems easier to me than iteratively if excess: self.say(excess, recipient, max_messages - 1)