Repository: sopel-irc/sopel

Issue:
tell: argument splitting/stripping is a bit broken
This code line removes most IRC formatting bytes from the beginning of the message:

https://github.com/sopel-irc/sopel/blob/f29c10dde2ff166dee15bc49f0cd43c93b93ba3e/sopel/modules/tell.py#L145

It's similar to another issue (#1877) we fixed in `choose` for 7.1 (#1965). The second `.lstrip()` with no arguments needs to be replaced with this plugin's own version of [`_format_safe()`](https://github.com/sopel-irc/sopel/blob/f29c10dde2ff166dee15bc49f0cd43c93b93ba3e/sopel/modules/choose.py#L19-L69) from `choose.py`.

In fact, it wouldn't take much at this point to convince me that Sopel's API should include its own formatting-safe versions of `strip()`, `lstrip()`, and `rstrip()`. Issues like this keep coming up, and stuff like `trigger.plain` won't help…

----

That line also misuses `.lstrip()` with the tellee's nick. That isn't a huge issue, because it's never going to go past the whitespace, but it could cause other quirks like the fact that punctuation gets through even though the code's _intent_ appears to be treating the tellee as a separate argument that isn't part of the message:

```irclog
<~dgw> ;tell Creator|TL, comma test
[…]
<&Kaede> [tell] Creator|TL: Monday, July 12, 2021 22:52:44 (IST) <dgw> tell Creator|TL , comma test
```




Discussion:

Resolving patch:
diff --git a/sopel/modules/tell.py b/sopel/modules/tell.py
--- a/sopel/modules/tell.py
+++ b/sopel/modules/tell.py
@@ -14,8 +14,9 @@
 import os
 import threading
 import time
+import unicodedata
 
-from sopel import plugin, tools
+from sopel import formatting, plugin, tools
 from sopel.config import types
 from sopel.tools.time import format_time, get_timezone
 
@@ -129,6 +130,43 @@ def shutdown(bot):
             pass
 
 
+def _format_safe_lstrip(text):
+    """``str.lstrip()`` but without eating IRC formatting.
+
+    :param str text: text to clean
+    :rtype: str
+    :raises TypeError: if the passed ``text`` is not a string
+
+    Stolen and tweaked from the ``choose`` plugin's ``_format_safe()``
+    function by the person who wrote it.
+    """
+    if not isinstance(text, str):
+        raise TypeError("A string is required.")
+    elif not text:
+        # unnecessary optimization
+        return ''
+
+    start = 0
+
+    # strip left
+    pos = 0
+    while pos < len(text):
+        is_whitespace = unicodedata.category(text[pos]) == 'Zs'
+        is_non_printing = (
+            text[pos] in formatting.CONTROL_NON_PRINTING and
+            text[pos] not in formatting.CONTROL_FORMATTING
+        )
+        if not is_whitespace and not is_non_printing:
+            start = pos
+            break
+        pos += 1
+    else:
+        # skipped everything; string is all whitespace
+        return ''
+
+    return text[start:]
+
+
 @plugin.command('tell', 'ask')
 @plugin.nickname_command('tell', 'ask')
 @plugin.example('$nickname, tell dgw he broke something again.')
@@ -142,7 +180,7 @@ def f_remind(bot, trigger):
         return
 
     tellee = trigger.group(3).rstrip('.,:;')
-    msg = trigger.group(2).lstrip(tellee).lstrip()
+    msg = _format_safe_lstrip(trigger.group(2).split(' ', 1)[1])
 
     if not msg:
         bot.reply("%s %s what?" % (verb, tellee))


Test patch:
diff --git a/test/modules/test_modules_tell.py b/test/modules/test_modules_tell.py
--- a/test/modules/test_modules_tell.py
+++ b/test/modules/test_modules_tell.py
@@ -5,6 +5,9 @@
 import io
 import os
 
+import pytest
+
+from sopel import formatting
 from sopel.modules import tell
 
 
@@ -228,3 +231,102 @@ def test_get_reminders():
         'Exirel: '
         '%s - 14:35:55UTC '
         '<HumorBaby> tell Exirel Thanks for the review.' % today)
+
+
+# Test custom lstrip implementation
+
+UNICODE_ZS_CATEGORY = [
+    '\u0020',  # SPACE
+    '\u00A0',  # NO-BREAK SPACE
+    '\u1680',  # OGHAM SPACE MARK
+    '\u2000',  # EN QUAD
+    '\u2001',  # EM QUAD
+    '\u2002',  # EN SPACE
+    '\u2003',  # EM SPACE
+    '\u2004',  # THREE-PER-EM SPACE
+    '\u2005',  # FOUR-PER-EM SPACE
+    '\u2006',  # SIX-PER-EM SPACE
+    '\u2007',  # FIGURE SPACE
+    '\u2008',  # PUNCTUATION SPACE
+    '\u2009',  # THIN SPACE
+    '\u200A',  # HAIR SPACE
+    '\u202F',  # NARROW NO-BREAK SPACE
+    '\u205F',  # MEDIUM MATHEMATICAL SPACE
+    '\u3000',  # IDEOGRAPHIC SPACE
+]
+
+SAFE_PAIRS = (
+    # regression checks vs. old string.lstrip()
+    ('',
+     ''),
+    ('a',  # one iteration of this code returned '' for one-char strings
+     'a'),
+    ('aa',
+     'aa'),
+    # basic whitespace
+    ('  leading space',  # removed
+     'leading space'),
+    ('trailing space ',  # kept
+     'trailing space '),
+    (' leading AND trailing space  ',  # removed AND kept
+     'leading AND trailing space  '),
+    # advanced whitespace
+    ('\tleading tab',  # removed
+     'leading tab'),
+    ('trailing tab\t',  # kept
+     'trailing tab\t'),
+    # whitespace inside formatting (kept)
+    ('\x02  leading space inside formatting\x02',
+     '\x02  leading space inside formatting\x02'),
+    ('\x02trailing space inside formatting  \x02',
+     '\x02trailing space inside formatting  \x02'),
+    ('\x02  leading AND trailing inside formatting  \x02',
+     '\x02  leading AND trailing inside formatting  \x02'),
+    # whitespace outside formatting
+    ('  \x02leading space outside formatting\x02',  # removed
+     '\x02leading space outside formatting\x02'),
+    ('\x02trailing space outside formatting\x02  ',  # kept
+     '\x02trailing space outside formatting\x02  '),
+    # whitespace both inside and outside formatting
+    ('  \x02  leading space inside AND outside\x02',  # outside removed
+     '\x02  leading space inside AND outside\x02'),
+    ('\x02trailing space inside AND outside  \x02  ',  # left alone
+     '\x02trailing space inside AND outside  \x02  '),
+    ('  \x02  leading AND trailing inside AND outside  \x02  ',  # only leading removed
+     '\x02  leading AND trailing inside AND outside  \x02  '),
+)
+
+
+def test_format_safe_lstrip_basic():
+    """Test handling of basic whitespace."""
+    assert tell._format_safe_lstrip(
+        ''.join(UNICODE_ZS_CATEGORY)) == ''
+
+
+def test_format_safe_lstrip_control():
+    """Test handling of non-printing control characters."""
+    all_formatting = ''.join(formatting.CONTROL_FORMATTING)
+
+    # no formatting chars should be stripped,
+    # but a reset should be added to the end
+    assert tell._format_safe_lstrip(all_formatting) == all_formatting
+
+    # control characters not recognized as formatting should be stripped
+    assert tell._format_safe_lstrip(
+        ''.join(
+            c
+            for c in formatting.CONTROL_NON_PRINTING
+            if c not in formatting.CONTROL_FORMATTING
+        )) == ''
+
+
+def test_format_safe_lstrip_invalid_arg():
+    """Test for correct exception if non-string is passed."""
+    with pytest.raises(TypeError):
+        tell._format_safe_lstrip(None)
+
+
+@pytest.mark.parametrize('text, cleaned', SAFE_PAIRS)
+def test_format_safe_lstrip_pairs(text, cleaned):
+    """Test expected formatting-safe string sanitization."""
+    assert tell._format_safe_lstrip(text) == cleaned